О чем этот пример
Визуализация огромного количества объектов — классическая проблема производительности в играх. Отрисовка каждого спрайта отдельным вызовом отрисовки (draw call) быстро исчерпывает ресурсы CPU. В этой статье мы разберем пример из официального репозитория 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('swatch', 'assets/swatches/colormap.png');
}
create ()
{
const texture = this.textures.get('swatch');
const textureBase = texture.get();
const width = textureBase.width;
const height = textureBase.height;
for (let y = 0; y < height; y++)
{
for (let x = 0; x < width; x++)
{
// Create a frame for each pixel in the texture
texture.add(`${x + y * width}`, 0, x, y, 1, 1);
}
}
const caption = this.add.text(10, 10, '1,000,000 Sprites (1 per pixel)', { font: '16px Courier', fill: '#ffffff' }).setDepth(1);
const layer = this.add.spriteGPULayer('swatch', 1000000);
const template = {
alpha:
{
base: 1,
amplitude: -1,
ease: 'Quad.easeInOut',
duration: 1000
},
originX: 0.5,
originY: 0.5
};
// Create a coarse grid of cells with noise values.
const grid = [];
for (let x = 0; x < 10; x++)
{
grid[x] = [];
for (let y = 0; y < 10; y++)
{
grid[x][y] = Math.random();
}
}
for (let y = 0; y < 1000; y++)
{
for (let x = 0; x < 1000; x++)
{
// Get a weighted average of the noise value at this point.
const x0 = Math.floor(x / 100);
const x1 = Math.ceil(x / 100) % 10;
const y0 = Math.floor(y / 100);
const y1 = Math.ceil(y / 100) % 10;
const dx = x / 100 - x0;
const dy = y / 100 - y0;
const v00 = grid[x0][y0];
const v01 = grid[x0][y1];
const v10 = grid[x1][y0];
const v11 = grid[x1][y1];
const v0 = v00 * (1 - dx) + v10 * dx;
const v1 = v01 * (1 - dx) + v11 * dx;
const v = v0 * (1 - dy) + v1 * dy;
template.x = x;
template.y = y;
template.frame = `${(x % width) + (y % height) * width}`;
template.alpha.duration = 1000 + v * 100;
template.alpha.delay = (x + y) * 2 - 10000;
layer.addMember(template);
}
}
}
}
const config = {
type: Phaser.WEBGL,
parent: 'phaser-example',
width: 1000,
height: 1000,
scene: Example
};
const game = new Phaser.Game(config);
Подготовка текстуры: создание фреймов для каждого пикселя
Ключевая идея — использовать один спрайтлист (текстуру), но манипулировать отображением отдельных её пикселей как независимыми кадрами (frames). Это позволяет иметь огромное визуальное разнообразие, используя минимальный объем видеопамяти.
В методе preload загружается простая текстурная карта (colormap.png). В create мы получаем доступ к этой текстуре через this.textures.get('swatch').
const texture = this.textures.get('swatch');
const textureBase = texture.get();
const width = textureBase.width;
const height = textureBase.height;
Далее в двойном цикле для каждого пикселя исходной текстуры создается отдельный фрейм размером 1x1 пиксель. Метод texture.add регистрирует новый фрейм с уникальным именем, которое вычисляется как x + y * width. Это гарантирует, что у каждого из, например, 4096 пикселей (если текстура 64x64) будет свой идентификатор.
for (let y = 0; y < height; y++)
{
for (let x = 0; x < width; x++)
{
texture.add(`${x + y * width}`, 0, x, y, 1, 1);
}
}
Создание GPU-слоя и шаблона спрайта
Сердце примера — объект SpriteGPULayer. Он создается методом this.add.spriteGPULayer(key, capacity), где key — имя текстуры, а capacity — максимальное количество спрайтов, которые слой может содержать (в нашем случае — 1 000 000). Этот слой оптимизирован для отрисовки всех своих спрайтов за минимальное количество вызовов отрисовки.
const layer = this.add.spriteGPULayer('swatch', 1000000);
Для массового создания спрайтов используется шаблон (template) — объект с конфигурацией. Он определяет общие свойства для всех добавляемых спрайтов, такие как прозрачность (alpha), точка вращения (origin) и, что важно, имя кадра (frame).
const template = {
alpha:
{
base: 1,
amplitude: -1,
ease: 'Quad.easeInOut',
duration: 1000
},
originX: 0.5,
originY: 0.5
};
Свойство alpha здесь настроено на анимацию: base (базовое значение) равно 1, а amplitude (амплитуда изменения) равно -1. Это значит, что альфа будет колебаться от 1 до 0 (1 + (-1)) и обратно, создавая эффект мерцания. ease и duration управляют плавностью и длительностью этого цикла.
Генерация сетки шума и расстановка спрайтов
Чтобы анимация миллиона спрайтов выглядела органично, а не единообразно, используется простая техника интерполяции шума. Сначала создается грубая сетка 10x10, каждая ячейка которой содержит случайное значение.
const grid = [];
for (let x = 0; x < 10; x++)
{
grid[x] = [];
for (let y = 0; y < 10; y++)
{
grid[x][y] = Math.random();
}
}
Затем в двойном цикле создается 1000x1000 (миллион) спрайтов. Для каждой позиции (x, y) вычисляется взвешенное среднее значение (`v) на основе четырёх ближайших точек сетки (v00,v01,v10,v11). Это значениеv` (от 0 до 1) будет использоваться для вариации анимации.
const x0 = Math.floor(x / 100);
const x1 = Math.ceil(x / 100) % 10;
// ... аналогично для y0, y1
const dx = x / 100 - x0;
const dy = y / 100 - y0;
// Билинейная интерполяция
const v0 = v00 * (1 - dx) + v10 * dx;
const v1 = v01 * (1 - dx) + v11 * dx;
const v = v0 * (1 - dy) + v1 * dy;
Перед добавлением каждого спрайта шаблон модифицируется:
- template.x и template.y задают позицию.
- template.frame выбирает один из заранее созданных пиксельных фреймов, используя модульную арифметику, чтобы равномерно распределить цвета по полю.
- template.alpha.duration варьируется на основе значения `v`, чтобы длительность мерцания была разной.
- template.alpha.delay задаёт задержку начала анимации в зависимости от позиции, создавая волнообразный эффект.
template.x = x;
template.y = y;
template.frame = `${(x % width) + (y % height) * width}`;
template.alpha.duration = 1000 + v * 100;
template.alpha.delay = (x + y) * 2 - 10000;
layer.addMember(template);
Метод layer.addMember(template) добавляет новый спрайт в GPU-слой, используя текущие значения шаблона.
Что попробовать дальше
Пример наглядно показывает мощь SpriteGPULayer для задач, требующих отрисовки десятков или сотен тысяч объектов. Основной выигрыш — в переносе вычислений и отрисовки на GPU и минимизации коммуникации между CPU и GPU. Для экспериментов попробуйте: изменить логику выбора frame для создания градиентов или паттернов; заменить анимацию альфа-канала на анимацию масштаба или цвета; использовать текстуру с готовым спрайтлистом (тайлсетом) для создания огромных анимированных тайловых карт; или привязать позиции спрайтов к данным симуляции (например, частиц в физическом поле).
