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

Создание спецэффектов с тысячами частиц — частая задача в играх, но рендерить каждую как отдельный спрайт тяжело для производительности. Пример из официальной коллекции Phaser демонстрирует мощную технику: генерацию пользовательской текстуры с помощью Graphics и её массовое использование через Blitter. Это позволяет отображать тысячи однотипных объектов (например, падающие звёзды) с минимальными затратами. В статье разберём, как работает этот пример, и как применить эти знания для создания дождя, огня или звёздного неба в вашем проекте.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    iter = 0;
    numbers = [];
    digits;
    frame = 'veg01';
    idx = 1;
    gravity = 0.5;
    bobs = [];
    blitter;
    add = false;
    scene = null;

    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');

        this.load.atlas('atlas', 'assets/tests/fruit/veg.png', 'assets/tests/fruit/veg.json');

    }

    create ()
    {

        const starGraphics = this.make.graphics({x: 0, y: 0, add: false});
        const scale = 1;
        this.drawStar(starGraphics, 25, 25, 5, 25, 12, 0xFFFF00, 0xFF0000);
        starGraphics.generateTexture('starGraphics', 50, 50);

        this.scene = this;

        this.numbers.push(this.add.image(30 + 0 * 48, 720, 'atlas', '0'));
        this.numbers.push(this.add.image(30 + 1 * 48, 720, 'atlas', '0'));
        this.numbers.push(this.add.image(30 + 2 * 48, 720, 'atlas', '0'));
        this.numbers.push(this.add.image(30 + 3 * 48, 720, 'atlas', '0'));
        this.numbers.push(this.add.image(30 + 4 * 48, 720, 'atlas', '0'));
        this.numbers.push(this.add.image(30 + 5 * 48, 720, 'atlas', '0'));
        this.numbers.push(this.add.image(30 + 6 * 48, 720, 'atlas', '0'));

        this.blitter = this.add.blitter(0, 0, 'starGraphics');

        for (let i = 0; i < 100; ++i)
        {
            this.launch();
        }

        this.updateDigits();

    }

    update ()
    {

        if (this.add)
        {
            for (let i = 0; i < 250; ++i)
            {
                this.launch();

                if (this.blitter.children.length === 2000)
                {
                    //  Create a new blitter object, as they can only hold 10k bobs each
                    this.blitter = this.add.blitter(0, 0, 'starGraphics');
                }
            }

            this.updateDigits();
        }

        for (let index = 0, length = this.bobs.length; index < length; ++index)
        {
            const bob = this.bobs[index];
            bob.data.vy += this.gravity;

            bob.y += bob.data.vy;
            bob.x += bob.data.vx;

            if (bob.x > 1024)
            {
                bob.x = 1024;
                bob.data.vx *= -bob.data.bounce;
            }
            else if (bob.x < 0)
            {
                bob.x = 0;
                bob.data.vx *= -bob.data.bounce;
            }

            if (bob.y > 650)
            {
                bob.y = 650;
                bob.data.vy *= -bob.data.bounce;
            }
        }
    }

    launch ()
    {

        this.idx++;

        if (this.idx === 38)
        {
            this.idx = 1;
        }

        const bob = this.blitter.create(0, 0);

        bob.data.vx = Math.random() * 10;
        bob.data.vy = Math.random() * 10;
        bob.data.bounce = 0.8 + (Math.random() * 0.3);

        this.bobs.push(bob);

    }

    updateDigits ()
    {
        const len = Phaser.Utils.String.Pad(this.bobs.length.toString(), 7, '0', 1);

        this.numbers[0].frame = this.scene.textures.getFrame('atlas', len[0]);
        this.numbers[1].frame = this.scene.textures.getFrame('atlas', len[1]);
        this.numbers[2].frame = this.scene.textures.getFrame('atlas', len[2]);
        this.numbers[3].frame = this.scene.textures.getFrame('atlas', len[3]);
        this.numbers[4].frame = this.scene.textures.getFrame('atlas', len[4]);
        this.numbers[5].frame = this.scene.textures.getFrame('atlas', len[5]);
        this.numbers[6].frame = this.scene.textures.getFrame('atlas', len[6]);
    }

    drawStar (graphics, cx, cy, spikes, outerRadius, innerRadius, color, lineColor)
    {
        let rot = Math.PI / 2 * 3;
        let x = cx;
        let y = cy;
        const step = Math.PI / spikes;
        graphics.lineStyle(2, lineColor, 1.0);
        graphics.fillStyle(color, 1.0);
        graphics.beginPath();
        graphics.moveTo(cx, cy - outerRadius);
        for (let i = 0; i < spikes; i++)
        {
            x = cx + Math.cos(rot) * outerRadius;
            y = cy + Math.sin(rot) * outerRadius;
            graphics.lineTo(x, y);
            rot += step;

            x = cx + Math.cos(rot) * innerRadius;
            y = cy + Math.sin(rot) * innerRadius;
            graphics.lineTo(x, y);
            rot += step;
        }
        graphics.lineTo(cx, cy - outerRadius);
        graphics.closePath();
        graphics.fillPath();
        graphics.strokePath();
    }
}

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

const game = new Phaser.Game(config);

window.onmousedown = () =>
{
    this.add = true;
};

window.onmouseup = () =>
{
    this.add = false;
};

Генерация текстуры «на лету»

Вместо загрузки готового изображения звезды, пример создаёт его программно. Это полезно для прототипирования или динамического изменения внешнего вида объектов.

Ключевой объект — Graphics. С его помощью рисуется фигура, которая затем конвертируется в текстуру. Эта текстура добавляется в менеджер текстур игры и может использоваться как любой другой загруженный ассет.

const starGraphics = this.make.graphics({x: 0, y: 0, add: false});
this.drawStar(starGraphics, 25, 25, 5, 25, 12, 0xFFFF00, 0xFF0000);
starGraphics.generateTexture('starGraphics', 50, 50);

Метод generateTexture принимает ключ текстуры ('starGraphics') и её размеры. После вызова этого метода текстура становится доступна по этому ключу. Обратите внимание на параметр add: false при создании Graphics — это означает, что сам объект Graphics не будет добавлен на дисплейный список, он нужен только как холст для рисования.

Массовый рендеринг через Blitter

Для отображения тысяч копий одной текстуры Phaser предлагает объект Blitter. В отличие от группы спрайтов (Sprite), Blitter оптимизирован для отрисовки множества статичных или простых объектов (Bob). Это даёт огромный прирост производительности.

Создаём Blitter, указывая текстуру для всех его элементов:

this.blitter = this.add.blitter(0, 0, 'starGraphics');

Каждая частица (звезда) создаётся как Bob внутри Blitter. Bob — это легковесный объект, содержащий в основном координаты и данные для рендеринга. В примере каждая звезда получает дополнительные пользовательские данные (скорость, отскок), которые хранятся в свойстве bob.data.

const bob = this.blitter.create(0, 0);
bob.data.vx = Math.random() * 10;
bob.data.vy = Math.random() * 10;
bob.data.bounce = 0.8 + (Math.random() * 0.3);

Физика и управление частицами

В примере реализована упрощённая физика. В методе update() для каждого Bob вычисляется движение под действием гравитации и проверяется столкновение с границами экрана.

bob.data.vy += this.gravity;
bob.y += bob.data.vy;
bob.x += bob.data.vx;

if (bob.y > 650) {
    bob.y = 650;
    bob.data.vy *= -bob.data.bounce;
}

Коэффициент bounce (отскок) у каждой частицы свой, что добавляет разнообразия. Управление симуляцией происходит через глобальные события мыши: зажатие кнопки запускает массовое создание частиц, отпускание — останавливает.

window.onmousedown = () => { this.add = true; };
window.onmouseup = () => { this.add = false; };

Важный нюанс: один объект Blitter может содержать до 10 000 элементов Bob. Когда лимит достигается, в примере создаётся новый объект Blitter, чтобы можно было добавлять ещё частицы.

Визуализация счётчика

Интересный приём — отображение количества частиц в реальном времени с помощью цифр из атласа. В начале создаётся массив изображений this.numbers, каждое из которых изначально показывает ноль.

this.numbers.push(this.add.image(30 + 0 * 48, 720, 'atlas', '0'));

Функция updateDigits() получает текущее количество частиц, форматирует его в строку из 7 цифр с ведущими нулями и для каждого изображения-цифры меняет его кадр (frame), подставляя нужный символ из атласа.

const len = Phaser.Utils.String.Pad(this.bobs.length.toString(), 7, '0', 1);
this.numbers[0].frame = this.scene.textures.getFrame('atlas', len[0]);

Это наглядный пример работы с Texture Atlas и изменения спрайта через свойство .frame.

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

Комбинация Graphics.generateTexture() и Blitter — это мощный инструмент для создания высокопроизводительных визуальных эффектов с тысячами частиц. Вы можете модифицировать пример: изменить форму частицы в функции drawStar, использовать загруженную текстуру вместо сгенерированной, добавить исчезновение частиц со временем или при столкновении. Попробуйте применить этот подход для создания метели, следов за игроком или фоновой анимации.