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

Карты нормалей — это мощный инструмент для создания иллюзии объемного освещения на плоских поверхностях. В этом примере мы не будем использовать готовые текстуры, а сгенерируем карту нормалей для сферы программно, используя встроенные в 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. Анимировать не только вращение света, но и его позицию или интенсивность, реагируя на действия игрока.