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

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

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

Живой запуск

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

Исходный код


const perlinNoiseFunction = `
// Assign this snippet to \`fragmentHeader\` in the shader config.
// This places it above the \`main\` function in the fragment shader.

vec2 rand2 (vec2 co) {
    co = vec2(dot(co, vec2(127.1, 311.7)), dot(co, vec2(269.5, 183.3)));
    return fract(sin(co) * 43758.5453);
}

float perlinNoise (vec2 uv)
{
    vec2 i = floor(uv);
    vec2 f = fract(uv);
    return mix(mix(dot(rand2(i + vec2(0.0, 0.0)), f - vec2(0.0, 0.0)),
                dot(rand2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), f.x),
            mix(dot(rand2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
                dot(rand2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), f.x), f.y) * 0.5 + 0.5;
}
`;

const cloudFunction = `
// Assign this snippet to \`fragmentHeader\` in the shader config.
// This places it above the \`main\` function in the fragment shader.

uniform float time;
uniform vec4 tint;

float noisyNoise (vec2 uvMain, vec2 uvDistort, float distortion) {
    return perlinNoise(uvMain + perlinNoise(uvDistort) * distortion);
}

float cloudyNoise (vec2 texCoord)
{
    // Apply perspective to the texture coordinates.
    float horizon = 1.5;
    float h = (horizon - texCoord.y);
    float w = texCoord.x / h;
    vec2 uv = vec2(
        w - 0.5 / h,
        texCoord.y / h);

    vec2 timeOffsetDistort = vec2(time * 0.37, time * 0.25);
    vec2 timeOffsetMain = vec2(time * -0.73, time * 0.55);

    // Base frequency, to match screen aspect ratio of this example.
    // Change this or set it as a uniform to match your game.
    vec2 frequency = vec2(16, 9);

    float noise1 = noisyNoise(
        uv * frequency * 3.1 + timeOffsetMain * 3.17,
        uv * frequency * 7.0 + timeOffsetDistort * 3.0,
        4.0);

    float noise2 = noisyNoise(
        uv * frequency * 0.5 + timeOffsetMain * 2.97 + noise1,
        uv * frequency * 1.7 + timeOffsetDistort * 2.95 + noise1,
        2.0 + noise1 * 5.0);

    float noise3 = noisyNoise(
        uv * frequency * 1.9 + timeOffsetMain * 2.93 + noise2,
        uv * frequency * 1.2 + timeOffsetDistort * 2.90 + noise2,
        1.0 + noise2 * 5.0);

    float sum = noise1 * 0.15 + noise2 * 0.7 + noise3 * 0.2;

    return sum;
}
`;

const process = `
// Assign this snippet to \`fragmentProcess\` in the shader config.
// This places it within the \`main\` function in the fragment shader.
// The \`fragColor\` variable is defined above this, and output below this.

// Sample 8 layers of noise to create a cloudy effect.
float sum = 0.0;
float col = 1.0;
const int layers = 16;
const float layersF = float(layers);
for (int i = 0; i < layers; i++)
{
    float layer = cloudyNoise(vec2(outTexCoord.x, 1. - outTexCoord.y + float(layers - i) * 0.002));
    sum += layer;
    float layerBrightness = float(i) / (layersF - 1.0);
    col += layer * layerBrightness;
}

// Normalize the sum to the range [0, 1].
sum /= layersF;
col /= layersF;

float alpha = 1.0 - (0.55 - sum) * 16.0;
alpha = clamp(alpha, 0.0, 1.0);
alpha *= outTexCoord.y * 0.5 + 0.5;

// Brighten the color.
col = (col - 0.26) * 12.0;

// Reduce color by density.
col *= 1.3 - sum * 0.8;

fragColor = vec4(vec3(col * alpha), alpha) * tint;
`;

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

    preload()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('bg', 'assets/skies/gradient22.png');
        this.load.atlas('megaset', 'assets/atlas/megaset-0.png', 'assets/atlas/megaset-0.json');
    }

    create()
    {
        this.add.image(640, 360, 'bg').setDisplaySize(1280, 720);

        const shaderConfig = {
            name: 'Noise',
            // By omitting `fragmentSource`, the default fragment shader is used.
            // It has template points for accepting additions.
            shaderAdditions: [
                {
                    name: 'PerlinNoise',
                    additions: {
                        fragmentHeader: perlinNoiseFunction
                    }
                },
                {
                    name: 'Cloud',
                    additions: {
                        fragmentHeader: cloudFunction
                    }
                },
                {
                    name: 'Proc',
                    additions: {
                        fragmentProcess: process
                    }
                }
            ],
            setupUniforms: (setUniform, drawingContext) => {
                // Don't let time grow too large, or it will lose precision.
                setUniform('time', (this.game.loop.time % 1000000) / 15000.0);

                setUniform('tint', [1.0, 0.6, 0.5, 1.0]);
            }
        };

        const shader = this.add.shader(shaderConfig, 640, 360, 1280, 720);

        // Create a Shader which reads a texture frame.
        const frame = this.textures.getFrame('megaset', 'phaser1');
        const shader2 = this.add.shader({
            name: "Hologram",
            fragmentSource: `
                #pragma phaserTemplate(shaderName)
                precision mediump float;
                uniform sampler2D uMainSampler;
                uniform float time;
                varying vec2 outTexCoord;
                void main(void)
                {
                    float wave = sin(outTexCoord.y * 1000.0 + time) * 0.1
                        + abs(sin(outTexCoord.y * 87.0 + time * 1.3)) * 0.1;

                    float spike = max(0.0, sin(outTexCoord.y * 101.0 + time * 0.5) - 0.9);

                    vec4 color = texture2D(uMainSampler, outTexCoord * vec2(1.0 + spike * 0.1, 1.0 + wave * 0.01));
                    gl_FragColor = color * vec4(0.5, 0.6, 1.0, 0.5 + wave);
                }
            `,
            setupUniforms: (setUniform, drawingContext) => {
                setUniform('uMainSampler', 0);
                setUniform('time', (this.game.loop.time % 1000000) / 100.0);
            }
        }, 640, 360, frame.width, frame.height, [ 'megaset' ]).setScale(2.0);

        // Set the texture coordinates to read only the frame.
        shader2.setTextureCoordinatesFromFrame(frame);
        // shader2.setTextureCoordinates(
        //     frame.u0, frame.v0, frame.u1, frame.v0,
        //     frame.u0, frame.v1, frame.u1, frame.v1
        // );
    }
}

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

const game = new Phaser.Game(config);

Основа: расширение стандартного шейдера

Phaser предоставляет базовый шейдер, в который можно вставлять пользовательский код через конфигурационный объект. Это удобно, так как не требует написания шейдера с нуля.

Ключевое свойство — shaderAdditions. Это массив объектов, где каждый объект определяет блок кода (additions) и его тип. Код вставляется в определенные части стандартного шейдера.

Основные типы вставок: * fragmentHeader: Код размещается перед функцией main фрагментного шейдера. Сюда выносят объявления функций и uniform-переменных. * fragmentProcess: Код размещается внутри функции main. Здесь происходит основная работа по вычислению цвета пикселя (fragColor).

const shaderConfig = {
    name: 'Noise',
    shaderAdditions: [
        {
            name: 'PerlinNoise',
            additions: { fragmentHeader: perlinNoiseFunction }
        },
        {
            name: 'Cloud',
            additions: { fragmentHeader: cloudFunction }
        },
        {
            name: 'Proc',
            additions: { fragmentProcess: process }
        }
    ],
    setupUniforms: (setUniform, drawingContext) => {
        setUniform('time', (this.game.loop.time % 1000000) / 15000.0);
        setUniform('tint', [1.0, 0.6, 0.5, 1.0]);
    }
};

Функция setupUniforms вызывается каждый кадр. С её помощью мы передаем в шейдер изменяющиеся во времени данные, такие как time.

Создать объект шейдера на сцене просто:

const shader = this.add.shader(shaderConfig, x, y, width, height);

Генерация процедурных облаков с шумом Перлина

Эффект облаков строится на многослойном шуме Перлина. Это тип градиентного шума, который дает естественные, плавные изменения, идеальные для имитации природных форм.

1. **Функция шума:** Код perlinNoiseFunction определяет функцию perlinNoise(vec2 uv). Она принимает координаты и возвращает значение в диапазоне [0.0, 1.0]. Эта функция вставляется в fragmentHeader. 2. **Создание "облачности":** Функция cloudyNoise (также в fragmentHeader) использует шум Перлина, чтобы создать более сложную текстуру. Она искажает базовые координаты (uv) для создания перспективы, добавляет смещение от времени (timeOffset) для анимации и комбинирует несколько октав шума (noise1, noise2, noise3) с разными частотами и уровнями искажения. Это придает текстуре объем и детализацию. 3. **Наложение слоев:** Основная магия происходит в коде process, который вставляется в fragmentProcess. Здесь функция cloudyNoise вызывается в цикле 16 раз (layers). Каждый следующий слой немного смещается по вертикали, создавая ощущение глубины и плотности.

for (int i = 0; i < layers; i++)
{
    float layer = cloudyNoise(vec2(outTexCoord.x, 1. - outTexCoord.y + float(layers - i) * 0.002));
    sum += layer;
    float layerBrightness = float(i) / (layersF - 1.0);
    col += layer * layerBrightness;
}

После суммирования значений происходит нормализация, расчет прозрачности (alpha) и итогового цвета (col). Финальный цвет пикселя записывается в fragColor с учетом uniform-переменной tint для окрашивания.

Работа с текстурами: эффект голограммы

Второй пример показывает, как использовать шейдер для пост-обработки существующей текстуры (спрайта из атласа). Здесь шейдер создается с нуля, указывая полный исходный код в fragmentSource.

Ключевой момент — доступ к текстуре через uniform uMainSampler.

setUniform('uMainSampler', 0);

При создании шейдера последним аргументом передается массив ключей текстур, которые будут связаны с семплерами (['megaset']).

Самое важное — корректно задать текстурные координаты. По умолчанию шейдер использует координаты всего Canvas. Чтобы он "читал" только конкретный кадр из атласа, используется метод setTextureCoordinatesFromFrame().

const frame = this.textures.getFrame('megaset', 'phaser1');
const shader2 = this.add.shader({...}, 640, 360, frame.width, frame.height, [ 'megaset' ]);
shader2.setTextureCoordinatesFromFrame(frame);

Этот метод автоматически берет UV-координаты (u0, v0, u1, v1) из объекта frame и передает их в шейдер. В шейдере эти координаты доступны как outTexCoord.

Эффект голограммы создается путем искажения этих координат с помощью синусоидальных волн, зависящих от вертикальной позиции (outTexCoord.y) и времени (time). Это создает характерное дрожание и свечение.

float wave = sin(outTexCoord.y * 1000.0 + time) * 0.1 + abs(sin(outTexCoord.y * 87.0 + time * 1.3)) * 0.1;
vec4 color = texture2D(uMainSampler, outTexCoord * vec2(1.0 + spike * 0.1, 1.0 + wave * 0.01));

Практические шаги для вашего проекта

1. **Начните с копирования функций:** Возьмите готовые функции perlinNoise, cloudyNoise и цикл из process. Они являются хорошей основой для любого процедурного эффекта на основе шума. 2. **Экспериментируйте с параметрами:** Изменяйте frequency, distortion, количество layers и формулу расчета alpha/col в коде process. Это кардинально меняет вид облаков — от легкой дымки до грозовых туч. 3. **Используйте setupUniforms для интерактивности:** Передавайте в шейдер не только time, но и, например, позицию курсора или значение здоровья игрока, чтобы эффект реагировал на геймплей. 4. **Применяйте к текстурам:** Не ограничивайтесь генерацией "из воздуха". Как показано во втором примере, шейдеры — отличный способ динамически модифицировать спрайты, создавая эффекты рассеивания, пламени, магического щита или водной поверхности. 5. **Отладка:** Начните с простого — выводите значение шума как grayscale цвет (fragColor = vec4(vec3(sum), 1.0)), чтобы увидеть сырую текстуру, а затем постепенно усложняйте формулу.

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

Шейдеры в Phaser 3 открывают двери в мир procedural graphics, позволяя создавать уникальную, динамическую и производительную графику прямо в браузере. Вы освоили два подхода: расширение стандартного шейдера для генерации текстур и написание кастомного для обработки изображений. Для экспериментов попробуйте заменить шум Перлина на клеточный шум (Worley noise) для создания эффекта камня или лавы. Или используйте технику наложения слоев шума для анимированного текстурирования тайловой карты, создавая "живые" поля и леса.