О чем этот пример
В разработке игр часто возникает задача отображения огромного количества объектов без падения производительности. Классический подход с отдельными спрайтами быстро упирается в лимиты отрисовки. Phaser 3 предлагает мощное решение — `SpriteGPULayer`. Этот механизм позволяет управлять десятками и даже сотнями тысяч спрайтов как единым буферным объектом на GPU, обновляя их данные напрямую. В этой статье мы разберем практический пример, где 65536 спрайтов плавно движутся и взаимодействуют с курсором мыши, и научимся работать с низкоуровневым буфером для максимальной производительности.
Версия 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`);
}
}
populateGPULayer ()
{
const layer = this.spriteGPULayer;
if (layer.memberCount < layer.size)
{
const colors = this.colors;
const template = {
x: {
ease: 'Quad.easeInOut',
},
y: {
ease: 'Quad.easeInOut',
},
rotation: {
ease: 'Linear',
amplitude: 3,
duration: 500
},
// alpha: 0.25,
tintBlend: {
base: 1,
ease: 'Linear'
}
};
const len = layer.size;
for (let i = layer.memberCount; i < len; 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(0, 768);
template.y.amplitude = Phaser.Math.Between(-6, 6);
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);
}
}
}
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 = 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);
this.populateGPULayer();
text.setText(`Sprites: ${format(layer.size)}`);
console.log(layer.size);
});
}
update (time, delta)
{
// Apply a lateral motion around the mouse.
const mx = this.input.mousePointer.x;
const my = this.input.mousePointer.y;
const layout = this.spriteGPULayer.submitterNode.instanceBufferLayout;
const stride = layout.layout.stride / Float32Array.BYTES_PER_ELEMENT;
const len = this.spriteGPULayer.size;
const f32 = layout.buffer.viewF32;
for (let i = 0; i < len; i++)
{
const address = i * stride;
let x = f32[address];
let y = f32[address + 4];
const dx = x - mx;
const dy = y - my;
const a = Math.atan2(dy, dx);
const h = Math.sqrt(dx * dx + dy * dy);
const k = ((i + Math.random()) % 32 + 1) / 256;
const a2 = a + Math.PI / 4;
x += Math.cos(a2) * k * delta;
y += Math.sin(a2) * k * delta;
const f = Math.pow(Math.max((h - 128) / 128, 0), 2) / h / 8;
x -= f * dx * k * delta;
y -= f * dy * k * delta;
f32[address] = x;
f32[address + 4] = y;
}
this.spriteGPULayer.setAllSegmentsNeedUpdate();
}
}
const config = {
type: Phaser.WEBGL,
parent: 'phaser-example',
width: 1024,
height: 768,
scene: Example,
backgroundColor: '#202020'
};
const game = new Phaser.Game(config);
Что такое SpriteGPULayer и зачем он нужен?
SpriteGPULayer — это специализированный контейнер для спрайтов, оптимизированный для массового рендеринга. Вместо того чтобы каждый спрайт был отдельным объектом с собственными трансформациями и вызовами отрисовки, все спрайты слоя хранятся в едином буфере на стороне GPU (вершинном буфере). Это кардинально снижает нагрузку на CPU и количество draw calls, позволяя отрисовывать колоссальное количество объектов в одном кадре.
Слой создается с указанием ключа текстуры и начального размера. Все спрайты внутри него используют один и тот же текстурированный квад (изображение). Основная магия происходит в методе update, где мы напрямую манипулируем данными в буфере, что дает нам полный контроль над позицией, цветом и другими атрибутами каждого спрайта с минимальными накладными расходами.
Инициализация слоя и добавление спрайтов
В методе create происходит базовая настройка. Сначала генерируется цветовая палитра с помощью Phaser.Display.Color.HSVColorWheel(), которая позже будет использоваться для окрашивания спрайтов.
Затем создается сам SpriteGPULayer. Ключевой параметр — count, который определяет максимальную емкость слоя. В примере он установлен в 65536, но код содержит закомментированные варианты вплоть до 524288 спрайтов, что демонстрирует потенциал технологии.
const layer = this.add.spriteGPULayer('sprite', 0);
this.spriteGPULayer = layer;
const count = 65536;
Спрайты добавляются в слой не сразу, а по клику мыши. Обработчик события pointerdown увеличивает размер слоя и вызывает метод populateGPULayer, который наполняет слой спрайтами с уникальными параметрами анимации.
this.input.on('pointerdown', () => {
layer.resize(layer.size + count);
this.populateGPULayer();
text.setText(`Sprites: ${format(layer.size)}`);
});
Наполнение слоя: шаблоны и анимация
Метод populateGPULayer отвечает за добавление новых спрайтов-членов в слой. Каждый спрайт добавляется с помощью метода layer.addMember(template), где template — это объект-конфигурация.
Шаблон определяет, как будут анимироваться свойства спрайта: базовая позиция (x.base, y.base), амплитуда и длительность колебаний вокруг этой позиции, задержка вращения (rotation.delay). Для каждого нового спрайта эти значения задаются случайно в определенных диапазонах с помощью Phaser.Math.Between и Phaser.Math.Wrap. Это создает эффект независимого, но управляемого движения для каждого объекта.
template.x.base = Phaser.Math.Between(0, 1024);
template.x.amplitude = Phaser.Math.Between(-6, 6);
template.x.duration = Phaser.Math.Between(500, 1000);
Цвета для тонирования (tintTopLeft, tintTopRight) берутся из заранее созданной HSV-палитры, что обеспечивает плавный цветовой градиент по всем спрайтам.
template.tintTopLeft = colors[Phaser.Math.Wrap(i, 0, 359)].color;
Прямой доступ к буферу: сердце высокой производительности
Самая важная и продвинутая часть примера — метод update. Здесь реализуется интерактивное поведение: все спрайты реагируют на положение курсора мыши. Для этого мы получаем прямой доступ к сырому буферу данных слоя (instanceBufferLayout).
const layout = this.spriteGPULayer.submitterNode.instanceBufferLayout;
const stride = layout.layout.stride / Float32Array.BYTES_PER_ELEMENT;
const len = this.spriteGPULayer.size;
const f32 = layout.buffer.viewF32;
Переменная stride указывает, сколько элементов типа float32 занимают данные одного спрайта в буфере. В цикле мы проходим по всем спрайтам, вычисляем их новый адрес в буфере и читаем/записываем значения координат `xиyнапрямую вFloat32Array`.
Алгоритм внутри цикла делает две вещи: 1. Заставляет спрайты двигаться по касательной (под углом 45 градусов) относительно направления на курсор. 2. Добавляет силу "отталкивания" от курсора, которая ослабевает с расстоянием. Это создает сложное, роеобразное движение.
f32[address] = x;
f32[address + 4] = y;
После модификации всех данных необходимо явно сообщить системе, что буфер обновился, с помощью this.spriteGPULayer.setAllSegmentsNeedUpdate(). Без этого вызова визуальные изменения не применятся.
Конфигурация проекта и ограничения
Важное условие для работы SpriteGPULayer — использование контекста WebGL. В конфигурации игры должен быть указан type: Phaser.WEBGL.
const config = {
type: Phaser.WEBGL,
parent: 'phaser-example',
width: 1024,
height: 768,
scene: Example,
backgroundColor: '#202020'
};
Следует помнить, что при прямом управлении буфером вы берете на себя ответственность за корректность данных и их расположение в памяти. Неправильное вычисление stride или индексов может привести к визуальным артефактам или падению производительности. Этот подход требует понимания того, как данные организованы в буфере, но взамен дает непревзойденную скорость.
Что попробовать дальше
SpriteGPULayer — это ключ к созданию в Phaser 3 масштабных визуальных эффектов, систем частиц или огромных скоплений юнитов, где количество объектов исчисляется десятками тысяч. Прямая работа с буфером экземпляров открывает дверь в высокопроизводительную графику. Для экспериментов попробуйте: изменить логику движения в update на гравитационное притяжение или отталкивание между самими спрайтами; анимировать другие свойства буфера, например, масштаб или цвет (tint); комбинировать несколько SpriteGPULayer с разными текстурами для сложных сцен.
