О чем этот пример
Рендеринг тысяч анимированных персонажей — классическая задача для игр жанров RTS, RPG или аркадных выживалок. Традиционный подход с созданием отдельных игровых объектов `Sprite` быстро упрётся в ограничения производительности CPU. В этой статье разберём продвинутую технику — `SpriteGPULayer` — которая переносит вычисления трансформаций и анимации на GPU, позволяя отрисовывать десятки тысяч анимаций с минимальными затратами ресурсов процессора. Вы научитесь готовить данные, управлять состоянием спрайтов через ArrayBuffer и эффективно заполнять сцену.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
spriteGPULayer;
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.path = 'assets/animations/aseprite/';
this.load.aseprite('paladin', 'paladin.png', 'paladin.json');
}
create ()
{
const tags = this.anims.createFromAseprite('paladin');
const size = this.renderer.height + 128;
this.spriteGPULayer = this.add.spriteGPULayer('paladin', size);
const spriteGPULayer = this.spriteGPULayer;
// Generate animation data in the layer:
spriteGPULayer.setAnimations(tags);
// Create a data template.
const abSize = spriteGPULayer.getDataByteSize(); // Or 168, at time of coding.
const ab = new ArrayBuffer(abSize);
const u32 = new Uint32Array(ab);
const f32 = new Float32Array(ab);
// Set non-zero template properties.
// Scale X
f32[12] = 1;
// Scale Y
f32[16] = 1;
// Alpha
f32[20] = 1;
// Frame index animation ease and delay
f32[27] = spriteGPULayer.EASE.Linear;
// Note: the delay is added to the ease as `delay / duration`, but here it's 0.
// VERY IMPORTANT: Enable the ease type.
spriteGPULayer.setAnimationEnabled('Linear', true);
// Tint blend (enables tinting)
f32[28] = 1;
// Tint+alpha bottom-left, top-left, bottom-right, top-right
u32[32] = 0xffffffff;
u32[33] = 0xffffffff;
u32[34] = 0xffffffff;
u32[35] = 0xffffffff;
// Origin X
f32[36] = 0.5;
// Origin Y
f32[37] = 0.5;
// Scroll factor X
f32[40] = 1;
// Scroll factor Y
f32[41] = 1;
// Alternatively, set the entire template from an array:
/*
f32.set([
0, 0, 0, 0, // position X
0, 0, 0, 0, // position Y
0, 0, 0, 0, // rotation
1, 0, 0, 0, // scale X
1, 0, 0, 0, // scale Y
1, 0, 0, 0, // alpha
0, 0, 0, spriteGPULayer.EASE.Linear, // frame index
1, 0, 0, 0, // tint blend
0, 0, 0, 0, // tint bottom-left, top-left, bottom-right, top-right
0.5, 0.5, 0, 0, // origin X, Y, tintMode, creationTime
1, 1 // scroll factor X, Y
]);
// Set the tint colors as Uint32Array, because float32 doesn't support these values.
u32[32] = 0xffffffff;
u32[33] = 0xffffffff;
u32[34] = 0xffffffff;
u32[35] = 0xffffffff;
*/
// Populate the scene.
for (let y = 0; y < size; y++)
{
const anim = Phaser.Utils.Array.GetRandom(tags);
const animData = spriteGPULayer.animationDataNames[anim.key];
// Frame index animation: [base, amplitude, duration] = [index, frameCount, duration]
f32[24] = animData.index;
f32[25] = animData.frameCount;
f32[26] = animData.duration;
// Note: set duration to negative to enable yoyo.
// X
f32[0] = Math.random() * this.renderer.width;
// Y
f32[4] = y;
spriteGPULayer.addData(f32);
}
}
}
const config = {
type: Phaser.WEBGL,
parent: 'phaser-example',
width: 1280,
height: 720,
scene: Example,
backgroundColor: '#202020'
};
const game = new Phaser.Game(config);
Что такое SpriteGPULayer и зачем он нужен?
SpriteGPULayer — это специализированный игровой объект в Phaser, предназначенный для массового рендеринга спрайтов из одного текстура-атласа. В отличие от обычных спрайтов, которые являются самостоятельными объектами с отдельными компонентами (позиция, масштаб, анимация), SpriteGPULayer работает с данными в виде сырых байтов (ArrayBuffer).
Этот буфер, содержащий информацию о позиции, повороте, масштабе, кадре анимации и цвете для каждого спрайта, загружается один раз в память видеокарты. Все последующие вычисления (например, перемещение или смена кадра) происходят в шейдерах GPU. Такой подход сводит к минимуму коммуникацию между CPU и GPU, что даёт огромный прирост производительности при отрисовке тысяч однотипных объектов.
**Ключевое отличие:** Вы управляете не объектами, а данными в массиве. Это требует другого подхода к программированию, но открывает двери к созданию масштабных сцен.
Подготовка: Загрузка анимации и создание слоя
Первым делом загружаем данные анимации, экспортированные из Aseprite. Важно использовать метод createFromAseprite, который автоматически создаст все теги анимации из JSON-файла.
Затем создаём сам слой. Конструктор add.spriteGPULayer принимает ключ текстуры и максимальное количество спрайтов, которые слой сможет содержать. Мы задаём размер, равный высоте рендера плюс запас.
После создания слоя необходимо передать в него информацию об анимациях с помощью метода setAnimations. Это позволяет слою знать, какие последовательности кадров доступны.
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.path = 'assets/animations/aseprite/';
this.load.aseprite('paladin', 'paladin.png', 'paladin.json');
}
create ()
{
// Создаём анимации из данных Aseprite
const tags = this.anims.createFromAseprite('paladin');
// Рассчитываем размер слоя (например, высота экрана + 128 спрайтов)
const size = this.renderer.height + 128;
// Создаём GPU-слой для спрайта 'paladin'
this.spriteGPULayer = this.add.spriteGPULayer('paladin', size);
const spriteGPULayer = this.spriteGPULayer;
// Передаём созданные анимации в слой
spriteGPULayer.setAnimations(tags);
}
Создание шаблона данных: Структура ArrayBuffer
Сердце SpriteGPULayer — это ArrayBuffer, который хранит состояние всех спрайтов. Размер этого буфера для одного спрайта можно получить через spriteGPULayer.getDataByteSize() (на момент написания это 168 байт).
Буфер просматривается через два типизированных массива: Float32Array (f32) для числовых значений (позиция, масштаб) и Uint32Array (u32) для цветов в формате RGBA.
В коде мы вручную устанавливаем значения по определённым индексам, которые соответствуют конкретным свойствам спрайта. Например, индекс 12 в f32 — это масштаб по X. Установка значения `1` означает исходный размер.
**Важные моменты настройки:**
* Тип интерполяции кадра (EASE.Linear) необходимо не только записать в буфер, но и явно включить для слоя через setAnimationEnabled.
* Чтобы работало tint-окрашивание, нужно активировать blend-режим (f32[28] = 1) и задать цвета в формате Uint32 для четырёх углов спрайта.
* Альтернативно, можно заполнить весь шаблон одним массивом, что иногда нагляднее.
// Получаем размер данных для одного спрайта
const abSize = spriteGPULayer.getDataByteSize();
const ab = new ArrayBuffer(abSize);
const u32 = new Uint32Array(ab);
const f32 = new Float32Array(ab);
// Устанавливаем свойства шаблона по умолчанию (ненулевые)
f32[12] = 1; // Scale X
f32[16] = 1; // Scale Y
f32[20] = 1; // Alpha (непрозрачность)
// Настраиваем линейную интерполяцию кадра анимации
f32[27] = spriteGPULayer.EASE.Linear;
spriteGPULayer.setAnimationEnabled('Linear', true); // ВАЖНО: Включаем!
// Включаем tint-окрашивание и задаём белый цвет для всех углов
f32[28] = 1; // Tint blend (включён)
u32[32] = 0xffffffff; // Bottom-left (RGBA)
u32[33] = 0xffffffff; // Top-left
u32[34] = 0xffffffff; // Bottom-right
u32[35] = 0xffffffff; // Top-right
f32[36] = 0.5; // Origin X (центр)
f32[37] = 0.5; // Origin Y (центр)
Наполнение сцены: Данные для каждого спрайта
Когда шаблон готов, мы можем в цикле создавать данные для каждого отдельного спрайта, изменяя значения в нашем Float32Array, и затем добавлять копию этого массива в слой через spriteGPULayer.addData().
Для анимации нужно указать три ключевых параметра в строго отведённых ячейках массива f32:
* **Базовый индекс анимации (f32[24])**: Начальный кадр последовательности в текстуре-атласе. Берётся из animData.index.
* **Амплитуда/количество кадров (f32[25])**: Сколько кадров содержит анимация (animData.frameCount).
* **Длительность (f32[26])**: Время в миллисекундах для проигрывания всей анимации (animData.duration). Если установить отрицательное значение, анимация будет играть в режиме "йо-йо" (туда-обратно).
После настройки анимации задаём случайную позицию по X и фиксированную по Y (для создания вертикального столбца спрайтов).
// Заполняем сцену спрайтами
for (let y = 0; y < size; y++)
{
// Выбираем случайную анимацию из доступных тегов
const anim = Phaser.Utils.Array.GetRandom(tags);
const animData = spriteGPULayer.animationDataNames[anim.key];
// Настраиваем параметры анимации в шаблоне
f32[24] = animData.index; // Стартовый кадр
f32[25] = animData.frameCount; // Всего кадров
f32[26] = animData.duration; // Длительность (мс)
// f32[26] = -animData.duration; // Для режима YOYO
// Задаём уникальную позицию для этого спрайта
f32[0] = Math.random() * this.renderer.width; // Случайный X
f32[4] = y; // Y = номер итерации
// Добавляем КОПИЮ текущего состояния шаблона в слой
spriteGPULayer.addData(f32);
}
Что попробовать дальше
SpriteGPULayer — это мощный инструмент для ситуаций, где количество важнее индивидуальности каждого объекта. Вы научились формировать буфер данных, настраивать свойства спрайтов на низком уровне и эффективно заполнять сцену. Для экспериментов попробуйте: динамически изменять данные (например, f32[0]) для движения толпы; использовать разные типы интерполяции (EASE.Sine.InOut) для плавности; применять per-vertex tint для градиентной окраски; или реализовать систему частиц на основе этого слоя, обновляя позиции и время жизни в массиве каждым кадром.
