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

Создание игр с тысячами объектов на экране всегда было вызовом для производительности. Классический подход к отрисовке спрайтов может быстро исчерпать ресурсы CPU и GPU. Пример демонстрирует использование `spriteGPULayer` в Phaser — мощного инструмента для паковки и рендеринга огромного количества однотипных спрайтов в одном draw call. Это позволяет создавать масштабные визуальные эффекты, такие как частицы, звездные поля или орды врагов, без просадок FPS.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    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`);
        }
    }

    createGPULayer (gpuLayer, count, colors)
    {
        if (gpuLayer.memberCount + count > gpuLayer.size)
        {
            gpuLayer.resize(gpuLayer.size + count);
        }

        const template = {
            x: {
                ease: 'Linear',
            },
            y: {
                ease: 'Sine.easeInOut',
            },
            rotation: {
                ease: 'Linear',
                amplitude: 3,
                duration: 500
            },
            // alpha: 0.25,
            tintBlend: {
                base: 1,
                ease: 'Linear'
            }
        };

        for (let i = 0; i < count; 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;

            gpuLayer.addMember(template);
        }
    }

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

        const layers = [];

        for (let i = 1; i <= 16; i++)
        {
            // layers.push(this.add.spriteGPULayer(`pixel${i}`));
            layers.push(this.add.spriteGPULayer(`sprite`, 0));
        }

        // 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 = 524288;
        // const count = 524288;

        let c = 0;
        
        // this.createGPULayer(layers[0], count, colors);
        // let total = count;

        let total = 0;

        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', () => {

            this.createGPULayer(layers[c], count, colors);

            c++;
            c %= layers.length;
            total += count;

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

            console.log(total);

        });
    }
}

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

const game = new Phaser.Game(config);

Загрузка ресурсов: подготовка пикселей и спрайтов

В методе preload происходит загрузка текстур. Ключевой момент — подготовка 32 изображений размером в один пиксель разного цвета (pixel1...pixel32). Это позволяет позже создавать GPU-слои с разными базовыми текстурами для визуального разнообразия. Также загружается стандартный спрайт и логотип.

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

Создание и настройка GPU-слоев

В методе create инициализируются сами GPU-слои. Цикл создает 16 слоев, используя текстуру sprite. Второй аргумент `0вspriteGPULayerуказывает максимальный размер слоя, где0` означает "по умолчанию". Слои хранятся в массиве для последовательного управления.

const layers = [];
for (let i = 1; i <= 16; i++)
{
    layers.push(this.add.spriteGPULayer(`sprite`, 0));
}

Также создается текстовый объект для отображения счетчика и определяется целевое количество спрайтов для добавления (в примере — 250 000).

Массовое добавление спрайтов в слой

Основная логика вынесена в метод createGPULayer. Его задача — добавить большое количество элементов (спрайтов) в переданный GPU-слой, настроив для каждого уникальные параметры анимации и цвета.

Сначала метод проверяет, достаточно ли места в слое, и при необходимости увеличивает его размер с помощью gpuLayer.resize().

if (gpuLayer.memberCount + count > gpuLayer.size)
{
    gpuLayer.resize(gpuLayer.size + count);
}

Затем создается шаблон объекта template, который описывает, какие свойства будут анимированы у каждого добавляемого спрайта: позиция по X и Y, вращение (rotation) и режим смешивания цвета (tintBlend). Для каждого свойства можно задать базовое значение, амплитуду изменения, длительность анимации и функцию сглаживания (ease).

Генерация уникальных параметров для каждого спрайта

В цикле для каждого из count спрайтов шаблон заполняется случайными значениями с помощью Phaser.Math.Between и Phaser.Math.Wrap. Это создает разнообразие в движении.

- x.base и y.base: задают начальную позицию. - x.amplitude и y.amplitude: определяют размах движения. - x.duration и y.duration: управляют скоростью анимации. - rotation.delay: добавляет задержку перед началом вращения.

Цвет для тонирования (tintTopLeft, tintTopRight) берется из заранее созданного цветового круга HSVColorWheel. Это гарантирует плавный цветовой градиент среди всех спрайтов.

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.tintTopLeft = colors[Phaser.Math.Wrap(i, 0, 359)].color;

После настройки шаблона спрайт добавляется в слой вызовом gpuLayer.addMember(template).

Интерактивное добавление и управление

Интерактивность обеспечивается обработчиком события pointerdown на игровом поле. По каждому клику мыши в текущий активный слой (индекс `c) добавляется новая партия изcountспрайтов, используя методcreateGPULayer. Индексc` циклически перебирает все 16 созданных слоев.

Счетчик общего количества спрайтов обновляется, и новый результат форматируется (разделяется запятыми для читаемости) и выводится на экран.

this.input.on('pointerdown', () => {
    this.createGPULayer(layers[c], count, colors);
    c++;
    c %= layers.length;
    total += count;
    text.setText(`Sprites: ${format(total)}`);
});

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

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

GPU-слои в Phaser — это ключ к высокой производительности при работе с десятками и сотнями тысяч спрайтов. Они работают, упаковывая геометрию и трансформации многих объектов в единые буферы, что сводит к минимуму коммуникацию между CPU и GPU. Для экспериментов попробуйте: изменить текстуру слоев на однопиксельные (pixel${i}), настроить другие свойства анимации в шаблоне (например, scale или alpha), или реализовать удаление спрайтов из слоя для динамических симуляций.