О чем этот пример
Карты нормалей — это мощный инструмент для создания иллюзии объемного освещения на плоских поверхностях. В этом примере мы не будем использовать готовые текстуры, а сгенерируем карту нормалей для сферы программно, используя встроенные в Phaser градиенты. Это полезный прием для динамического создания материалов, имитации металлических или стеклянных поверхностей, а также для глубокого понимания того, как устроены карты нормалей изнутри. Мы разберем, как декомпозировать сферическую карту нормалей на цветовые каналы (RGB), создать каждый канал отдельным градиентом, объединить их и применить к игровому объекту для создания эффекта вращающегося светящегося шара.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
image;
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('grass', 'assets/skies/grass.jpg');
}
create ()
{
this.image = this.add.image(400, 300, 'grass');
// Create a normal map for a sphere.
// A spherical normal map decomposes quite simply:
// - Blue channel: Radial gradient, flat blue into curving black at the edge.
// - Green channel: Linear gradient, green at top, black at bottom.
// - Red channel: Linear gradient, red at right, black at left.
const normalWidth = 600;
const normalHeight = 600;
const halfNormalWidth = normalWidth / 2;
const halfNormalHeight = normalHeight / 2;
this.normGradB = this.add.gradient({
bands: [
{
colorStart: 0x0000ff,
colorEnd: 0x00007f,
interpolation: 4
}
],
start: { x: 0.5, y: 0.5 },
shape: { x: 0.5, y: 0 },
shapeMode: 2,
repeatMode: 1
}, halfNormalWidth, halfNormalHeight, normalWidth, normalHeight);
this.normGradR = this.add.gradient({
bands: [
{
colorStart: 0xFF0000,
colorEnd: 0x000000
}
],
start: { x: 1, y: 0.5 },
shape: { x: -1, y: 0 },
shapeMode: 0,
repeatMode: 1
}, halfNormalWidth, halfNormalHeight, normalWidth, normalHeight);
this.normGradR.setBlendMode(Phaser.BlendModes.ADD);
this.normGradG = this.add.gradient({
bands: [
{
colorStart: 0x00ff00,
colorEnd: 0x000000
}
],
start: { x: 0.5, y: 0 },
shape: { x: 0, y: 1 },
shapeMode: 0,
repeatMode: 1
}, halfNormalWidth, halfNormalHeight, normalWidth, normalHeight);
this.normGradG.setBlendMode(Phaser.BlendModes.ADD);
// Place the gradients in a Container to be masked.
this.normContainer = this.add.container(0, 0, [this.normGradB, this.normGradG, this.normGradR]);
Phaser.Actions.AddMaskShape(this.normContainer, {
region: { x: 0, y: 0, width: 600, height: 600}
});
// Render the gradient container to a normal map.
this.normDT = this.textures.addDynamicTexture("normDT", 600, 600);
this.normDT.draw(this.normContainer).render();
// Now that we have a normal map, clean up.
this.normContainer.destroy();
// Create a flat white texture at a large size.
this.textures.addFlatColor("flatWhite", 600, 600, 0xffffff, 1);
// Use the normal map as a reflective orb.
this.orb = this.add.gradient({
bands: [
{
colorStart: 0xeeeeee,
colorEnd: 0xddddff,
interpolation: 4
}
],
start: { x: 0.5, y: 0.5 },
shape: { x: 0.5, y: 0 },
shapeMode: 2,
repeatMode: 1
}, 200, 300, 300, 300);
this.orb.enableFilters();
this.imgLight = this.orb.filters.internal.addImageLight({
normalMap: "normDT",
environmentMap: "grass"
});
// Display the rendered normal map.
this.normMapImg = this.add.image(600, 300, "normDT")
.setScale(0.5);
}
update (time, delta)
{
this.imgLight.viewMatrix.identity().rotateX(time / 1000);
}
}
const config = {
type: Phaser.WEBGL,
width: 800,
height: 600,
backgroundColor: '#2d2d2d',
parent: 'phaser-example',
scene: Example
};
const game = new Phaser.Game(config);
Декомпозиция карты нормалей для сферы
Карта нормалей хранит информацию о направлении поверхности (нормали) в цветовых каналах изображения: красный (R), зеленый (G) и синий (B). Каждый канал соответствует оси в пространстве: X (красный), Y (зеленый), Z (синий). Для сферической поверхности эти каналы можно представить в виде простых градиентов.
- **Синий канал (B, ось Z)**: Радиальный градиент. В центре сферы (где нормаль направлена прямо на зрителя) значение максимально (синий), а к краям (где нормаль направлена вбок) стремится к черному. - **Зеленый канал (G, ось Y)**: Линейный градиент сверху вниз. Верхняя часть сферы (нормаль направлена вверх) — зеленый, нижняя (нормаль направлена вниз) — черный. - **Красный канал (R, ось X)**: Линейный градиент слева направо. Правая часть сферы (нормаль направлена вправо) — красный, левая (нормаль направлена влево) — черный.
Объединив эти три градиента с помощью аддитивного смешивания (ADD), мы получим полноценную карту нормалей.
Создание градиентов для каждого канала
В Phaser для создания градиентов используется метод this.add.gradient(). Он принимает конфигурацию и геометрические параметры. Давайте создадим три градиента, каждый в своем контейнере размером 600x600 пикселей.
**Синий канал (радиальный градиент)**:
this.normGradB = this.add.gradient({
bands: [
{
colorStart: 0x0000ff,
colorEnd: 0x00007f,
interpolation: 4
}
],
start: { x: 0.5, y: 0.5 },
shape: { x: 0.5, y: 0 },
shapeMode: 2,
repeatMode: 1
}, halfNormalWidth, halfNormalHeight, normalWidth, normalHeight);
Здесь shapeMode: 2 задает радиальный тип градиента. start указывает центр градиента, а shape определяет его форму и направление.
**Красный и зеленый каналы (линейные градиенты)**:
this.normGradR = this.add.gradient({
bands: [
{
colorStart: 0xFF0000,
colorEnd: 0x000000
}
],
start: { x: 1, y: 0.5 },
shape: { x: -1, y: 0 },
shapeMode: 0,
repeatMode: 1
}, halfNormalWidth, halfNormalHeight, normalWidth, normalHeight);
this.normGradR.setBlendMode(Phaser.BlendModes.ADD);
Для линейных градиентов используется shapeMode: 0. Параметры start и shape задают направление градиента. Важный шаг — установка режима смешивания ADD, чтобы цвета каналов складывались, а не перекрывали друг друга.
Объединение градиентов и рендеринг в текстуру
После создания три градиента помещаются в контейнер this.normContainer. Чтобы ограничить область их видимости квадратом 600x600, применяется маска с помощью Phaser.Actions.AddMaskShape(). Затем содержимое контейнера отрисовывается в динамическую текстуру, которая и станет нашей картой нормалей.
this.normContainer = this.add.container(0, 0, [this.normGradB, this.normGradG, this.normGradR]);
Phaser.Actions.AddMaskShape(this.normContainer, {
region: { x: 0, y: 0, width: 600, height: 600}
});
this.normDT = this.textures.addDynamicTexture("normDT", 600, 600);
this.normDT.draw(this.normContainer).render();
Метод draw() отрисовывает контейнер в текстуру, а render() фиксирует результат. После этого временные объекты (normContainer) можно удалить методом destroy().
Создание и применение материала к объекту
Теперь у нас есть карта нормалей. Создадим объект, на который будем ее накладывать. В примере это еще один градиент (this.orb), имитирующий форму шара. Ключевой шаг — добавление к нему фильтра addImageLight, который использует нашу карту нормалей (normDT) и текстуру окружения (grass), создавая эффект отражения.
this.orb.enableFilters();
this.imgLight = this.orb.filters.internal.addImageLight({
normalMap: "normDT",
environmentMap: "grass"
});
Фильтр addImageLight рассчитывает освещение на основе карты нормалей и текстуры окружения, создавая реалистичные блики и отражения на поверхности шара.
Анимация освещения
Чтобы продемонстрировать объем, освещение можно анимировать, вращая матрицу вида источника света. В методе update мы вращаем эту матрицу вокруг оси X в зависимости от прошедшего времени.
update (time, delta)
{
this.imgLight.viewMatrix.identity().rotateX(time / 1000);
}
Метод identity() сбрасывает матрицу, а rotateX() применяет вращение. Параметр time / 1000 обеспечивает плавное вращение. В результате создается иллюзия, что источник света движется вокруг шара, динамически меняя блики.
Что попробовать дальше
Мы рассмотрели, как создать сферическую карту нормалей с нуля, используя только градиенты Phaser, и применить ее для реалистичного освещения объекта. Этот подход открывает возможности для процедурной генерации материалов прямо во время выполнения игры. Для экспериментов попробуйте:
1. Изменить форму градиентов (shapeMode, start, shape), чтобы создать карты нормалей для цилиндра или куба.
2. Использовать другую текстуру окружения в environmentMap для отражения звездного неба, воды или городского пейзажа.
3. Анимировать не только вращение света, но и его позицию или интенсивность, реагируя на действия игрока.
