QML

Shader-эффекты глубже: кастомные визуальные переходы между экранами

Введение

Основной цикл статей про Qt Widgets/QML (статья 89) уже касался базовых shader-эффектов через ShaderEffect. В этой статье углубимся в применение shader-эффектов специально для визуальных переходов между экранами приложения — эффектов растворения, пикселизации, искажения при смене состояния StackView или смене целого экрана, которые сложно реализовать через обычные QML-анимации свойств.

Концепция

ShaderEffect в Qt Quick позволяет применить произвольный фрагментный шейдер (написанный на GLSL или, в более новых версиях Qt с RHI, скомпилированный через инструмент qsb) к визуальному содержимому QML-элемента. Для переходов между экранами типичный подход — захватить оба экрана (старый и новый) как текстуры через ShaderEffectSource, и передать обе текстуры в шейдер, который вычисляет, как именно смешивать пиксели одного изображения с другим в зависимости от анимируемого параметра прогресса перехода (от 0.0 — полностью старый экран, до 1.0 — полностью новый).

Пример кода: Shader-эффекты глубже

// dissolve.frag.qsb — шейдер эффекта "растворения" одного экрана в другой
// (исходный GLSL-код, компилируемый через qsb в формат, используемый Qt Quick RHI)

/*
#version 440
layout(location = 0) in vec2 qt_TexCoord0;
layout(location = 0) out vec4 fragColor;

layout(std140, binding = 0) uniform buf {
    mat4 qt_Matrix;
    float qt_Opacity;
    float progress; // 0.0 — полностью текущий экран, 1.0 — полностью новый
};

layout(binding = 1) uniform sampler2D currentScreen;
layout(binding = 2) uniform sampler2D nextScreen;
layout(binding = 3) uniform sampler2D noiseTexture; // случайный шум для неравномерного растворения

void main() {
    vec4 noise = texture(noiseTexture, qt_TexCoord0);
    vec4 fromColor = texture(currentScreen, qt_TexCoord0);
    vec4 toColor = texture(nextScreen, qt_TexCoord0);

    // Пиксель "переключается" на новый экран не равномерно, а в момент,
    // когда прогресс перехода превышает случайное значение шума для этого пикселя —
    // создаёт неравномерный, органичный эффект растворения, а не плоское затухание
    float threshold = noise.r;
    fragColor = (progress > threshold) ? toColor : fromColor;
    fragColor *= qt_Opacity;
}
*/
// ScreenTransition.qml — использование скомпилированного шейдера для перехода между экранами
import QtQuick

Item {
    id: root
    anchors.fill: parent

    property Item currentScreen
    property Item nextScreen

    ShaderEffectSource {
        id: currentScreenSource
        sourceItem: root.currentScreen
        hideSource: true
        live: false // захватываем как статичный снимок именно в момент начала перехода
    }

    ShaderEffectSource {
        id: nextScreenSource
        sourceItem: root.nextScreen
        hideSource: true
        live: false
    }

    ShaderEffect {
        id: transitionEffect
        anchors.fill: parent
        property variant currentScreen: currentScreenSource
        property variant nextScreen: nextScreenSource
        property variant noiseTexture: noiseTextureSource
        property real progress: 0.0

        fragmentShader: "qrc:/shaders/dissolve.frag.qsb"
    }

    Image {
        id: noiseTextureImage
        source: "qrc:/textures/noise.png"
        visible: false
    }
    ShaderEffectSource { id: noiseTextureSource; sourceItem: noiseTextureImage; live: false }

    function startTransition()
    {
        transitionEffect.progress = 0.0;
        progressAnimation.start();
    }

    NumberAnimation {
        id: progressAnimation
        target: transitionEffect
        property: "progress"
        from: 0.0
        to: 1.0
        duration: 800
        easing.type: Easing.InOutQuad
        onFinished: root.nextScreen.visible = true // финальная фиксация состояния после перехода
    }
}

Пояснения к коду

Закомментированный GLSL-код шейдера показывает логику «органичного» растворения — вместо равномерного линейного смешивания (mix(fromColor, toColor, progress), что дало бы плоский, равномерный crossfade) используется текстура случайного шума: для каждого пикселя сравнивается текущий прогресс перехода с предварительно сгенерированным случайным значением шума именно для этого пикселя, и пиксель «переключается» на новое изображение в момент, когда прогресс превышает это случайное значение — пиксели с низким значением шума переключаются первыми, с высоким — последними, создавая неравномерный, визуально интересный эффект растворения.

ScreenTransition.qml показывает интеграцию шейдера в QML — два ShaderEffectSource захватывают визуальное содержимое старого и нового экрана как текстуры (с live: false, поскольку нужен статичный снимок на момент начала перехода, а не постоянно обновляемое живое содержимое), и NumberAnimation плавно изменяет свойство progress от 0 до 1, при каждом шаге передавая новое значение в шейдер через привязанное свойство ShaderEffect, что и создаёт анимированный переход.

Подводные камни

  • Компиляция шейдеров через qsb — обязательный шаг для современных версий Qt Quick, использующих RHI (Rendering Hardware Interface) вместо прямого OpenGL — простой .frag-файл с GLSL-кодом сам по себе не работает напрямую как fragmentShader в современных версиях Qt, и требует предварительной компиляции инструментом qsb из Qt SDK в промежуточный, платформонезависимый байткод-формат .qsb, который и используется в реальном QML-коде.
  • hideSource: true для ShaderEffectSource скрывает оригинальный элемент, что может создать путаницу, если тот же элемент (currentScreen/nextScreen) используется где-то ещё в иерархии сцены, ожидая, что он останется видимым — нужно явно учитывать, что захват элемента как источника текстуры для шейдера имеет побочный эффект скрытия оригинала, если не отключить это поведение явным указанием hideSource: false, когда оригинал должен остаться видимым отдельно.
  • Производительность shader-эффектов на embedded-устройствах со слабым GPU может быть значительно ниже, чем на десктопе — сложные шейдеры с несколькими текстурными выборками (как в примере с тремя текстурами — текущий экран, новый экран, шум) умножают нагрузку на каждый отрисовываемый пиксель, и для embedded-целевых платформ (основной цикл статей про embedded, статья 242) такие визуально насыщенные переходы стоит явно профилировать и при необходимости заменять на более простые, менее ресурсоёмкие альтернативы.
  • Отсутствие fallback-варианта для платформ или конфигураций, где кастомные шейдеры не поддерживаются или работают нестабильно (некоторые старые встроенные GPU, нестандартные графические драйверы) — для критичной по надёжности функциональности переходов стоит предусмотреть более простой, гарантированно работающий запасной вариант перехода (обычный crossfade через opacity), активируемый при обнаружении проблем с шейдерным рендерингом на конкретном устройстве.