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

Визуальные эффекты освещения создают атмосферу и глубину в 2D-играх, но их рендеринг может быть ресурсоемким. В этом примере демонстрируется, как Phaser автоматически оптимизирует отрисовку источников света с помощью техники 'light culling' — отсечения невидимых источников. Вы узнаете, как настроить систему освещения, добавить множество динамических огней и отслеживать производительность в реальном времени.

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    cursors;
    text;
    land;

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

        this.load.image('gem1');
        this.load.image('gem2');
        this.load.image('gem3');
        this.load.image('gem4');
        this.load.image('gem5');
        this.load.image('gem6');
        this.load.image('gem7');
        this.load.image('gem8');
        this.load.image('gem9');

        this.load.image('stones', [ 'stones.png', 'stones_n.png' ]);
    }

    create ()
    {
        this.cameras.main.removeBounds();

        this.land = this.add.tileSprite(400, 300, 800, 600, 'stones');

        this.land.setLighting(true);
        this.land.setScrollFactor(0, 0);
        this.land.tileScaleX = 0.5;
        this.land.tileScaleY = 0.5;

        this.lights.enable();
        this.lights.setAmbientColor(0x555555);

        const spotlight = this.lights.addLight(400, 300, 128).setIntensity(3);

        this.input.on('pointermove', pointer =>
        {

            spotlight.x = pointer.worldX;
            spotlight.y = pointer.worldY;

        });

        this.text = this.add.text(10, 10, '').setDepth(1).setScrollFactor(0, 0);

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

        const circ = new Phaser.Geom.Circle(400, 300, 400);
        let points = Phaser.Geom.Circle.GetPoints(circ, 12);

        for (let i = 0; i < points.length; i++)
        {
            const x = points[i].x;
            const y = points[i].y;

            this.add.image(x, y, 'gem2');

            this.lights.addLight(x, y, 128, 0xff22ff, 6);
        }

        circ.setTo(400, 300, 700);
        points = Phaser.Geom.Circle.GetPoints(circ, 20);

        for (let i = 0; i < points.length; i++)
        {
            const x = points[i].x;
            const y = points[i].y;

            this.add.image(x, y, 'gem3');

            this.lights.addLight(x, y, 128, 0x22ffff, 6);
        }

        circ.setTo(400, 300, 1000);
        points = Phaser.Geom.Circle.GetPoints(circ, 26);

        for (let i = 0; i < points.length; i++)
        {
            const x = points[i].x;
            const y = points[i].y;

            this.add.image(x, y, 'gem4');

            this.lights.addLight(x, y, 128, 0xffff22, 6);
        }
    }

    update ()
    {
        this.text.setText([
            'Cursors to move',
            `Visible Lights: ${this.lights.visibleLights}`
        ]);

        const speed = 6;

        if (this.cursors.left.isDown)
        {
            this.cameras.main.scrollX -= speed;
            this.land.tilePositionX -= speed * 2;
        }
        else if (this.cursors.right.isDown)
        {
            this.cameras.main.scrollX += speed;
            this.land.tilePositionX += speed * 2;
        }

        if (this.cursors.up.isDown)
        {
            this.cameras.main.scrollY -= speed;
            this.land.tilePositionY -= speed * 2;
        }
        else if (this.cursors.down.isDown)
        {
            this.cameras.main.scrollY += speed;
            this.land.tilePositionY += speed * 2;
        }
    }
}

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

const game = new Phaser.Game(config);

Настройка сцены и загрузка ассетов

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

this.load.image('stones', [ 'stones.png', 'stones_n.png' ]);

В методе create() первым делом снимаются границы камеры, чтобы можно было свободно скроллить мир. Затем создается TileSprite, который будет служить фоном с текстурой камней.

this.cameras.main.removeBounds();
this.land = this.add.tileSprite(400, 300, 800, 600, 'stones');

Включение и настройка системы освещения

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

this.lights.enable();
this.lights.setAmbientColor(0x555555);

Затем создается главный источник света — прожектор (spotlight), который будет следовать за курсором мыши. Метод setIntensity(3) делает его ярче.

const spotlight = this.lights.addLight(400, 300, 128).setIntensity(3);
this.input.on('pointermove', pointer => {
    spotlight.x = pointer.worldX;
    spotlight.y = pointer.worldY;
});

Важно отметить, что фон (land) должен быть подготовлен к освещению. Для этого у него вызывается метод setLighting(true). Также для него отключается эффект параллакса (setScrollFactor(0, 0)), чтобы он оставался на месте при скролле камеры.

this.land.setLighting(true);
this.land.setScrollFactor(0, 0);

Создание массива статических источников света

В примере создается три кольца из драгоценных камней, каждый со своим цветным источником света. Для равномерного размещения объектов по кругу используется геометрический объект Phaser.Geom.Circle и метод GetPoints, который возвращает массив точек на его окружности.

const circ = new Phaser.Geom.Circle(400, 300, 400);
let points = Phaser.Geom.Circle.GetPoints(circ, 12);

В цикле для каждой точки добавляется изображение драгоценного камня и источник света с определенным цветом и радиусом.

for (let i = 0; i < points.length; i++) {
    const x = points[i].x;
    const y = points[i].y;
    this.add.image(x, y, 'gem2');
    this.lights.addLight(x, y, 128, 0xff22ff, 6);
}

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

Отслеживание производительности (Light Culling)

Сердце оптимизации — это автоматическое отсечение источников света, которые находятся за пределами видимой области камеры. Phaser делает это сам, не требуя от разработчика ручного управления. Количество видимых в данный момент источников света хранится в свойстве this.lights.visibleLights.

this.text.setText([
    'Cursors to move',
    `Visible Lights: ${this.lights.visibleLights}`
]);

В методе update() это значение выводится на экран. При скролле камеры вы увидите, как число visibleLights меняется — уменьшается, когда камера уезжает от колец с огнями, и увеличивается при приближении. Это наглядная демонстрация работы culling.

Управление камерой и параллакс-эффект

Скроллинг камеры активируется стрелками на клавиатуре. При движении камеры фон (land) должен оставаться статичным, но в примере его текстура также сдвигается, создавая простой параллакс-эффект. Обратите внимание, что скорость смещения текстуры фона (tilePosition) в два раза выше скорости скролла камеры.

if (this.cursors.left.isDown) {
    this.cameras.main.scrollX -= speed;
    this.land.tilePositionX -= speed * 2; // Параллакс-эффект
}

Этот прием добавляет ощущение глубины сцене, хотя сам фон и не имеет смещения в пространстве (scrollFactor равен 0).

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

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