О чем этот пример

Визуальные эффекты в играх часто требуют многоуровневой постобработки, где результат одного шейдера служит входными данными для другого. Phaser 3 предоставляет для этого мощный, но малоизвестный инструмент — систему шейдерных буферов. Эта статья на практическом примере покажет, как связать два GLSL-шейдера в цепочку, создавая бесконечную анимацию с обратной связью. Вы научитесь рендерить шейдеры в текстуры и передавать их между собой, открывая путь к сложным эффектам вроде плазмы, водных симуляций или психоделических переходов.

Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.

Живой запуск

Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.

Исходный код


class Example extends Phaser.Scene
{
    create ()
    {
        const s1 = `
    precision mediump float;

    uniform float time;
    uniform vec2 resolution;
    uniform sampler2D iChannel0;

    varying vec2 outTexCoord;

    #define iTime time
    #define iResolution resolution

    vec4 texture(sampler2D s, vec2 c) { return texture2D(s,c); }

    // Created by Stephane Cuillerdier - @Aiekick/2016
    // License Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.

    void mainImage( out vec4 f, vec2 g )
    {
        float
            t = time,
            p;

        vec2
            s = iResolution.xy,
            u = (g+g-s)/s.y,
            ar = vec2(
                atan(u.x, u.y) * 3.18 + t*2.,
                length(u)*3. + sin(t*.5)*10.);

        p = floor(ar.y)/5.;

        ar = abs(fract(ar)-.5);

        f =
            mix(
                vec4(1,.3,0,1),
                vec4(.3,.2,.5,1),
                vec4(p))
            * .1/dot(ar,ar) * .1
            + texture(iChannel0, g / s) * .9;
    }

    void main(void)
    {
        mainImage(gl_FragColor, outTexCoord.xy * iResolution.xy);
    }
        `;

        const s2 = `
    precision mediump float;

    uniform float time;
    uniform vec2 resolution;
    uniform sampler2D iChannel0;

    varying vec2 outTexCoord;

    #define iTime time
    #define iResolution resolution

    vec4 texture(sampler2D s, vec2 c) { return texture2D(s,c); }

    void mainImage( out vec4 f, vec2 g )
    {
        f = texture(iChannel0, g/iResolution.xy);
    }

    void main(void)
    {
        mainImage(gl_FragColor, outTexCoord.xy * iResolution.xy);
    }
        `;

        const shader1 = this.add.shader(
            {
                name: 'BufferA',
                fragmentSource: s1,
                setupUniforms: (setUniform, drawingContext) => {
                    setUniform('time', this.game.loop.getDuration());
                },
                initialUniforms: {
                    iChannel0: 0,
                    resolution: [ 512, 512 ]
                }
            },
            0, 0, 512, 512, [ '__DEFAULT' ]
        )
        .setRenderToTexture('shader1');
        const shader2 = this.add.shader(
            {
                name: 'BufferB',
                fragmentSource: s2,
                setupUniforms: (setUniform, drawingContext) => {
                    setUniform('time', this.game.loop.getDuration());
                },
                initialUniforms: {
                    iChannel0: 0,
                    resolution: [ 512, 512 ]
                }
            },
            0, 0, 512, 512, [ 'shader1' ]
        )
        .setRenderToTexture('shader2');

        shader1.setTextures([ 'shader2' ]);

        this.add.image(400, 300, 'shader2');
    }
}

const config = {
    type: Phaser.WEBGL,
    parent: 'phaser-example',
    width: 800,
    height: 600,
    scene: Example
};

const game = new Phaser.Game(config);

Суть техники: рендеринг в текстуру и обратная связь

Ключевая идея примера — создать два шейдерных объекта (шейдерных спрайта), которые рендерят свой результат не на экран, а в текстуру. Эти текстуры затем используются как входные данные (sampler2D) для шейдеров в следующем кадре, создавая цикл обратной связи.

В данном случае: - **Шейдер 1 (BufferA)** генерирует сложный анимированный узор (спирали, завихрения). - **Шейдер 2 (BufferB)** — это простой «пасс-сёру», который просто отрисовывает переданную ему текстуру без изменений.

Магия происходит в настройке их зависимостей: BufferA в качестве входной текстуры iChannel0 получает результат BufferB предыдущего кадра, а BufferB, в свою очередь, получает результат BufferA. Это создаёт вечный цикл, где изображение постоянно трансформируется, порождая органичную, бесконечную анимацию.

shader1.setTextures([ 'shader2' ]);

Этот вызов — сердце механизма. Он динамически привязывает текстуру под именем 'shader2' (в которую рендерит второй шейдер) к первому шейдеру в качестве семплера iChannel0.

Создание шейдеров с рендерингом в текстуру

В Phaser для создания шейдера, который рендерит в текстуру, используется метод this.add.shader(). Критически важны последние два параметра: размеры области рендера и массив имён входных текстур.

Посмотрите на создание первого шейдера:

const shader1 = this.add.shader(
    {
        name: 'BufferA',
        fragmentSource: s1,
        setupUniforms: (setUniform, drawingContext) => {
            setUniform('time', this.game.loop.getDuration());
        },
        initialUniforms: {
            iChannel0: 0,
            resolution: [ 512, 512 ]
        }
    },
    0, 0, 512, 512, [ '__DEFAULT' ]
).setRenderToTexture('shader1');

Разберём ключевые моменты: 1. **Параметры рендера**: 0, 0, 512, 512 — это координаты и размер прямоугольника рендера *внутри текстуры*. По сути, мы создаём текстуру 512x512 пикселей. 2. **Массив текстур [ '__DEFAULT' ]**: Это начальный источник текстуры для семплера iChannel0. '__DEFAULT' — специальная белая текстура 2x2 пикселя от Phaser. 3. **Метод .setRenderToTexture('shader1')**: Именно он переключает шейдер в режим рендера в текстуру. Текстура регистрируется в текстовом кеше игры под переданным именем ('shader1').

Аналогично создаётся второй шейдер, но с важным отличием:

const shader2 = this.add.shader(
    ... // конфиг шейдера
    0, 0, 512, 512, [ 'shader1' ] // Использует текстуру от первого шейдера!
).setRenderToTexture('shader2');

Здесь массив [ 'shader1' ] указывает, что изначально семплер iChannel0 этого шейдера должен брать данные из текстуры 'shader1', которую создал первый шейдер. Так устанавливается первоначальная связь.

GLSL-код: как работает анимация

Шейдеры написаны на GLSL с использованием шаблона mainImage от Shadertoy, который популярен в демосцене.

**Шейдер BufferA (основной эффект):** Его код создаёт спиралевидный узор, который зависит от времени (time) и расстояния пикселя от центра экрана.

glsl
vec2 u = (g+g-s)/s.y;
vec2 ar = vec2(atan(u.x, u.y) * 3.18 + t*2., length(u)*3. + sin(t*.5)*10.);

Здесь ar.x — угол, к которому прибавляется время, заставляя узор вращаться. ar.y — радиус, который пульсирует с помощью синуса от времени. Функция fract() и вычитание 0.5 создают повторяющийся узор с резкими гранями.

**Шейдер BufferB (прокси):** Его код предельно прост — он лишь передаёт пиксель из входной текстуры.

glsl
void mainImage( out vec4 f, vec2 g )
{
    f = texture(iChannel0, g/iResolution.xy);
}

Его задача — сохранить результат BufferA в текстуру shader2 для использования в следующем кадре.

Важный нюанс в коде BufferA — смешивание (mix) собственного узора с входной текстурой:

glsl
f = mix(...) * .1/dot(ar,ar) * .1 + texture(iChannel0, g / s) * .9;

Здесь texture(iChannel0, g / s) * .9 — это результат BufferB (т.е., BufferA с предыдущего кадра), который на 90% определяет итоговый цвет. Это создаёт эффект "послесвечения" и плавной эволюции изображения, а не его резкой смены.

Замыкание цикла и отображение результата

После создания обоих шейдеров необходимо замкнуть цикл обратной связи. Мы уже установили, что BufferB берёт данные из BufferA. Теперь нужно сделать наоборот — заставить BufferA использовать результат BufferB.

Это делается постфактум, с помощью метода setTextures:

shader1.setTextures([ 'shader2' ]);

Этот вызов переопределяет источник для семплера iChannel0 шейдера shader1. Теперь в каждом кадре будет происходить следующее: 1. BufferA рисует, используя текстуру shader2 (результат BufferB с прошлого кадра). 2. Его результат сохраняется в текстуру shader1. 3. BufferB рисует, используя текстуру shader1 (только что вычисленный результат BufferA). 4. Его результат сохраняется в текстуру shader2. 5. Цикл повторяется с пункта 1.

Чтобы увидеть результат, мы просто создаём обычное изображение (this.add.image), которое использует итоговую текстуру shader2 в качестве источника.

this.add.image(400, 300, 'shader2');

Поскольку текстура shader2 обновляется каждый кадр, изображение на экране тоже анимируется.

Практические советы и отладка

Работа с шейдерными буферами требует внимательности. Вот что важно помнить:

1. **Размеры имеют значение:** Убедитесь, что размеры области рендера (width, height в add.shader) соответствуют размеру, указанному в uniform resolution. Несоответствие вызовет искажения. 2. **Порядок обновления:** Phaser обновляет шейдеры в порядке их добавления на сцену. В данном примере порядок создания (shader1, затем shader2) и настройки обратной связи корректен. 3. **Инициализация текстур:** Массив входных текстур в конструкторе ([ '__DEFAULT' ], [ 'shader1' ]) задаёт начальные значения *до* первого вызова setTextures. Без этого шейдер может не скомпилироваться из-за отсутствующего семплера. 4. **Используйте name:** Задание свойства name в конфиге шейдера помогает при отладке в инструментах разработчика. 5. **Производительность:** Рендеринг в текстуру и обратная связь — дорогие операции. Оптимизируйте шейдерный код, избегайте сложных ветвлений (if) и используйте умеренные разрешения (512x512, как в примере — хороший выбор).

Что попробовать дальше

Система шейдерных буферов в Phaser 3 — это портал в мир сложных динамических визуалов. Вы научились создавать цепочки шейдеров с обратной связью, что является основой для множества эффектов. **Идеи для экспериментов:** 1. Измените коэффициенты смешивания в шейдере BufferA (.1 и .9), чтобы сделать эволюцию более резкой или, наоборот, плавной. 2. Добавьте третий шейдерный буфер в цепочку для ещё более сложной постобработки. 3. Используйте входную текстуру не от другого буфера, а от реального игрового объекта (например, RenderTexture с отрисованными спрайтами), чтобы применять шейдерные эффекты к игровой сцене. 4. Модифицируйте GLSL-код BufferA: попробуйте другие математические функции (sin, cos, tan, mod) для создания иных паттернов — клеток, волн, шума.