О чем этот пример
При использовании 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. Реализуйте зуммирование камеры — при сильном приближении артефакт от неповёрнутых нормалей может стать заметнее, и вы сможете наглядно сравнить оба подхода.
