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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    smoke;
    rate = 16;
    lifespan = 2048;
    count = this.rate * this.lifespan / 16;
    currentMemberIndex = 0;
    currentSwirl = 0;
    emitNewMembers;
    lastPosition = new Phaser.Math.Vector2();
    swirliness = 4;

    preload ()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('smoke0', 'assets/particles/smoke0.png');
    }

    create ()
    {
        this.smoke = this.add.spriteGPULayer('smoke0', this.count);

        // Prepopulate the layer with invisible members.
        let template = {
            alpha: 0
        };

        for (let i = 0; i < this.count; i++)
        {
            this.smoke.addMember(template);
        }

        // Prepare a template for the members.
        template = {
            x: {
                ease: 'Circ.easeInOut',
                yoyo: true
            },
            y: {
                gravityFactor: -0.05,
                duration: this.lifespan,
                ease: 'Gravity'
            },
            rotation: {
                duration: this.lifespan,
                ease: 'Smoothstep.easeOut',
            },
            scaleX: {
                // base: 0.2,
                // amplitude: 0.4,
                base: 0.05,
                amplitude: 0.1,
                duration: this.lifespan,
                ease: 'Smoothstep.easeOut'
            },
            scaleY: {
                // base: 0.2,
                // amplitude: 0.4,
                base: 0.05,
                amplitude: 0.1,
                duration: this.lifespan,
                ease: 'Smoothstep.easeOut'
            },
            alpha: {
                base: 0.1,
                amplitude: -0.1,
                duration: this.lifespan,
                ease: 'Smoothstep.easeInOut'
            }
        };

        this.emitNewMembers = () => {
            const pointerX = this.input.activePointer.x;
            const pointerY = this.input.activePointer.y;
            const currentPosition = new Phaser.Math.Vector2(pointerX, pointerY);
            const rate = this.rate;

            for (let i = 0; i < rate; i++)
            {
                const pos = Phaser.Math.LinearXY(
                    this.lastPosition,
                    currentPosition,
                    i / rate
                );
                this.currentMemberIndex = Phaser.Math.Wrap(
                    this.currentMemberIndex - 1,
                    0,
                    this.count
                );
                this.currentSwirl += (Math.random() - 0.5) * 0.5;

                template.x.base = pos.x;
                template.x.amplitude = Math.cos(this.currentSwirl) * this.swirliness * 2;
                template.x.duration = this.lifespan * (1 - 0.5 * Math.random());
                template.x.delay = this.lifespan * 0.5 * Math.random();
                template.y.base = pos.y;
                // template.y.velocity = -64;
                // template.y.velocity = -64 + 16 * Math.random();
                template.y.velocity = -64 + this.swirliness * Math.sin(this.currentSwirl);
                template.rotation.base = Math.random() * Phaser.Math.PI2;
                template.rotation.amplitude = template.rotation.base + Math.random() - 0.5;

                this.smoke.editMember(this.currentMemberIndex, template);
            }

            this.lastPosition.set(pointerX, pointerY);
        };
    }

    update ()
    {
        this.emitNewMembers();
    }
}

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

const game = new Phaser.Game(config);

Зачем нужен SpriteGPULayer?

Обычные системы частиц в Phaser, вроде this.add.particles, хорошо работают для простых эффектов. Однако когда требуется больше контроля над каждой частицей или нужно отображать тысячи одновременно, они могут стать узким местом производительности.

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

В нашем примере используется один текстурированный спрайт дыма (smoke0.png), который многократно копируется и анимируется как независимая частица. Такой подход идеален для эффектов, где все частицы используют одну текстуру.

Подготовка и создание слоя

Класс Example расширяет Phaser.Scene. В preload загружается текстура дыма. Основная магия начинается в create.

Сначала мы создаём сам GPU-слой с помощью this.add.spriteGPULayer. Первый аргумент — ключ текстуры, второй — максимальное количество элементов (members) в слое. Количество рассчитывается заранее, исходя из желаемой частоты эмиссии и времени жизни частиц.

this.smoke = this.add.spriteGPULayer('smoke0', this.count);

Сразу после создания мы «предзаполняем» слой невидимыми элементами. Это важный шаг: все элементы создаются один раз при инициализации, а в дальнейшем мы лишь изменяем их параметры (переиспользуем). Это устраняет затраты на создание и удаление объектов в цикле игры.

let template = { alpha: 0 };
for (let i = 0; i < this.count; i++) {
    this.smoke.addMember(template);
}

Шаблон анимации частицы

Сердце системы — объект-шаблон template. Он описывает, как будут анимироваться свойства каждого элемента слоя: позиция (`x,y), поворот (rotation), масштаб (scaleX,scaleY) и прозрачность (alpha`).

Каждое свойство — это объект конфигурации анимации. Ключевые параметры: - base: базовое значение. - amplitude: амплитуда изменения (например, для колебательного движения). - duration: длительность анимации в миллисекундах. - ease: функция плавности (easing), например, 'Smoothstep.easeOut'. - yoyo: если true, анимация будет «ходить туда-сюда» (полезно для движения по оси X). - gravityFactor: фактор гравитации (отрицательное значение заставит частицу подниматься). - velocity: начальная скорость.

template = {
    x: { ease: 'Circ.easeInOut', yoyo: true },
    y: { gravityFactor: -0.05, duration: this.lifespan, ease: 'Gravity' },
    rotation: { duration: this.lifespan, ease: 'Smoothstep.easeOut' },
    scaleX: { base: 0.05, amplitude: 0.1, duration: this.lifespan, ease: 'Smoothstep.easeOut' },
    alpha: { base: 0.1, amplitude: -0.1, duration: this.lifespan, ease: 'Smoothstep.easeInOut' }
};

Этот шаблон определяет поведение: частица будет медленно подниматься (отрицательная гравитация), увеличиваться в размере, плавно вращаться и исчезать. Движение по X будет колебательным (yoyo).

Эмиссия частиц в реальном времени

Функция emitNewMembers вызывается каждый кадр в update. Она отвечает за «оживление» частиц в позиции курсора.

Алгоритм: 1. Получаем текущую позицию курсора. 2. Чтобы создать эффект шлейфа, мы интерполируем позиции между предыдущим (lastPosition) и текущим положением курсора, создавая rate частиц вдоль этой линии. 3. Для каждой новой частицы мы вычисляем индекс (currentMemberIndex), который циклически перебирает все элементы слоя (старые частицы перезаписываются). 4. Динамически модифицируем шаблон для каждой частицы, задавая уникальные параметры: базовую позицию, амплитуду движения по X (создаёт завихрения), случайную длительность, задержку и начальную скорость по Y. 5. Применяем изменённый шаблон к конкретному элементу слоя через this.smoke.editMember.

this.emitNewMembers = () => {
    const pointerX = this.input.activePointer.x;
    const pointerY = this.input.activePointer.y;
    const currentPosition = new Phaser.Math.Vector2(pointerX, pointerY);
    const rate = this.rate;

    for (let i = 0; i < rate; i++) {
        const pos = Phaser.Math.LinearXY(this.lastPosition, currentPosition, i / rate);
        this.currentMemberIndex = Phaser.Math.Wrap(this.currentMemberIndex - 1, 0, this.count);
        this.currentSwirl += (Math.random() - 0.5) * 0.5;

        template.x.base = pos.x;
        template.x.amplitude = Math.cos(this.currentSwirl) * this.swirliness * 2;
        template.x.duration = this.lifespan * (1 - 0.5 * Math.random());
        template.x.delay = this.lifespan * 0.5 * Math.random();
        template.y.base = pos.y;
        template.y.velocity = -64 + this.swirliness * Math.sin(this.currentSwirl);
        template.rotation.base = Math.random() * Phaser.Math.PI2;

        this.smoke.editMember(this.currentMemberIndex, template);
    }
    this.lastPosition.set(pointerX, pointerY);
};

Использование Phaser.Math.Wrap для индекса гарантирует бесконечный цикл переиспользования частиц. Переменная currentSwirl добавляет псевдослучайное «закручивание» в движение, делая его более органичным.

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

SpriteGPULayer — мощный инструмент для создания сложных, высокопроизводительных визуальных эффектов в Phaser. Он даёт fine-grained контроль над анимацией каждой частицы, полностью разгружая CPU. **Идеи для экспериментов:** 1. Замените текстуру дыма на искры или магические частицы. 2. Измените параметры gravityFactor и velocity, чтобы создать эффект падающих листьев или поднимающихся пузырьков. 3. Привяжите эмиссию не к курсору, а к позиции игрового персонажа для следов или ауры. 4. Поэкспериментируйте с другими функциями плавности (ease) из библиотеки Phaser для совершенно разного поведения частиц. 5. Добавьте изменение цвета (tint) в шаблон анимации.