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

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

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

Живой запуск

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

Исходный код


const randomNoiseFunction = `
uniform vec2 uSeed;
float randomNoise (vec2 uv, vec2 extraSeed) {
    return fract(sin(dot(uv, uSeed + extraSeed)) * 43758.5453123);
}
`;

const randomBWProcess = `fragColor = vec4(vec3(step(0.5, randomNoise(outTexCoord, vec2(0.0)))), 1.0);`;

const randomGrayProcess = `fragColor = vec4(vec3(mix(0.25, 1.0, randomNoise(outTexCoord, vec2(0.0)))), 1.0);`;

const randomColorProcess = `fragColor = vec4(randomNoise(outTexCoord, vec2(0.0)), randomNoise(outTexCoord, vec2(1.0)), randomNoise(outTexCoord, vec2(2.0)), 1.0);`;

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

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

    create()
    {
        this.cameras.main.setBackgroundColor(0x222233);

        this.add.image(240, 440, 'megaset', 'atari800').setTint(0x808080);
        this.add.image(640, 440, 'megaset', 'atari1200xl').setTint(0x808080);
        this.add.image(1040, 440, 'megaset', 'atari130xe').setTint(0x808080);

        // Define a list of shader additions to insert.
        // We could combine additions in a single entry in the list,
        // but this way we can easily swap out individual parts.
        const shaderAdditions = [
            {
                name: 'RandomNoise',
                additions: { fragmentHeader: randomNoiseFunction }
            },
            {
                name: 'BWProcess',
                additions: { fragmentProcess: randomBWProcess }
            }
        ];

        const shaderConfig = {
            name: 'Noise',
            // By omitting `fragmentSource`, the default fragment shader is used.
            // It has template points for accepting additions.
            shaderAdditions,
            setupUniforms: (setUniform, drawingContext) => {
                // Prevent the seed from becoming too large and losing precision.
                const time = this.game.loop.time % 100000;
                setUniform('uSeed', [time * 12.3456789 + 98.7654321, time * 98.7654321 + 12.3456789]);
            }
        };

        const shader1 = this.add.shader(shaderConfig, 240, 300, 200, 100);

        shaderAdditions.pop();
        shaderAdditions.push({
            name: 'GrayProcess',
            additions: { fragmentProcess: randomGrayProcess }
        });

        const shader2 = this.add.shader(shaderConfig, 640, 300, 200, 100);

        shaderAdditions.pop();
        shaderAdditions.push({
            name: 'ColorProcess',
            additions: { fragmentProcess: randomColorProcess }
        });

        const shader3 = this.add.shader(shaderConfig, 1040, 300, 200, 100);

        // Add a bloom filter to the screen.
        const parallel = this.cameras.main.filters.internal.addParallelFilters();
        parallel.top.addThreshold(0.5, 1.0);
        parallel.top.addBlur(1, 4, 4, 1, 0xddddff);
        parallel.blend.blendMode = Phaser.BlendModes.ADD;

    }
}

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

const game = new Phaser.Game(config);

Основа шума: функция randomNoise

Вся магия начинается с функции, генерирующей псевдослучайное значение для каждой точки (пикселя) текстуры. В GLSL (язык шейдеров) мы создаём функцию randomNoise. Она использует математическую операцию dot (скалярное произведение) координат текстуры и заранее заданного "зерна" (seed), чтобы получить предсказуемый, но визуально случайный результат.

Функция возвращает значение в диапазоне от 0.0 до 1.0, используя fract для взятия дробной части. Это значение и будет интенсивностью шума в конкретной точке.

const randomNoiseFunction = `
uniform vec2 uSeed;
float randomNoise (vec2 uv, vec2 extraSeed) {
    return fract(sin(dot(uv, uSeed + extraSeed)) * 43758.5453123);
}
`;

Ключевые моменты: - uniform vec2 uSeed; — это uniform-переменная. Её значение едино для всех пикселей и задаётся из JavaScript-кода. Мы будем менять его каждый кадр для анимации шума. - extraSeed — дополнительный параметр, позволяющий получать разные последовательности шума, используя одну базовую функцию. Это пригодится для цветного шума.

Сборка шейдера из модулей

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

Каждое "дополнение" (addition) — это объект с именем и указанием, в какую часть шаблонного шейдера его вставить. В нашем примере мы используем две точки вставки: - fragmentHeader: для объявления функций (нашу randomNoise). - fragmentProcess: для кода, который непосредственно вычисляет итоговый цвет пикселя (fragColor).

const shaderAdditions = [
    {
        name: 'RandomNoise',
        additions: { fragmentHeader: randomNoiseFunction }
    },
    {
        name: 'BWProcess',
        additions: { fragmentProcess: randomBWProcess }
    }
];

const shaderConfig = {
    name: 'Noise',
    shaderAdditions, // Передаём массив дополнений
    setupUniforms: (setUniform, drawingContext) => {
        // Код для обновления uniform-переменных каждый кадр
    }
};

Объект shaderConfig затем передаётся в this.add.shader() для создания игрового объекта.

Три типа шума: от чёрно-белого к цветному

Меняя всего одну строчку в fragmentProcess, мы кардинально меняем визуальный результат. Всё благодаря тому, что функция randomNoise возвращает гибкое числовое значение.

**1. Пороговый (чёрно-белый) шум:** Функция step(0.5, value) возвращает 0.0, если значение меньше 0.5, и 1.0 — если больше или равно. Получаются резкие чёрные и белые точки.

const randomBWProcess = `fragColor = vec4(vec3(step(0.5, randomNoise(outTexCoord, vec2(0.0)))), 1.0);`;

**2. Градиентный серый шум:** Функция mix(0.25, 1.0, value) интерполирует (смешивает) между тёмно-серым (0.25) и белым (1.0) в зависимости от значения шума. Получается плавный серый градации.

const randomGrayProcess = `fragColor = vec4(vec3(mix(0.25, 1.0, randomNoise(outTexCoord, vec2(0.0)))), 1.0);`;

**3. Цветной шум:** Здесь используется ключевая фишка — параметр extraSeed. Мы трижды вызываем randomNoise с разным extraSeed (vec2(0.0), vec2(1.0), vec2(2.0)), получая три независимых случайных значения для красного, зелёного и синего каналов соответственно.

const randomColorProcess = `fragColor = vec4(randomNoise(outTexCoord, vec2(0.0)), randomNoise(outTexCoord, vec2(1.0)), randomNoise(outTexCoord, vec2(2.0)), 1.0);`;

В коде примера мы последовательно заменяем последний элемент в массиве shaderAdditions и создаём три отдельных объекта-шейдера с разными процессами.

Анимация и пост-обработка

Чтобы шум не был статичной картинкой, его нужно анимировать. Для этого в конфиге шейдера используется функция setupUniforms. Она вызывается каждый кадр и позволяет обновлять uniform-переменные.

В примере мы берём время из игрового цикла (this.game.loop.time) и на его основе вычисляем новое значение для uSeed. Меняя seed, мы получаем совершенно новую последовательность шума каждый кадр, создавая иллюзию движения.

setupUniforms: (setUniform, drawingContext) => {
    // Ограничиваем время, чтобы seed не стал слишком большим
    const time = this.game.loop.time % 100000;
    setUniform('uSeed', [time * 12.3456789 + 98.7654321, time * 98.7654321 + 12.3456789]);
}

**Добавляем свечение (Bloom):** Пример также показывает, как применить пост-обработку ко всей сцене. Создаётся параллельная цепочка фильтров (addParallelFilters()), где сначала применяется пороговая фильтрация (addThreshold), затем размытие (addBlur), а результат смешивается с оригиналом в режиме ADD (сложение). Это добавляет шумовым текстурам лёгкое свечение.

const parallel = this.cameras.main.filters.internal.addParallelFilters();
parallel.top.addThreshold(0.5, 1.0);
parallel.top.addBlur(1, 4, 4, 1, 0xddddff);
parallel.blend.blendMode = Phaser.BlendModes.ADD;

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

Используя модульный подход к шейдерам в Phaser, вы можете создавать богатую библиотеку визуальных эффектов, комбинируя их как конструктор. Шум — лишь один из кирпичиков. **Идеи для экспериментов:** 1. Замените step и mix на другие функции GLSL (например, smoothstep для более мягких переходов). 2. Используйте несколько слоёв шума с разной частотой (умножая outTexCoord на коэффициент) и смешайте их для получения более сложных текстур (например, для облаков). 3. Привяжите значение seed не ко времени, а к положению игрового объекта, чтобы шум "следовал" за ним. 4. Используйте шумовую текстуру в качестве маски для других эффектов, например, для постепенного "исчезновения" спрайта.