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

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

Версия 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.

// We declare this here so it can be used in the fragment process.
uniform float time;

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 waveProcess = `
// 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.

// Direct the noise down the screen.
vec2 timeOffset = vec2(0.0, time);

// 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);

// Accumulate noise from three different frequencies.
float noise0 = perlinNoise(outTexCoord * frequency * 3.0 + timeOffset * 2.0);
float noise1 = perlinNoise(outTexCoord * frequency * 5.0 + timeOffset / 1.345);
float noise2 = perlinNoise(outTexCoord * frequency * 7.0 + timeOffset / 2.1);
float sum = noise0 * 0.5 + noise1 * 0.3 + noise2 * 0.2;

// Round the sum up to 0.5 if below, to create flat areas.
sum = max(sum, 0.5);

fragColor = vec4(vec3(sum), 1.0);
`;

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

    preload()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('bg', 'assets/pics/purple-bars.jpg');
    }

    create()
    {
        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,
                        fragmentProcess: waveProcess
                    }
                }
            ],
            setupUniforms: (setUniform, drawingContext) => {
                // Don't let time grow too large, or it will lose precision.
                setUniform('time', (this.game.loop.time % 1000000) / 500);
            }
        };

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

        const bg = this.add.image(640, 360, 'bg')
            .enableFilters()
            .setDisplaySize(1280, 720);

        bg.filters.internal.addDisplacement('ooze', 0, 0.5);
    }
}

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

const game = new Phaser.Game(config);

Суть подхода: кастомные фрагменты шейдера

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

В примере используется конфигурационный объект shaderConfig. Ключевое поле shaderAdditions принимает массив объектов, каждый из которых содержит именованные дополнения для фрагментного шейдера.

const shaderConfig = {
    name: 'Noise',
    shaderAdditions: [
        {
            name: 'PerlinNoise',
            additions: {
                fragmentHeader: perlinNoiseFunction,
                fragmentProcess: waveProcess
            }
        }
    ],
    setupUniforms: (setUniform, drawingContext) => {
        setUniform('time', (this.game.loop.time % 1000000) / 500);
    }
};

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

Генерация шума Перлина в GLSL

Шум Перлина создает более естественные, органичные паттерны по сравнению с простым случайным шумом. Его алгоритм интерполирует псевдослучайные градиенты в узлах целочисленной сетки.

Функция perlinNoise написана на GLSL и помещается в fragmentHeader. Она работает с двумерными координатами (uv).

const perlinNoiseFunction = `
uniform float time;

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;
}
`;

- rand2: Генерирует псевдослучайный вектор на основе входных координат. Используется для создания градиентов в узлах сетки. - perlinNoise: Основная функция. Она: 1. Находит целочисленные координаты узла (`i) и дробную часть внутри ячейки (f`). 2. Для четырех углов текущей ячейки вычисляет скалярное произведение случайного градиента и вектора от угла к точке. 3. Дважды интерполирует (с помощью mix) результаты по осям X и Y. 4. Масштабирует и смещает результат в диапазон [0.0, 1.0].

Анимация и наслоение шума

Один слой шума Перлина выглядит слишком однородно. Чтобы создать сложную, «турбулентную» текстуру, мы накладываем несколько слоев (октав) с разной частотой и скоростью.

Этот код помещается в fragmentProcess и управляет итоговым цветом пикселя.

const waveProcess = `
vec2 timeOffset = vec2(0.0, time);
vec2 frequency = vec2(16, 9);

float noise0 = perlinNoise(outTexCoord * frequency * 3.0 + timeOffset * 2.0);
float noise1 = perlinNoise(outTexCoord * frequency * 5.0 + timeOffset / 1.345);
float noise2 = perlinNoise(outTexCoord * frequency * 7.0 + timeOffset / 2.1);
float sum = noise0 * 0.5 + noise1 * 0.3 + noise2 * 0.2;

sum = max(sum, 0.5);

fragColor = vec4(vec3(sum), 1.0);
`;

- timeOffset: Вектор, зависящий от времени. Добавляется к координатам текстуры (outTexCoord), создавая эффект «плывущего» вниз шума. - frequency: Базовый вектор частоты, подобранный под соотношение сторон экрана (1280/720 ≈ 16/9). Умножаясь на разные множители (3.0, 5.0, 7.0), создает октавы с разной детализацией. - noise0, noise1, noise2: Три слоя шума с разной частотой и скоростью анимации. - sum: Наложение слоев с разным весом. Веса (0.5, 0.3, 0.2) определяют вклад каждой октавы. - max(sum, 0.5): Обрезает низкие значения, создавая плоские «плато» в текстуре. - fragColor: Итоговый цвет пикселя — оттенки серого (все каналы RGB равны sum).

Интеграция в сцену: шейдер как текстура искажения

Сгенерированный шум редко используется как самостоятельное изображение. Чаще он служит картой смещения (displacement map) для искажения других текстур.

create() {
    const shader = this.add.shader(shaderConfig, 640, 360, 1280, 720);
    shader.setRenderToTexture('ooze');

    const bg = this.add.image(640, 360, 'bg')
        .enableFilters()
        .setDisplaySize(1280, 720);

    bg.filters.internal.addDisplacement('ooze', 0, 0.5);
}

1. this.add.shader(...): Создает игровой объект шейдера, центрирует его и задает размеры, равные размеру сцены. 2. setRenderToTexture('ooze'): Ключевой метод. Отрисовывает результат выполнения шейдера не на экран, а в именованную текстуру 'ooze', которая становится доступна для других объектов. 3. enableFilters(): Включает систему фильтров для изображения фона. 4. addDisplacement('ooze', 0, 0.5): Применяет к фону фильтр смещения (displacement filter). Он использует текстуру 'ooze' как карту: яркость каждого пикселя этой карты определяет, насколько сместить соответствующий пиксель фона. Параметры `0и0.5` задают смещение по осям X и Y. В результате статичный фон начинает «течь» и искажаться в соответствии с нашим анимированным шумом.

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

Использование кастомных шейдеров для генерации procedural-тектур открывает огромные возможности для визуализации в Phaser. Вы можете менять параметры частоты, количества октав, формулу анимации, чтобы создавать огонь, воду, туман или странные инопланетные ландшафты. Для экспериментов попробуйте: - Изменить frequency и веса октав для получения более резкого или плавного шума. - Использовать шум для управления не только смещением, но и цветом (fragColor.rgba). - Привязать uniform-переменную time не к игровому времени, а к положению курсора или состоянию игрового объекта.