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

В игровых проектах часто возникает необходимость работать с огромным количеством объектов на сцене, будь то инвентарь, карта мира или стратегические юниты. Проблема производительности при рендеринге и обработке взаимодействий для тысяч объектов — классический вызов. В этой статье мы разберем пример из репозитория Phaser, который демонстрирует, как создать и эффективно управлять 10 000 интерактивными спрайтами, используя группировку, отключение кадрирования камеры (culling) и оптимизацию обработки ввода. Вы научитесь создавать масштабируемые интерактивные сцены, которые остаются отзывчивыми.

Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.

Живой запуск

Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.

Исходный код


class Example extends Phaser.Scene
{
    controls;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.spritesheet('bobs', 'assets/sprites/bobs-by-cleathley.png', { frameWidth: 32, frameHeight: 32 });
    }

    create ()
    {
        //  Create a little 32x32 texture to use to show where the mouse is
        const graphics = this.make.graphics({ x: 0, y: 0, add: false, fillStyle: { color: 0xff00ff, alpha: 1 } });

        graphics.fillRect(0, 0, 32, 32);

        graphics.generateTexture('block', 32, 32);

        const highlighted = this.add.image(16, 16, 'block');

        //  All the Images can share the same Shape, no need for a unique instance per one, a reference is fine
        const hitArea = new Phaser.Geom.Rectangle(0, 0, 32, 32);
        const hitAreaCallback = Phaser.Geom.Rectangle.Contains;

        //  Create 10,000 Image Game Objects aligned in a grid
        //  Change this to 2000 on MS Edge as it can't seem to cope with 10k at the moment
        const group = this.make.group({
            classType: Phaser.GameObjects.Image,
            key: 'bobs',
            frame: Phaser.Utils.Array.NumberArray(0, 399),
            randomFrame: true,
            repeat: 24,
            max: 10000,
            hitArea: hitArea,
            hitAreaCallback: hitAreaCallback,
            gridAlign: {
                width: 100,
                cellWidth: 32,
                cellHeight: 32
            }
        });

        //  Camera controls
        const cursors = this.input.keyboard.createCursorKeys();

        const controlConfig = {
            camera: this.cameras.main,
            left: cursors.left,
            right: cursors.right,
            up: cursors.up,
            down: cursors.down,
            acceleration: 0.04,
            drag: 0.0005,
            maxSpeed: 0.7
        };

        this.cameras.main.disableCull = true;

        this.controls = new Phaser.Cameras.Controls.SmoothedKeyControl(controlConfig);

        this.input.on('pointerover', (pointer, gameObjects) =>
        {

            highlighted.setPosition(gameObjects[0].x, gameObjects[0].y);

        });

        this.input.on('gameobjectdown', (pointer, gameObject) =>
        {

            gameObject.visible = false;

        });
    }

    update (time, delta)
    {
        this.controls.update(delta);
    }
}

const config = {
    type: Phaser.WEBGL,
    parent: 'phaser-example',
    width: 800,
    height: 600,
    scene: Example
};

const game = new Phaser.Game(config);

Подготовка ресурсов и создание курсора

Прежде чем создавать армию спрайтов, нужно подготовить визуальные элементы. В методе preload загружается спрайтшит. В create создается простая текстура 32x32 пикселя, которая будет служить индикатором наведения курсора мыши. Это помогает визуализировать взаимодействие.

const graphics = this.make.graphics({ x: 0, y: 0, add: false, fillStyle: { color: 0xff00ff, alpha: 1 } });
graphics.fillRect(0, 0, 32, 32);
graphics.generateTexture('block', 32, 32);
const highlighted = this.add.image(16, 16, 'block');

Массовое создание объектов через Group

Ключевой инструмент для работы с большим количеством объектов — Phaser.GameObjects.Group. Вместо ручного создания 10 000 изображений мы используем фабричный метод this.make.group. Он позволяет сгенерировать множество объектов с общими настройками: классом, текстурой, кадром анимации и, что важно, областью взаимодействия (hitArea).

Область взаимодействия и функция проверки (hitAreaCallback) задаются один раз для всей группы, что экономит память. Параметр gridAlign автоматически выравнивает объекты в сетку 100x100.

const hitArea = new Phaser.Geom.Rectangle(0, 0, 32, 32);
const hitAreaCallback = Phaser.Geom.Rectangle.Contains;
const group = this.make.group({
    classType: Phaser.GameObjects.Image,
    key: 'bobs',
    frame: Phaser.Utils.Array.NumberArray(0, 399),
    randomFrame: true,
    repeat: 24,
    max: 10000,
    hitArea: hitArea,
    hitAreaCallback: hitAreaCallback,
    gridAlign: {
        width: 100,
        cellWidth: 32,
        cellHeight: 32
    }
});

Управление камерой и отключение кадрирования

Чтобы можно было исследовать огромную сцену, реализовано плавное управление камерой с клавиатуры с помощью Phaser.Cameras.Controls.SmoothedKeyControl. Конфигурация задает клавиши, ускорение, сопротивление и максимальную скорость.

Самая важная для производительности строка — this.cameras.main.disableCull = true. По умолчанию Phaser не отрисовывает объекты, находящиеся за пределами видимости камеры (техника culling). Для 10 000 объектов постоянное вычисление видимости может быть дорогим. Отключение кадрирования упрощает рендеринг, так как все объекты отрисовываются всегда. В WebGL это часто работает быстрее для большого количества простых спрайтов.

this.cameras.main.disableCull = true;
const controlConfig = {
    camera: this.cameras.main,
    left: cursors.left,
    right: cursors.right,
    up: cursors.up,
    down: cursors.down,
    acceleration: 0.04,
    drag: 0.0005,
    maxSpeed: 0.7
};
this.controls = new Phaser.Cameras.Controls.SmoothedKeyControl(controlConfig);

Обработка взаимодействия с объектами

Несмотря на количество, каждый спрайт остается интерактивным. Обработчики событий ввода pointerover и gameobjectdown привязаны ко всей сцене. Phaser автоматически определяет, с каким объектом из группы произошло взаимодействие, благодаря заданной hitArea.

При наведении курсора (pointerover) индикатор highlighted перемещается на координаты первого затронутого игрового объекта из массива gameObjects. При клике (gameobjectdown) объект становится невидимым (visible = false).

this.input.on('pointerover', (pointer, gameObjects) => {
    highlighted.setPosition(gameObjects[0].x, gameObjects[0].y);
});
this.input.on('gameobjectdown', (pointer, gameObject) => {
    gameObject.visible = false;
});

Обновление состояния камеры

Плавное движение камеры требует обновления в каждом кадре. В методе update вызывается this.controls.update(delta), куда передается время, прошедшее с предыдущего кадра. Это обеспечивает независимость скорости камеры от частоты кадров.

update (time, delta)
{
    this.controls.update(delta);
}

Что попробовать дальше

Этот пример наглядно показывает, что Phaser способен эффективно работать с десятками тысяч интерактивных объектов. Главные секреты — использование Group для массового создания, общая hitArea для оптимизации ввода и отключение кадрирования камеры для снижения вычислительной нагрузки на CPU. Для экспериментов попробуйте изменить параметры gridAlign, чтобы создать не сетку, а другую формацию. Добавьте физические тела к объектам в группе или реализуйте более сложную логику при клике, например, воспроизведение анимации или изменение кадра спрайта.