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

Создание сложных сцен с динамическим освещением и тысячами объектов — классическая проблема производительности в веб-играх. Phaser 3 предлагает мощное решение: `SpriteGPULayer`. Эта статья покажет, как использовать этот специализированный объект для рендеринга тысяч спрайтов с нормальными картами и динамическим освещением без падения FPS. Вы научитесь создавать атмосферные, живые сцены, которые раньше были недостижимы для WebGL без тонкой оптимизации.

Версия 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('spider', [ 'assets/normal-maps/spider.png', 'assets/normal-maps/spider_n.png' ]);
      this.load.image('stones', [ 'assets/normal-maps/stones.png', 'assets/normal-maps/stones_n.png' ]);
    }

    create ()
    {
        this.lights.enable();
        const light1 = this.lights.addLight(-1000, -300, 3000, 0x8844bb, 1);
        const light2 = this.lights.addLight(500, -1000, 3000, 0x888888, 1);
        const light3 = this.lights.addLight(1800, -300, 3000, 0x44ff44, 1);

        const subLight1 = this.lights.addLight(400, 1600, 2000, 0xffbb88, 3);
        const subLight2 = this.lights.addLight(400, 800, 600, 0xffbb88, 3);
        subLight2.setZNormal(0.5);

        this.mouseLight = this.lights.addLight(0,0, 256, 0xbbbbff, 3).setZNormal(0.5);

        const background = this.add.tileSprite(400, 300, 1200, 1200, 'stones').setLighting(true);

        const count = 1024 * 8;

        const layer = this.add.spriteGPULayer('spider', count).setLighting(true);

        console.log(layer);

        const template = {
            x: {
                base: -100,
                ease: 'Linear',
                amplitude: 1100,
                duration: 15000,
                yoyo: false
            },
            y: {
                ease: 'Linear',
                yoyo: false
            },
            rotation: {
                ease: 'Smoothstep.easeInOut'
            }
        };

        const centerX = 400;
        const centerY = 300;
        const radius = Math.sqrt(900 * 900 + 700 * 700);

        for (let i = 0; i < count; i++)
        {
            const angle1 = Math.random() * Math.PI * 2;
            const angle2 = angle1 + (Math.random() + 0.5) * Math.PI;

            const startX = centerX + Math.cos(angle1) * radius;
            const startY = centerY + Math.sin(angle1) * radius;
            const endX = centerX + Math.cos(angle2) * radius;
            const endY = centerX + Math.sin(angle2) * radius;
            const dx = endX - startX;
            const dy = endY - startY;
            const distance = Math.sqrt(dx * dx + dy * dy);
            const velocity = 20 + Math.random() * 6;
            const duration = 1000 * distance / velocity;

            template.x.base = startX;
            template.x.amplitude = dx;
            template.x.duration = duration;
            template.x.delay = Math.random() * duration;

            template.y.base = startY;
            template.y.amplitude = dy;
            template.y.duration = duration;
            template.y.delay = template.x.delay;

            // Sprites face DOWN, which is a quarter turn above standard.
            template.rotation.base = Math.atan2(dy, dx) - 0.1 - Math.PI / 2;
            template.rotation.amplitude = 0.2;
            template.rotation.duration = 160 + Math.random() * 80;

            template.scaleX = 0.03 + Math.random() * Math.random() * 0.1;
            template.scaleY = template.scaleX;

            layer.addMember(template);
        }
    }

    update (time, delta)
    {
        const camera = this.cameras.main;

        camera.setScroll(
            100 * Math.sin(time / 1000),
            100 * Math.cos((time + 3456) / 1200)
        );
        camera.setRotation(time / 10000);
        camera.setZoom(1 + 0.2 * (Math.sin(time / 1234) + 1));

        const vec = camera.getWorldPoint(this.input.mousePointer.x, this.input.mousePointer.y);
        this.mouseLight.x = vec.x;
        this.mouseLight.y = vec.y;
    }
}

const config = {
    type: Phaser.WEBGL,
    parent: 'phaser-example',
    width: 800,
    height: 600,
    scene: Example,
    backgroundColor: '#808080',
    render:
    {
        selfShadow: true
    }
};

const game = new Phaser.Game(config);

Подготовка сцены: включаем свет и загружаем ассеты

Первым делом в методе preload загружаем изображения. Ключевой момент — для текстур, которые будут реагировать на свет, мы загружаем пары файлов: основное изображение (цвет) и нормальную карту (*_n.png). Нормальная карта кодирует информацию о поверхности (выступы и впадины), что необходимо для корректного расчета освещения.

В методе create активируем систему освещения сцены. Без этого вызова световые эффекты работать не будут.

this.lights.enable();

Затем создаем несколько статических источников света с помощью this.lights.addLight(). У каждого света задаются координаты (x, y), радиус, цвет и интенсивность. Обратите внимание на свет subLight2: для него вызывается метод .setZNormal(0.5). Этот метод задает "глубину" источника света относительно нормальной карты, создавая более сложные и объемные тени (self-shadowing).

Также создается динамический источник света this.mouseLight, который позже будет следовать за курсором.

Фон добавляется как TileSprite с включенным освещением через .setLighting(true).

Создание SpriteGPULayer: основа производительности

Вместо создания тысяч отдельных объектов Sprite, мы используем SpriteGPULayer. Это специализированный объект, который оптимизирован для рендеринга множества однотипных спрайтов за один draw call на GPU. Это кардинально снижает нагрузку на CPU и увеличивает производительность.

const count = 1024 * 8;
const layer = this.add.spriteGPULayer('spider', count).setLighting(true);

В конструктор передается ключ текстуры ('spider') и максимальное количество спрайтов в слое. Для корректной работы с освещением также вызывается .setLighting(true). Все спрайты, добавленные в этот слой, будут использовать одну текстуру и нормальную карту, а их трансформации (позиция, поворот, масштаб) будут обрабатываться на стороне GPU.

Настройка анимации: шаблоны и параметры движения

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

const template = {
    x: { base: -100, ease: 'Linear', amplitude: 1100, duration: 15000, yoyo: false },
    y: { ease: 'Linear', yoyo: false },
    rotation: { ease: 'Smoothstep.easeInOut' }
};

Каждое свойство (например, `x) — это объект конфигурации, который поддерживает базовое значение (base), амплитуду изменения (amplitude), длительность анимации (duration), функцию плавности (ease) и флаг возврата (yoyo`).

В цикле для каждого из 8192 пауков вычисляется индивидуальная траектория. Пауки будут двигаться по хордам большой окружности от случайной начальной точки к случайной конечной. Для этого генерируются два случайных угла, вычисляются координаты начала и конца пути, а затем расстояние и длительность анимации, основанная на случайной скорости.

template.x.base = startX;
template.x.amplitude = dx; // Смещение по X за время анимации
template.x.duration = duration;
template.x.delay = Math.random() * duration; // Случайная задержка старта

Аналогично настраивается ось Y. Поворот (rotation) вычисляется так, чтобы паук был направлен вдоль вектора движения, с добавлением небольшого случайного "дрожания" за счет амплитуды. Масштаб (scaleX, scaleY) задается случайным, но очень маленьким значением, чтобы создать ощущение огромной массы далеких объектов.

После настройки шаблона для конкретного паука, он добавляется в слой:

layer.addMember(template);

Динамика камеры и интерактивный свет

В методе update реализована плавная анимация камеры: она медленно вращается, меняет зум и слегка смещается по синусоидальной траектории. Это создает эффект "полета" через сцену.

camera.setScroll(100 * Math.sin(time / 1000), 100 * Math.cos((time + 3456) / 1200));
camera.setRotation(time / 10000);
camera.setZoom(1 + 0.2 * (Math.sin(time / 1234) + 1));

Также здесь обрабатывается интерактивный источник света, следующий за курсором. Поскольку координаты мыши даны относительно экрана, их нужно преобразовать в мировые координаты сцены с учетом текущих трансформаций камеры.

const vec = camera.getWorldPoint(this.input.mousePointer.x, this.input.mousePointer.y);
this.mouseLight.x = vec.x;
this.mouseLight.y = vec.y;

Метод camera.getWorldPoint() выполняет это преобразование, обеспечивая корректное позиционирование света в мире игры, даже когда камера смещена, повернута или увеличена.

Конфигурация рендерера: включаем self-shadowing

Важный аспект настройки находится в объекте конфигурации игры, в свойстве render. Для активации сложных теней, отбрасываемых объектами на самих себя (например, паук отбрасывает тень на свое же тело), необходимо явно включить опцию selfShadow.

const config = {
    type: Phaser.WEBGL,
    // ... другие настройки ...
    render: {
        selfShadow: true
    }
};

Без этой настройки эффект от метода .setZNormal(), примененного к источнику света subLight2, был бы неполным или отсутствовал. Эта опция увеличивает качество освещения за счет дополнительных вычислений на GPU.

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

Использование SpriteGPULayer в сочетании с системой динамического освещения Phaser открывает двери к созданию невероятно насыщенных и сложных сцен без ущерба для производительности. Этот подход идеален для визуализации частиц, фоновых элементов, толп или любых других массовых объектов. Для экспериментов попробуйте: изменить количество спрайтов в слое, добавить больше динамических источников света с разными параметрами setZNormal, анимировать не только позицию, но и другие свойства в шаблоне (например, alpha), или заменить текстуру паука на свою, создав собственную нормальную карту в графическом редакторе.