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

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

Версия 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(-6, 6);
            template.x.duration = Phaser.Math.Between(500, 1000);

            template.y.base = Phaser.Math.Between(256, 512);
            template.y.amplitude = Phaser.Math.Between(-5, 5);
            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 = [];
        this.layers = 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');

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

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

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

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

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

            text.setText(`Sprites: ${format(total)}\nClick to add ${format(count)}`);

            console.log(total);

        });
    }

    update (time, delta)
    {
        const layers = this.layers;

        const mask = [1];

        layers.forEach(layer => {
            const size = layer.size;

            //
            // This is fast, but each change costs about as much
            // as rendering four sprites:
            //

            const layout = layer.submitterNode.instanceBufferLayout;
            const f32 = layout.buffer.viewF32;
            const stride = layout.layout.stride / f32.BYTES_PER_ELEMENT;
            for (let i = 0; i < size; i++)
            {
                const offset = i * stride;
                var x = f32[offset] + delta * 0.1 + (i % 32) / 256;
                if (x > 1024)
                {
                    x -= 1024;
                }
                f32[offset] = x;
            }
            layer.setAllSegmentsNeedUpdate();

            //
            // This method is an order of magnitude slower:
            //

            // for (let i = 0; i < size; i++)
            // {
            //     const member = layer.getMemberData(i);
            //     member[0] = member[0] + delta * 0.1 + (i % 32) / 256;
            //     layer.patchMember(i, member, mask);
            // }

            //
            // And this method is two orders of magnitude slower,
            // but it's the best way to update animations:
            //

            // for (let i = 0; i < size; i++)
            // {
            //     const member = layer.getMember(i);
            //     if (member.x.base)
            //     {
            //         member.x.base += delta * 0.1 + (i % 32) / 256;
            //     }
            //     else{
            //         member.x += delta * 0.1 + (i % 32) / 256;
            //     }
            //     layer.editMember(i, member);
            // }
        });
    }
}

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

const game = new Phaser.Game(config);

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

Обычные спрайты в Phaser — это самостоятельные игровые объекты (GameObject), каждый со своим преобразованием, текстурой и логикой. При количестве в несколько тысяч производительность начинает резко падать из-за накладных расходов на управление и рендеринг каждого объекта по отдельности.

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

В примере создается 16 таких слоёв, и по клику на сцену каждый из них наполняется 250 000 спрайтами.

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

В методе preload загружается основная текстура логотипа, спрайт «космического злодея» (sprite) и набор из 32 однопиксельных изображений разных цветов. Последние часто используются для отладки или создания простых форм.

Ключевое действие происходит в create. Создается массив цветов HSVColorWheel, который предоставляет палитру из 360 оттенков. Затем в цикле создаются 16 GPU-слоёв. Обратите внимание на вызов this.add.spriteGPULayer. В примере закомментировано создание слоёв с пикселями (pixel${i}), а используется текстура sprite.

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

Параметр `0— это начальный размер буфера слоя. Далее, по клику, он будет динамически увеличиваться методомresize`.

Наполнение слоя спрайтами: метод createGPULayer

Метод createGPULayer отвечает за добавление большого количества спрайтов в слой. Сначала он проверяет, хватит ли места в буфере, и при необходимости увеличивает его размер.

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

Затем создается объект-шаблон (template), который описывает свойства одного спрайта и параметры их анимации (tween). Важно: здесь задаются не конечные значения, а параметры для твинов — базовая позиция (base), амплитуда колебаний (amplitude) и длительность (duration). Это позволяет позже анимировать спрайты «на лету».

В цикле для каждого добавляемого спрайта параметры шаблона рандомизируются, задаются уникальные цвета из палитры, и спрайт добавляется в слой.

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

Метод addMember копирует данные шаблона в GPU-буфер.

Супер-быстрое обновление: работа с буфером напрямую

Сердце примера — метод update. Здесь показаны три принципиально разных способа обновления данных спрайтов в слое, отличающиеся по скорости на порядки.

Самый быстрый способ — прямая работа с сырым (raw) буфером WebGL, который хранит данные всех спрайтов. Код получает доступ к Float32Array (viewF32), представляющему этот буфер, и в цикле модифицирует значение координаты X для каждого спрайта.

const layout = layer.submitterNode.instanceBufferLayout;
const f32 = layout.buffer.viewF32;
const stride = layout.layout.stride / f32.BYTES_PER_ELEMENT;
for (let i = 0; i < size; i++)
{
    const offset = i * stride;
    var x = f32[offset] + delta * 0.1 + (i % 32) / 256;
    if (x > 1024) { x -= 1024; }
    f32[offset] = x;
}
layer.setAllSegmentsNeedUpdate();

После модификации данных необходимо уведомить систему, что буфер изменился, с помощью setAllSegmentsNeedUpdate(). Этот подход невероятно эффективен, но требует понимания структуры буфера (рассчитывать stride и offset) и изменения только «плоских» данных.

В комментариях показаны более медленные, но и более удобные методы patchMember и editMember.

Почему прямой доступ к буферу — это рискованно?

Хотя прямой доступ к viewF32 даёт максимальную производительность, он сопряжён с рисками:

1. **Хрупкость:** Структура буфера (stride, порядок атрибутов) — это внутренняя деталь реализации Phaser и WebGL. Она может измениться в будущих версиях движка, сломав ваш код. 2. **Сложность:** Вам нужно точно знать, по какому смещению (offset) в массиве находятся нужные атрибуты (X, Y, rotation, tint и т.д.). Ошибка в расчёте смещения приведёт к визуальным артефактам. 3. **Отсутствие валидации:** Движок не проверяет записываемые вами значения. Вы можете случайно испортить данные, отвечающие за другие атрибуты, или выйти за границы массива.

Используйте этот метод только для критичных к производительности пассов, когда количество объектов исчисляется десятками тысяч, и вы готовы обновлять код при смене мажорной версии Phaser. Для большинства задач методы editMember или patchMember будут безопаснее и достаточно быстры.

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

SpriteGPULayer — это мощный инструмент для задач, требующих рендеринга огромного количества однотипных объектов. Прямая манипуляция GPU-буфером позволяет обновлять сотни тысяч позиций за доли миллисекунды. **Идеи для экспериментов:** 1. Замените текстуру sprite на атлас спрайтов и модифицируйте обновление буфера, чтобы менялся не только X, но и индекс кадра (frameIndex), создавая анимированную массовку. 2. Реализуйте простую систему частиц (дождь, снег, огонь), где в update обновляются не только координаты, но и, например, альфа-канал для эффекта исчезновения. 3. Создайте прототип стратегической игры, где юниты представлены таким слоем, и испытайте, насколько плавно можно перемещать армии из тысяч объектов, обновляя их позиции по сетке. 4. Сравните производительность трёх методов обновления (viewF32, patchMember, editMember) на разном количестве спрайтов (10k, 50k, 100k) и выведите FPS на экран.