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

Рендеринг тысяч анимированных персонажей — классическая задача для игр жанров 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 для градиентной окраски; или реализовать систему частиц на основе этого слоя, обновляя позиции и время жизни в массиве каждым кадром.