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

Создание сложных визуальных эффектов — ключевой аспект геймдева. В Phaser шейдеры и фильтры позволяют выйти за рамки стандартных спрайтов и текстур. Данный пример демонстрирует, как использовать модульную систему шейдерных добавок (shader additions) для построения билинейного и линейного градиентов, а затем комбинировать их через фильтр смещения. Этот подход полезен для генерации динамических фонов, симуляции атмосферных явлений или создания уникальных визуальных искажений для игровых объектов.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    preload ()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('plane', 'assets/sprites/ww2plane.png');

        this.load.glsl('gradient-color', 'assets/shaders/gradients/gradient-color.glsl');
        this.load.glsl('gradient-process', 'assets/shaders/gradients/gradient-process.glsl');
        this.load.glsl('srgb', 'assets/shaders/gradients/srgb-color.glsl');
        this.load.glsl('value-linear', 'assets/shaders/gradients/value-linear.glsl');
        this.load.glsl('value-bilinear', 'assets/shaders/gradients/value-bilinear.glsl');
    }

    create ()
    {
        const getSource = (key) => this.cache.shader.get(key).glsl;

        // This addition defines standard gradient functionality.
        const standardGradientAddition = {
            name: 'STANDARD',
            additions: {
                fragmentHeader: [
                    getSource('srgb'),
                    getSource('gradient-color')
                ].join('\n'),
                fragmentProcess: getSource('gradient-process')
            }
        };

        const bilinearGradientShader = this.add.shader(
            {
                name: 'Gradient',
                shaderAdditions: [
                    // This addition controls a bilinear gradient.
                    // It must be defined in the shader before the gradient function is called.
                    {
                        name: 'BILINEAR',
                        additions: { fragmentHeader: getSource('value-bilinear') }
                    },
                    standardGradientAddition
                ],
                setupUniforms: (setUniform) => {
                    setUniform('positionFrom', [0, 0]);
                    setUniform('positionTo', [1.1, 0.11]);
                    setUniform('color1', [0, 0, 0, 0]);
                    setUniform('color2', [0, 1, 0, 1]);
                    setUniform('steps', 16);
                    setUniform('repeat', 32);
                }
            }, 640, 360, 1280, 720)
            .setRenderToTexture('bilinear');

        const linearGradientShader = this.add.shader(
            {
                name: 'Gradient',
                shaderAdditions: [
                    // This addition controls a linear gradient.
                    // It must be defined in the shader before the gradient function is called.
                    {
                        name: 'LINEAR',
                        additions: { fragmentHeader: getSource('value-linear') }
                    },
                    standardGradientAddition
                ],
                setupUniforms: (setUniform) => {
                    setUniform('positionFrom', [0, 0]);
                    setUniform('positionTo', [0, 1]);
                    setUniform('color1', [0, 0.1, 1, 1]);
                    setUniform('color2', [0.05, 0.3, 1, 1]);
                    setUniform('steps', 6);
                    setUniform('repeat', 16);
                    setUniform('offset', (this.game.loop.time / 3000) % (1 / 16));
                }
            }, 640, 360, 1280 + 100, 720 + 100);
        
        // Use the bilinear gradient to displace the linear gradient.
        linearGradientShader.enableFilters().filters.internal.addDisplacement('bilinear', 0, 0.01);

        const sprite = this.add.image(640, 720 * 3 / 4, 'plane').setScale(2);
    }
}

const config = {
    type: Phaser.AUTO,
    width: 1280,
    height: 720,
    backgroundColor: '#000000',
    parent: 'phaser-example',
    scene: Example,
    pixelArt: true
};

const game = new Phaser.Game(config);

Загрузка GLSL-шейдеров

В Phaser шейдеры загружаются как отдельные ресурсы через метод this.load.glsl. Каждый файл с расширением .glsl содержит фрагмент кода на языке шейдеров.

this.load.image('plane', 'assets/sprites/ww2plane.png');
this.load.glsl('gradient-color', 'assets/shaders/gradients/gradient-color.glsl');
this.load.glsl('gradient-process', 'assets/shaders/gradients/gradient-process.glsl');
this.load.glsl('srgb', 'assets/shaders/gradients/srgb-color.glsl');
this.load.glsl('value-linear', 'assets/shaders/gradients/value-linear.glsl');
this.load.glsl('value-bilinear', 'assets/shaders/gradients/value-bilinear.glsl');

После загрузки шейдерные ресурсы становятся доступны в кэше this.cache.shader.get(key).glsl. Это позволяет динамически комбинировать части шейдерного кода.

Создание модульных шейдеров с добавками

Основная мощь примера — в использовании системы shaderAdditions. Это массив объектов, каждый из которых определяет именованный блок кода (добавку), который встраивается в итоговый шейдер. Порядок добавок важен.

Сначала создаётся стандартная добавка STANDARD, которая включает базовую функциональность для работы с градиентами: преобразование цвета и процесс рендеринга.

const standardGradientAddition = {
    name: 'STANDARD',
    additions: {
        fragmentHeader: [
            getSource('srgb'),
            getSource('gradient-color')
        ].join('\n'),
        fragmentProcess: getSource('gradient-process')
    }
};

Затем создаётся шейдер с билинейным градиентом. Для этого перед стандартной добавкой STANDARD вставляется добавка BILINEAR, которая определяет математику билинейной интерполяции.

const bilinearGradientShader = this.add.shader(
    {
        name: 'Gradient',
        shaderAdditions: [
            {
                name: 'BILINEAR',
                additions: { fragmentHeader: getSource('value-bilinear') }
            },
            standardGradientAddition
        ],
        setupUniforms: (setUniform) => {
            setUniform('positionFrom', [0, 0]);
            setUniform('positionTo', [1.1, 0.11]);
            setUniform('color1', [0, 0, 0, 0]);
            setUniform('color2', [0, 1, 0, 1]);
            setUniform('steps', 16);
            setUniform('repeat', 32);
        }
    }, 640, 360, 1280, 720)
    .setRenderToTexture('bilinear');

Ключевой момент: вызов .setRenderToTexture('bilinear') рендерит результат этого шейдера в текстуру с именем bilinear. Эта текстура будет использована позже как карта смещения.

Анимированный линейный градиент и фильтр смещения

Второй шейдер создаёт линейный градиент. Его добавка LINEAR определяет линейную интерполяцию. Обратите внимание на униформу offset, которая анимируется на основе игрового времени, создавая движущийся градиент.

const linearGradientShader = this.add.shader(
    {
        name: 'Gradient',
        shaderAdditions: [
            {
                name: 'LINEAR',
                additions: { fragmentHeader: getSource('value-linear') }
            },
            standardGradientAddition
        ],
        setupUniforms: (setUniform) => {
            setUniform('positionFrom', [0, 0]);
            setUniform('positionTo', [0, 1]);
            setUniform('color1', [0, 0.1, 1, 1]);
            setUniform('color2', [0.05, 0.3, 1, 1]);
            setUniform('steps', 6);
            setUniform('repeat', 16);
            setUniform('offset', (this.game.loop.time / 3000) % (1 / 16));
        }
    }, 640, 360, 1280 + 100, 720 + 100);

Самое интересное происходит в следующей строке. Мы применяем к шейдеру с линейным градиентом фильтр внутреннего смещения (internal.addDisplacement). В качестве карты смещения используется текстура bilinear, созданная первым шейдером. Это искажает линейный градиент по паттерну билинейного.

linearGradientShader.enableFilters().filters.internal.addDisplacement('bilinear', 0, 0.01);

Параметры `0и0.01` задают смещение по осям X и Y. В результате получается сложный, динамически изменяющийся визуальный паттерн, объединяющий оба градиента.

Интеграция с игровым миром

Созданные шейдеры отрисовываются непосредственно на сцену как полноценные игровые объекты с заданными координатами и размерами. Они существуют в том же пространстве, что и обычные спрайты.

const sprite = this.add.image(640, 720 * 3 / 4, 'plane').setScale(2);

Самолёт добавляется поверх шейдеров, демонстрируя, как статичная графика может сочетаться с процедурно сгенерированным фоном. Конфигурация игры включает pixelArt: true, что может влиять на сглаживание и придавать эффектам определённый стиль.

const config = {
    type: Phaser.AUTO,
    width: 1280,
    height: 720,
    backgroundColor: '#000000',
    parent: 'phaser-example',
    scene: Example,
    pixelArt: true
};

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

Пример наглядно показывает силу модульного подхода к шейдерам в Phaser. Комбинируя готовые GLSL-блоки через shaderAdditions и используя рендер в текстуру, можно создавать сложные многослойные эффекты без написания монолитных шейдеров. Для экспериментов попробуйте: изменить параметры positionFrom/To для изменения направления градиентов; использовать другую текстуру для фильтра смещения; анимировать униформы color1 и color2 для плавного изменения палитры; применить получившийся шейдер в качестве фильтра к самому спрайту самолёта через sprite.setPipeline().