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

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

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

Живой запуск

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

Исходный код


class Example extends Phaser.Scene
{
    preload ()
    {
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
        for (let i = 0; i < 8; i++)
        {
            this.load.image('skull' + i, [ 'assets/tests/lights/skull.png', 'assets/tests/lights/skull-n.png' ]);
        }
    }

    create ()
    {
        this.centreX = 400;
        this.centreY = 300;
        this.centreRotation = 0;

        this.skulls = [];
        this.turretMountPoints = {
            1: { x: -100, y: -200 },
            2: { x: 100, y: -200 },
            3: { x: -100, y: -100 },
            4: { x: 100, y: -100 },
            5: { x: -100, y: 50 },
            6: { x: 100, y: 50 },
            7: { x: -100, y: 0 },
            8: { x: 100, y: 0 },
        };

        for (let i = 0; i < 8; i++)
        {
            // const randomX = Phaser.Math.Between(1, 9) * 0.1;
            // const randomY = Phaser.Math.Between(2, 8) * 0.1;
            // const randomAngle = Phaser.Math.Between(0, 180);
            const texture = 'skull' + i;
            const skull = this.add.sprite(this.centreX, this.centreY, 'skull0')
                .setPipeline('Light2D');

            // console.log(skull.getPipelineName());
            this.skulls.push(skull);
        }

        // var lightManager = this.sys.lights;

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

        // const spotlight = this.lights.addLight(400, 300, 280).setIntensity(3);
        const spotlight = this.lights.addLight(500, -500, 10000, "0xffffff", 3);

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

        //     spotlight.x = pointer.x;
        //     spotlight.y = pointer.y;

        // });
    }

    update ()
    {
        if (this.skulls.length === 0) return;

        for (let index = 0; index < this.skulls.length; index++)
        {
            const offset = this.turretMountPoints[ index + 1 ];
            const skull = this.skulls[ index ];

            const rotatedX =
                this.centreX +
                offset.x * Math.cos(this.centreRotation) -
                offset.y * Math.sin(this.centreRotation);
            const rotatedY =
                this.centreY +
                offset.x * Math.sin(this.centreRotation) +
                offset.y * Math.cos(this.centreRotation);


            const pointer = this.input.mousePointer;
            const dx = pointer.worldX - skull.x;
            const dy = pointer.worldY - skull.y;

            const angle = Math.atan2(dy, dx); // ANGLE IN RADS

            // Finally we set the rotation of the turret to point at mouse
            skull.setPosition(rotatedX, rotatedY);
            skull.setRotation(angle + Math.PI / 2);
        }
    }
}

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

const game = new Phaser.Game(config);

Суть проблемы: нормали vs. вращение

Система Light2D в Phaser использует два изображения для каждого спрайта: основную диффузную текстуру и карту нормалей (normal map). Карта нормалей, закодированная в цветах RGB, описывает «уклон» поверхности каждого пикселя, что позволяет рассчитать, как свет от источников должен на него падать, создавая эффект объема.

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

В нашем примере это видно на черепах (skull): они вращаются, следуя за курсором мыши, но их «объём» от освещения ведёт себя несоответственно.

Структура примера: загрузка и настройка сцены

Код начинается с загрузки ассетов и настройки базовых элементов сцены.

В методе preload() загружаются 8 пар текстур. Важный момент: метод this.load.image с массивом путей загружает сразу и основную текстуру, и карту нормалей. Второй путь в массиве (с суффиксом -n) автоматически распознаётся как normal map.

this.load.image('skull' + i, [ 'assets/tests/lights/skull.png', 'assets/tests/lights/skull-n.png' ]);

В create() инициализируется массив спрайтов this.skulls и объект this.turretMountPoints, который хранит локальные координаты (относительно центра) для размещения каждого черепа. Каждый спрайт создаётся в центре экрана, но ключевое действие — ему назначается конвейер (pipeline) Light2D, без которого освещение на него не повлияет.

const skull = this.add.sprite(this.centreX, this.centreY, 'skull0').setPipeline('Light2D');

Затем активируется система освещения, задаётся ambient свет и создаётся один мощный источник (spotlight).

Обходной манёвр: независимый расчёт позиции и поворота

Поскольку напрямую заставить карту нормалей вращаться вместе со спрайтом в текущем API Phaser сложно, в примере применяется обходной путь. Вместо того чтобы вращать спрайт вокруг своей собственной оси, мы вычисляем его конечную позицию на окружности и помещаем его туда уже с нужным углом поворота. Так спрайт всегда добавляется в сцену в своей «канонической», неротированной ориентации (с точки зрения его текстуры и нормалей), но визуальный поворот достигается за счёт его позиционирования по кругу.

Основная логика находится в update(). Для каждого черепа: 1. Берётся его смещение (offset) от центра композиции. 2. С помощью тригонометрических функций рассчитывается новая позиция (rotatedX, rotatedY) на окружности с учётом общего угла вращения всей группы (this.centreRotation).

const rotatedX = this.centreX + offset.x * Math.cos(this.centreRotation) - offset.y * Math.sin(this.centreRotation);
const rotatedY = this.centreY + offset.x * Math.sin(this.centreRotation) + offset.y * Math.cos(this.centreRotation);

3. Отдельно вычисляется угол (angle), на который должен быть повёрнут череп, чтобы "смотреть" на курсор мыши. 4. Спрайту устанавливается рассчитанная позиция и угол поворота.

skull.setPosition(rotatedX, rotatedY);
skull.setRotation(angle + Math.PI / 2);

Таким образом, система освещения Light2D всегда работает с исходной ориентацией нормалей черепа, но для игрока он выглядит повёрнутым и правильно освещённым.

Практические выводы и ограничения подхода

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

* **Производительность:** Расчёты позиций для множества объектов каждый кадр могут быть накладными. * **Сложность логики:** Управление состоянием объектов становится более запутанным по сравнению с использованием встроенных методов трансформации Phaser. * **Не универсально:** Для объектов со сложной иерархией или анимацией этот метод может не подойти.

Важно помнить, что это обходное решение, а не фикс самой системы. В будущих версиях Phaser API для работы с нормалями может быть расширен.

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

Работа с 2D-освещением и нормалями в Phaser требует внимания к деталям вращения объектов. Показанный пример демонстрирует креативный подход к сохранению визуальной целостности, когда прямое использование setRotation() ломает освещение. **Идеи для экспериментов:** 1. Попробуйте анимировать свойство this.centreRotation, чтобы вся группа черепов плавно вращалась по орбите. 2. Добавьте несколько источников света (this.lights.addLight) разных цветов и понаблюдайте, как они взаимодействуют с «правильно» повёрнутыми нормалями. 3. Реализуйте зуммирование камеры — при сильном приближении артефакт от неповёрнутых нормалей может стать заметнее, и вы сможете наглядно сравнить оба подхода.