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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    color = new Phaser.Display.Color();
    noiseConfigX = {
        noiseCells: [ 16, 9, 4 ],
        noiseSeed: [ 2, 3, 4 ]
    };
    noiseConfigY = { ...this.noiseConfigX, noiseSeed: [ 5, 6, 7 ] };
    rectangles = [];

    create()
    {
        const { width, height } = this.scale;

        // Increase step to 16 or 32 to decrease count if performance is unacceptable.
        const step = 8;

        for (let y = 0; y <= height; y += step)
        {
            for (let x = 0; x <= width; x += step)
            {
                const vector = [ x / width, y / height ];
                const rectangle = this.add.rectangle(x, y, 4, 4, 0xff0000);
                rectangle.baseVector = [ x, y ];
                rectangle.noiseVector = vector;
                this.rectangles.push(rectangle);
            }
        }
    }

    update(time)
    {
        // Update pre-existing configs,
        // because creating many objects is expensive.
        this.noiseConfigX.noiseFlow = time / 1000;
        this.noiseConfigY.noiseFlow = time / 1000;
        const noiseConfigX = this.noiseConfigX;
        const noiseConfigY = this.noiseConfigY;
        const color = this.color;

        const { width, height } = this.scale;

        for (const rectangle of this.rectangles)
        {
            const vector = rectangle.noiseVector;
            const hashX = Phaser.Math.HashSimplex(vector, noiseConfigX);
            const hashY = Phaser.Math.HashSimplex(vector, noiseConfigY);

            rectangle.x = rectangle.baseVector[0] + hashX * 8;
            rectangle.y = rectangle.baseVector[1] + hashY * 8;

            color.setGLTo(
                hashX / 2 + (rectangle.baseVector[0] / width) - (rectangle.baseVector[1] / height / 2),
                (rectangle.baseVector[1] / height) - (rectangle.baseVector[0] / width / 2),
                0.5 + hashY / 2
            );
            rectangle.setFillStyle(color.color);
        }
    }
}

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

const game = new Phaser.Game(config);

Создание сетки из прямоугольников

В методе create() мы создаем основу для нашего визуального эффекта — плотную сетку из маленьких прямоугольников, покрывающую весь экран. Размер шага (step) определяет плотность сетки: чем меньше шаг, тем больше объектов и выше детализация, но и выше нагрузка на производительность.

Каждому созданному прямоугольнику мы присваиваем три ключевых свойства: 1. baseVector: исходные координаты [x, y] на экране. 2. noiseVector: нормализованные координаты (от 0 до 1), которые будут использоваться как входные данные для функции шума. Все созданные объекты сохраняются в массив this.rectangles для последующего обновления.

create()
{
    const { width, height } = this.scale;
    const step = 8;
    for (let y = 0; y <= height; y += step)
    {
        for (let x = 0; x <= width; x += step)
        {
            const vector = [ x / width, y / height ];
            const rectangle = this.add.rectangle(x, y, 4, 4, 0xff0000);
            rectangle.baseVector = [ x, y ];
            rectangle.noiseVector = vector;
            this.rectangles.push(rectangle);
        }
    }
}

Анимация: Шум как источник движения

В методе update() происходит магия. Вместо создания новых конфигурационных объектов каждый кадр (что ресурсоемко), мы модифицируем существующие. Параметр noiseFlow устанавливается на основе времени, что заставляет шум "течь" и плавно меняться.

Затем для каждого прямоугольника в массиве: 1. Из его noiseVector и конфигов вычисляются два значения шума с помощью Phaser.Math.HashSimplex — для смещения по X и Y. 2. Эти значения (в диапазоне примерно от -1 до 1) умножаются на коэффициент (здесь 8) и добавляются к исходным координатам, создавая волнообразное движение.

update(time)
{
    this.noiseConfigX.noiseFlow = time / 1000;
    this.noiseConfigY.noiseFlow = time / 1000;
    const noiseConfigX = this.noiseConfigX;
    const noiseConfigY = this.noiseConfigY;
    for (const rectangle of this.rectangles)
    {
        const vector = rectangle.noiseVector;
        const hashX = Phaser.Math.HashSimplex(vector, noiseConfigX);
        const hashY = Phaser.Math.HashSimplex(vector, noiseConfigY);
        rectangle.x = rectangle.baseVector[0] + hashX * 8;
        rectangle.y = rectangle.baseVector[1] + hashY * 8;

Динамическая окраска на основе шума

Движение — это только половина эффекта. Цвет каждого прямоугольника также вычисляется динамически на основе его позиции и значений шума. Используется метод color.setGLTo(), который задает цвет в формате WebGL (значения red, green, blue от 0 до 1).

Формула для каждого канала комбинирует: - Нормализованные координаты прямоугольника (baseVector[0] / width), создающие градиент. - Значения шума (hashX, hashY), добавляющие вариативность и связь с движением. Полученный цвет применяется к прямоугольнику через rectangle.setFillStyle(). Это создает сложную, живую цветовую палитру, которая эволюционирует вместе с движением.

color.setGLTo(
            hashX / 2 + (rectangle.baseVector[0] / width) - (rectangle.baseVector[1] / height / 2),
            (rectangle.baseVector[1] / height) - (rectangle.baseVector[0] / width / 2),
            0.5 + hashY / 2
        );
        rectangle.setFillStyle(color.color);
    }
}

Инициализация игры и конфигурация

Финальный шаг — создание экземпляра игры Phaser с необходимой конфигурацией. Ключевой момент — использование type: Phaser.WEBGL. WebGL-рендерер критически важен для производительности при отрисовке тысяч динамически окрашиваемых объектов в реальном времени. Canvas-рендерер может не справиться с такой нагрузкой.

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

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

Метод Phaser.Math.HashSimplex открывает мощный инструментарий для процедурной генерации контента прямо во время выполнения игры. Вы можете экспериментировать: измените step для контроля производительности, поиграйте с множителями движения (hashX * 8) для изменения амплитуды "волн", модифицируйте формулу цвета для создания разных настроений (огненные, ледяные, ядовитые эффекты) или привяжите шум не к прямоугольникам, а к спрайтам противников для создания "роящегося" поведения. Попробуйте использовать одно значение шума для управления масштабом или вращением объектов.