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

Визуализация огромного количества объектов — классическая проблема производительности в играх. Отрисовка каждого спрайта отдельным вызовом отрисовки (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 для создания градиентов или паттернов; заменить анимацию альфа-канала на анимацию масштаба или цвета; использовать текстуру с готовым спрайтлистом (тайлсетом) для создания огромных анимированных тайловых карт; или привязать позиции спрайтов к данным симуляции (например, частиц в физическом поле).