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

Вы когда-нибудь сталкивались с падением FPS при отрисовке тысяч спрайтов в Phaser? Классический подход через `this.add.sprite` имеет свои пределы. В этой статье мы разберем пример, который ломает эти ограничения, демонстрируя отрисовку **миллиона** анимированных спрайтов с плавной анимацией. Вы узнаете, как `SpriteGPULayer` использует низкоуровневую работу с GPU, позволяя создавать масштабные визуальные эффекты и симуляции, ранее недоступные в веб-играх.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    colors;
    spriteGPULayer;

    preload ()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('logo', 'assets/sprites/phaser-wire-300.png');
        this.load.image('sprite', 'assets/sprites/space-baddie.png');

        for (let i = 1; i <= 32; i++)
        {
            this.load.image(`pixel${i}`, `assets/tests/pixels/${i}.png`);
        }
    }

    create ()
    {
        this.colors = Phaser.Display.Color.HSVColorWheel();

        const layer = this.add.spriteGPULayer('sprite', 0);
        this.spriteGPULayer = layer;

        // const count = 512;
        // const count = 1024;
        // const count = 4096;
        // const count = 8192;
        // const count = 16384;
        // const count = 65536;
        // const count = 131072;
        // const count = 262144;
        // const count = 250000;
        const count = 1_000_000;
        // const count = 524288;

        this.add.image(512, 384, 'logo');

        const text = this.add.text(100, 16, `Sprites: 0`, { font: '16px Courier', fill: '#ffffff' });

        function format (number)
        {
            return number.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
        }

        this.input.on('pointerdown', () => {

            layer.resize(layer.size + count);

            text.setText(`Sprites: ${format(layer.size)}`);

            console.log(layer.size);

        });
    }

    update ()
    {
        const layer = this.spriteGPULayer;

        if (layer.memberCount < layer.size)
        {
            const colors = this.colors;
            const template = {
                x: {
                    ease: 'Linear',
                },
                y: {
                    ease: 'Sine.easeInOut',
                },
                rotation: {
                    ease: 'Linear',
                    amplitude: 3,
                    duration: 500
                },
                // alpha: 0.25,
                tintBlend: {
                    base: 1,
                    ease: 'Linear'
                },
                scaleX: 0.1,
                scaleY: 0.1,
            };

            // The layer can upload smaller segments of the buffer to the GPU,
            // which have the size `bufferUpdateSegmentSize`.
            const len = Math.min(
                layer.memberCount + layer.bufferUpdateSegmentSize,
                layer.size
            );

            console.log('updating', layer.memberCount, 'to', len);

            for (let i = layer.memberCount; i < len; i++)
            {
                template.x.base = Phaser.Math.Between(0, 1024);
                template.x.amplitude = Phaser.Math.Between(-600, 600);
                template.x.duration = Phaser.Math.Between(500, 1000);

                template.y.base = Phaser.Math.Between(256, 512);
                template.y.amplitude = Phaser.Math.Between(-250, 250);
                template.y.duration = Phaser.Math.Wrap(i, 500, 1000);

                template.rotation.delay = Phaser.Math.Wrap(i, 250, 1000);

                template.tintTopLeft = colors[Phaser.Math.Wrap(i, 0, 359)].color;
                template.tintTopRight = colors[Phaser.Math.Wrap(i, 0, 359)].color;

                layer.addMember(template);
            }
        }
    }
}

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

const game = new Phaser.Game(config);

Что такое SpriteGPULayer и зачем он нужен

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

Это радикально снижает нагрузку на CPU и количество вызовов отрисовки (draw calls), что является главным瓶颈 (бутылочным горлышком) при работе с тысячами объектов. В примере ключевой объект создается одной строкой:

Настройка сцены и создание слоя

Основная магия начинается в методе create(). Сначала мы загружаем текстуру-образец, на основе которой будет создан каждый спрайт в слое. Затем создается сам SpriteGPULayer.

const layer = this.add.spriteGPULayer('sprite', 0);
this.spriteGPULayer = layer;

Первый аргумент — ключ текстуры ('sprite'), второй — начальный размер буфера (0, так как мы будем увеличивать его динамически). Слой создан, но в нем пока нет ни одного спрайта. Их количество задается переменной count. Пример перебирает варианты от 512 до 524288, но итоговое значение поражает — **1 000 000**.

Динамическое добавление спрайтов по клику

Спрайты добавляются не все сразу, а пачками по клику мыши. Это сделано для демонстрации и контроля. Обработчик события pointerdown увеличивает размер буфера слоя и обновляет счетчик на экране.

this.input.on('pointerdown', () => {
    layer.resize(layer.size + count);
    text.setText(`Sprites: ${format(layer.size)}`);
});

Метод layer.resize() — критически важный. Он аллоцирует память в GPU-буфере под новое количество спрайтов. Однако resize() только выделяет память, сами данные спрайтов нужно заполнить. Это происходит в методе update().

Наполнение буфера данными в update()

Логика добавления спрайтов реализована в update(). Слой имеет два важных свойства: size (общий выделенный размер) и memberCount (сколько спрайтов фактически инициализировано данными). Пока memberCount меньше size, мы добавляем новые спрайты.

Ключевая оптимизация здесь — bufferUpdateSegmentSize. Вместо того чтобы обновлять весь огромный буфер каждый кадр, слой позволяет обновлять его небольшими сегментами. Это предотвращает "заикание" кадровой частоты.

const len = Math.min(
    layer.memberCount + layer.bufferUpdateSegmentSize,
    layer.size
);

В цикле для каждого нового индекса создается шаблон (template) — объект с параметрами будущего спрайта. Параметры вроде x.base, y.amplitude или tintTopLeft задают анимацию и внешний вид. Важно: мы не создаем объекты Phaser.GameObjects.Sprite, а лишь заполняем структурированные данные в буфере.

template.x.base = Phaser.Math.Between(0, 1024);
template.tintTopLeft = colors[Phaser.Math.Wrap(i, 0, 359)].color;
layer.addMember(template);

Метод layer.addMember() отправляет подготовленный шаблон в буфер слоя по текущему индексу memberCount и увеличивает счетчик.

Как работает анимация миллиона объектов

Самое удивительное — анимация. В шаблоне мы задали не статичные значения, а параметры твинов: ease, amplitude, duration. Это встроенная возможность SpriteGPULayer. Слой самостоятельно, на стороне шейдера GPU, интерполирует значения для каждого спрайта в буфере на основе прошедшего времени.

Например, это создает бесконечную волнообразную анимацию по оси Y:

y: {
    ease: 'Sine.easeInOut',
    amplitude: Phaser.Math.Between(-250, 250),
    duration: Phaser.Math.Wrap(i, 500, 1000)
}

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

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

SpriteGPULayer — это мощный инструмент для создания сложных визуальных систем: галактик, толп, частиц дыма или волн травы. Он перекладывает работу с CPU на GPU, где она выполняется в тысячу раз быстрее. Для экспериментов попробуйте: изменить текстуру на маленький пиксель для симуляции звездного поля; управлять сегментом обновления bufferUpdateSegmentSize для баланса между скоростью добавления и FPS; или использовать tint для создания плавных цветовых градиентов в массовых эффектах.