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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    constructor ()
    {
        super();
    }

    preload ()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('pic1', 'assets/pics/robot-ai.jpg');
        this.load.image('pic2', 'assets/pics/baal-ai.jpg');

        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-circle', 'assets/shaders/gradients/value-circle.glsl');
    }

    create ()
    {
        this.add.image(400, 300, 'pic1');

        const sprite = this.add.image(400, 300, 'pic2');

        // Establish a progress value for the shader.
        let progress = 0;
        const feather = 0.05;

        const getSource = (key) => this.cache.shader.get(key).glsl;
        const gradientShader = this.add.shader({
            name: 'Gradient',
            shaderAdditions: [
                // This addition controls a circle gradient.
                // It must be defined in the shader before the gradient function is called.
                {
                    name: 'CIRCLE',
                    additions: { fragmentHeader: getSource('value-circle') }
                },
                // This addition defines standard gradient functionality.
                {
                    name: 'STANDARD',
                    additions: {
                        fragmentHeader: [
                            getSource('srgb'),
                            getSource('gradient-color')
                        ].join('\n'),
                        fragmentProcess: getSource('gradient-process')
                    }
                }
            ],
            setupUniforms: (setUniform) => {
                // Compute the position of the gradient from the progress value.
                const amount = progress * feather;
                const p = progress + amount;

                // Constant feather:
                const f = feather;

                // Proportional feather (create a radial gradient):
                // const f = p;

                setUniform('center', [0.5, 0.5]);
                setUniform('radius', p);
                setUniform('feather', f);
                setUniform('scale', [1, 800 / 600]); // Compensate for non-square aspect ratio.
                setUniform('color1', [1, 1, 1, 1]); // Interior color.
                setUniform('color2', [0, 0, 0, 0]); // Exterior color.
                setUniform('steps', 0); // Smooth gradient.
                setUniform('repeat', 1); // No tiling.
                setUniform('offset', 0); // No offset.
            }
        }, 400, 300, 800, 600)
        .setRenderToTexture('gradient');

        sprite.enableFilters().filters.internal.addMask('gradient');

        this.input.on('pointermove', pointer => {

            // Get the distance in normalized space.
            progress = Phaser.Math.Distance.Between(0.5, 0.5, pointer.worldX / 800, pointer.worldY / 600);

        });
    }
}

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

let game = new Phaser.Game(config);

Загрузка ресурсов: картинки и шейдеры

В методе preload загружаются два изображения для демонстрации перехода и четыре GLSL-шейдерных файла. Ключевой момент — использование метода this.load.glsl. Он загружает текстовые файлы с кодом шейдеров и помещает их в кеш (this.cache.shader), откуда их можно будет извлечь позже как простой текст.

this.load.image('pic1', 'assets/pics/robot-ai.jpg');
this.load.image('pic2', 'assets/pics/baal-ai.jpg');

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-circle', 'assets/shaders/gradients/value-circle.glsl');

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

В методе create сначала отрисовывается фоновое изображение (pic1), а поверх него — второе изображение (pic2), к которому будет применена маска. Основная работа происходит при создании объекта шейдера с помощью this.add.shader. Конфигурация шейдера состоит из двух частей: shaderAdditions и setupUniforms.

shaderAdditions позволяет собрать финальный код шейдера из нескольких загруженных кусков (additions). В данном случае определяются два блока: CIRCLE (задаёт форму градиента — круг) и STANDARD (определяет цветовую модель и функцию для расчёта градиента). Код из кеша извлекается с помощью вспомогательной функции getSource.

const getSource = (key) => this.cache.shader.get(key).glsl;
const gradientShader = this.add.shader({
    name: 'Gradient',
    shaderAdditions: [
        {
            name: 'CIRCLE',
            additions: { fragmentHeader: getSource('value-circle') }
        },
        {
            name: 'STANDARD',
            additions: {
                fragmentHeader: [
                    getSource('srgb'),
                    getSource('gradient-color')
                ].join('\n'),
                fragmentProcess: getSource('gradient-process')
            }
        }
    ],
    // ... setupUniforms будет рассмотрен далее
}, 400, 300, 800, 600)
.setRenderToTexture('gradient');

Функция setupUniforms вызывается каждый кадр для обновления параметров (uniforms) шейдера. Здесь рассчитывается радиус градиента на основе переменной progress и параметра размытия края feather. Униформы color1 и color2 задают цвета изнутри и снаружи круга (здесь белый и прозрачный черный). Униформа scale компенсирует не квадратное соотношение сторон экрана, чтобы круг не сплющился.

setupUniforms: (setUniform) => {
    const amount = progress * feather;
    const p = progress + amount;
    const f = feather;

    setUniform('center', [0.5, 0.5]);
    setUniform('radius', p);
    setUniform('feather', f);
    setUniform('scale', [1, 800 / 600]);
    setUniform('color1', [1, 1, 1, 1]);
    setUniform('color2', [0, 0, 0, 0]);
    setUniform('steps', 0);
    setUniform('repeat', 1);
    setUniform('offset', 0);
}

Метод .setRenderToTexture('gradient') указывает, что результат рендера этого шейдера должен быть сохранён в текстуру с именем gradient. Эта текстура будет использована как маска.

Применение шейдерной текстуры в качестве маски

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

sprite.enableFilters().filters.internal.addMask('gradient');

Здесь 'gradient' — это имя текстуры, которое мы задали ранее в setRenderToTexture. Белые области текстуры-градиента будут показывать спрайт, черные — скрывать, а градиентные — создавать плавный переход.

Интерактивность: управление прогрессом курсором

Чтобы переход был динамическим, переменная progress обновляется при движении указателя мыши. Расстояние от центра экрана (в нормализованных координатах от 0 до 1) до позиции курсора становится новым значением прогресса. Это расстояние вычисляется с помощью Phaser.Math.Distance.Between.

this.input.on('pointermove', pointer => {
    progress = Phaser.Math.Distance.Between(0.5, 0.5, pointer.worldX / 800, pointer.worldY / 600);
});

Поскольку setupUniforms вызывается каждый кадр, изменение progress мгновенно влияет на расчёт радиуса в шейдере, и маска плавно следует за курсором.

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

Вы создали высокопроизводительный интерактивный визуальный эффект, используя шейдеры как динамические маски в Phaser. Этот подход гораздо эффективнее, чем использование растровых масок или последовательностей спрайтов. Для экспериментов попробуйте: изменить форму градиента в шейдере value-circle.glsl на, например, квадратную волну; анимировать параметр feather для создания пульсирующего эффекта; использовать несколько масок с разными текстурами для сложных композиций; привязать прогресс не к курсору, а к времени или логике игры для автоматических переходов между локациями.