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

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

Версия 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('spooky', 'assets/skies/spooky.png');
        this.load.image('wizball', 'assets/sprites/wizball.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-bicircle', 'assets/shaders/gradients/value-bicircle.glsl');
    }

    create ()
    {
        const w = this.scale.gameSize.width;
        const h = this.scale.gameSize.height;

        // Define a space a bit larger than the screen, to absorb displacement.
        const wPlus = w + 100;
        const hPlus = h + 100;

        const getSource = (key) => this.cache.shader.get(key).glsl;
        const gradientShader = this.add.shader({
            name: 'Gradient',
            shaderAdditions: [
                // This addition controls a bi-circle gradient.
                // It must be defined in the shader before the gradient function is called.
                {
                    name: 'BICIRCLE',
                    additions: { fragmentHeader: getSource('value-bicircle') }
                },
                // This addition defines standard gradient functionality.
                {
                    name: 'STANDARD',
                    additions: {
                        fragmentHeader: [
                            getSource('srgb'),
                            getSource('gradient-color')
                        ].join('\n'),
                        fragmentProcess: getSource('gradient-process')
                    }
                }
            ],
            setupUniforms: (setUniform) => {
                setUniform('center', [0.5, 0.33]);
                setUniform('radius', 1.4);
                setUniform('feather', 1.4);
                setUniform('scale', [1, 0.5]);
                setUniform('color1', [1, 1, 1, 1]); // Interior color.
                setUniform('color2', [0, 0, 0, 0]); // Exterior color.
                setUniform('steps', 0); // Smooth gradient.
                setUniform('repeat', 32);
                setUniform('offset', (-this.game.loop.time / 30000) % (1.4 / 32) + (1.4 / 32)); // Animation.
                // 1.4 / 32 is the width of a single gradient step.
            }
        }, 0, 0, wPlus, hPlus)
        .setRenderToTexture('gradient');

        // Create a dynamic texture for inverting the gradient.
        // Using `redraw` mode, we automatically update the texture every frame.
        // Note that this is the size of `gradientShader`, not the screen.
        const gradientInverse = this.add.renderTexture(
            0, 0,
            wPlus, hPlus
        );
        gradientInverse
            .clear()
            .stamp('gradient', null, wPlus / 2, hPlus / 2, { scaleY: -1 })
            .preserve(true)
            .setRenderMode('redraw')
            .saveTexture('gradient-inverse');

        // Add a scaled-up background to be reflected.
        const spookyTextureFrame = this.textures.get('spooky').get();
        const bg = this.add.image(wPlus / 2, hPlus / 2, 'spooky').setScale(
            wPlus / spookyTextureFrame.width,
            hPlus / spookyTextureFrame.height
        );

        // Add a reflection ball.
        const reflection = this.add.image(wPlus / 2, hPlus * 1 / 8, 'wizball');
        this.reflection = reflection;

        // Create a container for the reflected world.
        const reflectionContainer = this.add.container(0, 0)
            .add([ bg, reflection ])
            .setVisible(false);

        // Render the container to a texture.
        const reflectionTexture = this.add.renderTexture(w / 2, h / 2, wPlus, hPlus)
            .setFlipY(true);

        reflectionTexture.enableFilters();
        reflectionTexture.filters.internal.addBlur(1, 2, 2);
        reflectionTexture.filters.internal.addDisplacement('gradient-inverse');
        
        reflectionTexture
            .clear()
            .draw(reflectionContainer)
            .preserve(true)
            .setRenderMode('all');


        const rippleBase = this.add.image(w / 2, h / 2, 'gradient')
        .setAlpha(0.125)
        .setTint(0x1800cc)
        .setBlendMode(Phaser.BlendModes.ADD);
        const rippleTop = this.add.image(w / 2, h / 2 - 8, 'gradient')
        .setAlpha(0.125)
        .setTint(0x66aaff)
        .setBlendMode(Phaser.BlendModes.ADD);

        const ball = this.add.image(w / 2, h / 2, 'wizball');
        this.ball = ball;

        // this.cameras.main.filters.internal.addTiltShift();

        this.tweens.add({
            targets: ball,
            y: h / 2 + 32,
            duration: 1000,
            yoyo: true,
            repeat: -1,
            ease: 'Sine.easeInOut',
            delay: 0
        });
    }

    update ()
    {
        // Sync the reflection with the ball.
        const height = this.scale.gameSize.height;
        const ball = this.ball;
        const reflection = this.reflection;
        const surface = height * 0.66;
        const distance = ball.y - surface;
        reflection.y = (height - surface) + distance;
    }
}

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

let game = new Phaser.Game(config);

Подготовка ресурсов и загрузка шейдеров

Ключевой элемент примера — использование GLSL-шейдеров для создания анимированного градиента. Phaser позволяет загружать шейдеры как отдельные ресурсы с помощью метода load.glsl().

this.load.glsl('gradient-color', 'assets/shaders/gradients/gradient-color.glsl');
this.load.glsl('gradient-process', 'assets/shaders/gradients/gradient-process.glsl');

В методе preload мы загружаем несколько шейдерных файлов и спрайты. Обратите внимание на load.setBaseURL — он задает базовый путь для всех последующих загрузок, что удобно для работы с удаленными ресурсами. Загруженные шейдеры становятся доступны через кэш this.cache.shader.

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

Основной визуальный паттерн — волны — создается с помощью шейдерного объекта. Мы используем this.add.shader(), передавая ему конфигурацию с "дополнениями" (additions) и униформ-переменными.

const gradientShader = this.add.shader({
    name: 'Gradient',
    shaderAdditions: [
        {
            name: 'BICIRCLE',
            additions: { fragmentHeader: getSource('value-bicircle') }
        }
    ],
    setupUniforms: (setUniform) => {
        setUniform('offset', (-this.game.loop.time / 30000) % (1.4 / 32) + (1.4 / 32));
    }
}, 0, 0, wPlus, hPlus)
.setRenderToTexture('gradient');

Параметр shaderAdditions позволяет модульно собрать шейдер из отдельных GLSL-кодов. Функция setupUniforms задает параметры градиента, такие как центр, радиус и цвета. Униформа offset анимируется на основе игрового времени (this.game.loop.time), создавая плавное движение волн. Вызов .setRenderToTexture('gradient') рендерит результат шейдера в текстуру с указанным ключом для последующего использования.

Работа с Render Texture: инверсия и отражение

Phaser предоставляет мощный инструмент — RenderTexture. В примере она используется для двух целей: создания инвертированной копии градиента и рендеринга отраженной сцены.

const gradientInverse = this.add.renderTexture(0, 0, wPlus, hPlus);
gradientInverse
    .clear()
    .stamp('gradient', null, wPlus / 2, hPlus / 2, { scaleY: -1 })
    .saveTexture('gradient-inverse');

Метод .stamp() накладывает существующую текстуру 'gradient' с инверсией по вертикали (scaleY: -1). Режим рендера 'redraw' гарантирует, что текстура будет перерисовываться каждый кадр, если ее содержимое динамическое.

Вторая RenderTexture используется для создания отражения с применением фильтров:

reflectionTexture.enableFilters();
reflectionTexture.filters.internal.addBlur(1, 2, 2);
reflectionTexture.filters.internal.addDisplacement('gradient-inverse');

Фильтр размытия (addBlur) и смещения (addDisplacement) применяются к отрисованной сцене, имитируя искажение в воде. Текстура 'gradient-inverse' выступает в качестве карты смещения.

Композиция и наложение слоев

Финальный визуальный эффект достигается наложением нескольких слоев с разными режимами смешивания (Blend Modes).

const rippleBase = this.add.image(w / 2, h / 2, 'gradient')
    .setAlpha(0.125)
    .setTint(0x1800cc)
    .setBlendMode(Phaser.BlendModes.ADD);

Создается два экземпляра текстуры 'gradient' с разными оттенками (setTint) и низкой прозрачностью. Режим смешивания ADD приводит к осветлению фона в местах наложения, создавая эффект свечения. Анимированный шар добавляется поверх для завершения композиции.

Синхронизация анимации и логика обновления

Движение отражения шара синхронизировано с движением самого шара в методе update. Это простой, но эффективный пример зеркальной симметрии.

const surface = height * 0.66;
const distance = ball.y - surface;
reflection.y = (height - surface) + distance;

Переменная surface определяет условную "поверхность воды". Положение отражения (reflection.y) вычисляется относительно этой поверхности, создавая иллюзию, что шар и его отражение движутся согласованно. Анимация основного шара реализована через твин, который бесконечно повторяется с эффектом yoyo.

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

Комбинируя шейдеры, render textures и фильтры, вы можете создавать в Phaser 3 сложные динамические фоны без использования предварительно отрисованных видео. Для экспериментов попробуйте изменить параметры униформ в шейдере (например, feather или steps), чтобы получить другой рисунок волн. Замените карту смещения или добавьте новые фильтры к текстуре отражения. Также можно адаптировать эту технику для создания эффектов магических щитов, искажений пространства или подводного мира.