О чем этот пример
Вы когда-нибудь сталкивались с падением 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 для создания плавных цветовых градиентов в массовых эффектах.
