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

При создании игр с большим количеством частиц производительность может резко упасть, особенно если эмиттеры находятся за пределами видимой области. В этой статье мы разберем пример, демонстрирующий технику отсечения (culling) эмиттеров частиц на основе их границ (bounds) и вида камеры. Вы узнаете, как рендерить только те частицы, которые потенциально видны игроку, экономя драгоценные ресурсы GPU и CPU.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        this.load.atlas('bubbles', 'assets/particles/bubbles.png', 'assets/particles/bubbles.json');
    }

    create ()
    {
        this.emitters = [];

        this.graphics = this.add.graphics();

        //  Our camera just for the help text
        const textCam = this.cameras.add(0, 0, 800, 600);

        let f = 1;
        const frames = [ 'bluebubble', 'redbubble', 'greenbubble', 'silverbubble' ];

        const emitter = this.createEmitter(400, 300, frames[0]);

        this.emitters.push(emitter);

        for (let i = 0; i < 64; i++)
        {
            const x = Phaser.Math.Between(-1900, 1900);
            const y = Phaser.Math.Between(-1900, 1900);

            const emitter = this.createEmitter(x, y, frames[f]);

            this.emitters.push(emitter);

            f++;

            if (f === frames.length)
            {
                f = 0;
            }
        }

        const cursors = this.input.keyboard.createCursorKeys();

        const controlConfig = {
            camera: this.cameras.main,
            left: cursors.left,
            right: cursors.right,
            up: cursors.up,
            down: cursors.down,
            zoomIn: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.Q),
            zoomOut: this.input.keyboard.addKey(Phaser.Input.Keyboard.KeyCodes.E),
            acceleration: 0.02,
            drag: 0.0005,
            maxSpeed: 1.0
        };

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

        const help = this.add.text(10, 10, 'Cursors to move camera. Q and E to zoom.').setScrollFactor(0);

        this.info = this.add.text(10, 40, '').setScrollFactor(0);

        this.cameras.main.ignore([ help, this.info ]);

        textCam.ignore([ this.graphics, ...this.emitters ]);
    }

    createEmitter (x, y, frame)
    {
        const emitter = this.add.particles(x, y, 'bubbles', {
            frame,
            scale: { min: 0.1, max: 0.5 },
            speed: { min: 20, max: 40 },
            alpha: { start: 1, end: 0 },
            lifespan: 2000,
            frequency: 50,
            gravityY: -90,
            particleBringToTop: false
        });

        emitter.viewBounds = emitter.getBounds(10, 6000);

        return emitter;
    }

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

        this.graphics.clear();
        this.graphics.fillStyle(0x2d2d6d);
        this.graphics.fillRect(-2000, -2000, 4000, 4000);

        this.graphics.lineStyle(1, 0x00ff00);

        var visible = 0;
        var offscreen = 0;
        var cam = this.cameras.main;
        var camBounds = cam.worldView;

        this.emitters.forEach(emitter => {

            this.graphics.strokeRectShape(emitter.viewBounds);

            if (Phaser.Geom.Intersects.RectangleToRectangle(emitter.viewBounds, camBounds))
            {
                visible++;
            }
            else
            {
                offscreen++;
            }

        });

        this.info.setText([
            `Particle Emitters: Rendering: ${visible} - Culled: ${offscreen}`,
            '',
            `Camera x: ${cam.scrollX} y: ${cam.scrollY}`
        ]);
    }
}

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

const game = new Phaser.Game(config);


Создание и настройка эмиттеров частиц

В методе create() создается массив эмиттеров this.emitters. Первый эмиттер помещается в центр сцены, а еще 64 — в случайных координатах в пределах большого игрового мира. Для создания каждого эмиттера используется вспомогательный метод createEmitter(). Это позволяет избежать дублирования кода конфигурации.

Метод createEmitter принимает координаты (x, y) и кадр (frame) текстуры из атласа. Он создает и возвращает объект эмиттера с заданными параметрами. Ключевой момент здесь — предварительный расчет границ (viewBounds) для каждого эмиттера.

const emitter = this.add.particles(x, y, 'bubbles', {
    frame,
    scale: { min: 0.1, max: 0.5 },
    speed: { min: 20, max: 40 },
    alpha: { start: 1, end: 0 },
    lifespan: 2000,
    frequency: 50,
    gravityY: -90,
    particleBringToTop: false
});

emitter.viewBounds = emitter.getBounds(10, 6000);

Метод emitter.getBounds() вычисляет прямоугольную область (Phaser.Geom.Rectangle), в которой будут находиться все частицы этого эмиттера на протяжении их жизненного цикла. Первый аргумент (10) — это запас (padding) по краям, второй (6000) — максимальное время в миллисекундах, на которое проецируются границы. Это создает "контрольную зону" для эмиттера.

Управление камерой и отсечение невидимых эмиттеров

Для навигации по обширному миру используется Phaser.Cameras.Controls.SmoothedKeyControl. Это плавное управление камерой с помощью клавиш стрелок (для перемещения) и Q/E (для зума). Объект управления создается в create() и обновляется каждый кадр в update().

this.controls.update(delta);

Основная логика оптимизации происходит в методе update(). Для каждого кадра мы очищаем холст this.graphics и рисуем на нем фон мира, а также зеленые прямоугольники — границы (viewBounds) всех эмиттеров. Это визуализация для отладки.

Затем для каждого эмиттера проверяется, пересекаются ли его границы (viewBounds) с текущим видом камеры (cam.worldView). Вид камеры — это прямоугольник, описывающий, какая часть мирового пространства видна на экране.

if (Phaser.Geom.Intersects.RectangleToRectangle(emitter.viewBounds, camBounds))
{
    visible++;
}
else
{
    offscreen++;
}

Счетчики visible и offscreen подсчитывают, сколько эмиттеров потенциально видно, а сколько — точно нет. Хотя в данном примере рендеринг эмиттеров не отключается физически, эта информация выводится на экран и является основой для реализации настоящего отсечения. Вы можете использовать этот флаг, чтобы приостанавливать (emitter.paused = true) или вовсе отключать (emitter.visible = false) эмиттеры за пределами камеры.

Визуализация и отладка

Пример не только оптимизирует, но и наглядно демонстрирует процесс. С помощью графического объекта (this.graphics) рисуется фон и границы всех эмиттеров. Это помогает понять размер и расположение их контрольных зон.

this.graphics.clear();
this.graphics.fillStyle(0x2d2d6d);
this.graphics.fillRect(-2000, -2000, 4000, 4000);

this.graphics.lineStyle(1, 0x00ff00);
...
this.graphics.strokeRectShape(emitter.viewBounds);

На экран также выводится информационная панель с помощью объектов Phaser.GameObjects.Text. Она показывает количество эмиттеров, которые находятся в зоне видимости камеры и за ее пределами, а также текущие координаты камеры. Эти тексты имеют setScrollFactor(0), что привязывает их к экрану, а не к миру, и они игнорируются основной камерой, чтобы не мешать обзору.

this.info.setText([
    `Particle Emitters: Rendering: ${visible} - Culled: ${offscreen}`,
    '',
    `Camera x: ${cam.scrollX} y: ${cam.scrollY}`
]);

Такая визуализация бесценна при настройке параметров getBounds() и понимании того, как работает отсечение.

Практические выводы и настройка

Параметры, передаваемые в getBounds(padding, maxTime), критически важны. Слишком маленький maxTime приведет к тому, что границы будут рассчитаны только на короткую дистанцию полета частиц, и некоторые из них могут появиться на краю экрана, но их эмиттер будет сочтен невидимым и отсечен. Слишком большой maxTime создаст огромные границы, сводя на нет всю оптимизацию, так как большинство эмиттеров всегда будут считаться видимыми.

// Слишком маленький maxTime - риск пропустить частицы
emitter.viewBounds = emitter.getBounds(10, 500);

// Слишком большой maxTime - границы будут огромными
emitter.viewBounds = emitter.getBounds(10, 30000);

// Золотая середина зависит от lifespan и speed частиц
emitter.viewBounds = emitter.getBounds(10, 6000);

Padding (запас) нужен для учета разброса в размере (scale) и скорости (speed) частиц. Без запаса самая быстрая или большая частица может вылететь за рассчитанные границы.

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

Использование getBounds() для расчета зоны влияния эмиттера и последующая проверка пересечения с cam.worldView — мощный метод оптимизации систем частиц в больших мирах. Вы можете развить эту идею: реализовать настоящую паузу для невидимых эмиттеров, настроить многоуровневое отсечение (LOD) с разной частотой испускания частиц в зависимости от расстояния до камеры или применить эту технику не только к частицам, но и к другим игровым объектам со статичными или предсказуемыми границами.