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

Встроенные фильтры Phaser — это мощный инструмент для постобработки, но настоящая гибкость открывается при создании собственных шейдерных эффектов. В этой статье мы разберем пример кастомного фильтра, который преобразует черно-белую карту высот (bump map) в карту нормалей (normal map) в реальном времени. Этот навык позволит вам создавать уникальные визуальные стили, динамические искажения и сложные эффекты освещения, напрямую работая с графическим конвейером WebGL.

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

Живой запуск

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

Исходный код


// Define custom filter.

// This custom filter is a simple example of a filter that converts a bump map to a normal map.
// The filter is defined in two parts: a Controller and a RenderNode.

// The Controller is the main interface for the filter.
class BumpToNormal extends Phaser.Filters.Controller
{
    constructor (camera)
    {
        // The name 'FilterBumpToNormal' is defined in FilterBumpToNormal.
        super(camera, 'FilterBumpToNormal');

        // Properties on the controller are used to pass data to the shader.
        this.radius = 1;
    }
}

// The fragment shader for the filter.
const fragmentShaderBumpToNormal =
`
// BUMP_TO_NORMAL_FS
#pragma phaserTemplate(shaderName)

precision mediump float;

uniform sampler2D uMainSampler;

uniform vec2 resolution;
uniform float radius;

varying vec2 outTexCoord;

void main ()
{
    // Sample neighboring texels.
    vec2 texelSize = radius / resolution;
    vec4 center = texture2D(uMainSampler, outTexCoord);
    vec4 right = texture2D(uMainSampler, outTexCoord + vec2(texelSize.x, 0.0));
    vec4 left = texture2D(uMainSampler, outTexCoord - vec2(texelSize.x, 0.0));
    vec4 top = texture2D(uMainSampler, outTexCoord + vec2(0.0, texelSize.y));
    vec4 bottom = texture2D(uMainSampler, outTexCoord - vec2(0.0, texelSize.y));

    // Calculate the normal.
    float dx = (left.r + left.g + left.b) - (right.r + right.g + right.b);
    float dy = (top.r + top.g + top.b) - (bottom.r + bottom.g + bottom.b);
    vec3 normal = normalize(vec3(dx, dy, 1.0));

    // Convert the normal to color.
    vec4 color = vec4(normal * 0.5 + 0.5, center.a);

    gl_FragColor = color;
}
`;

// This RenderNode is required for rendering the custom filter.
class FilterBumpToNormal extends Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader
{
    constructor (manager)
    {
        // The name 'FilterBumpToNormal' is used by the Controller.
        super('FilterBumpToNormal', manager, null, fragmentShaderBumpToNormal);
    }

    // This method sets up the uniforms for the shader.
    setupUniforms (controller, drawingContext)
    {
        const programManager = this.programManager;

        programManager.setUniform('resolution', [ drawingContext.width, drawingContext.height ]);
        programManager.setUniform('radius', controller.radius);
    }
}

// End custom filter definition.


class Example extends Phaser.Scene
{
    preload ()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('distortion8', 'assets/textures/distortion8.png');
    }

    create ()
    {
        if (!this.renderer.renderNodes.hasNode('FilterBumpToNormal'))
        {
            // Load the custom filter.
            // We load during scene creation because the renderer has booted by this point.
            this.renderer.renderNodes.addNodeConstructor('FilterBumpToNormal', FilterBumpToNormal);
        }

        // Add an unfiltered image.
        this.add.image(0, 360, 'distortion8')
        .setOrigin(0, 0.5);

        // Add an image for filtering.
        const image = this.add.image(1280, 360, 'distortion8')
        .setOrigin(1, 0.5)
        .enableFilters();
        const blur = image.filters.internal.addBlur();
        blur.strength = 2;

        // Add custom filter to the image.
        // Note that the filter requires a camera to be passed to it.
        const bumpToNormal = image.filters.internal.add(
            new BumpToNormal(image.filterCamera)
        );
        bumpToNormal.radius = 8;

        // Tween the blur strength.
        this.tweens.add({
            targets: blur,
            strength: 0,
            duration: 2000,
            yoyo: true,
            repeat: -1
        });
    }
}

const config = {
    type: Phaser.AUTO,
    width: 1280,
    height: 720,
    backgroundColor: '#000000',
    parent: 'phaser-example',
    scene: Example
};

let game = new Phaser.Game(config);

Архитектура кастомного фильтра в Phaser

Пользовательский фильтр в Phaser состоит из двух обязательных частей: Контроллера (Controller) и Узла рендеринга (RenderNode). Эта архитектура разделяет логику управления (настройка параметров из кода игры) и логику отрисовки (шейдер и его униформы).

Контроллер наследуется от Phaser.Filters.Controller и служит интерфейсом для вашей сцены. Через его свойства (например, radius) вы передаете данные в шейдер.

Узел рендеринга наследуется от Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader. Его задача — скомпилировать шейдер и связать униформы шейдера со значениями из контроллера. Именно здесь определяется GLSL-код вашего фильтра.

Создаем Контроллер фильтра

Контроллер — это класс, который вы будете инстанцировать и добавлять к игровым объектам. В его конструктор передается камера объекта, к которому применяется фильтр. Имя, переданное в super, должно точно совпадать с именем класса вашего RenderNode.

class BumpToNormal extends Phaser.Filters.Controller
{
    constructor (camera)
    {
        super(camera, 'FilterBumpToNormal');
        this.radius = 1;
    }
}

В этом примере мы создаем свойство radius, которое позже будет передано в шейдер для управления "радиусом" выборки соседних текселей. Чем больше радиус, тем более размытой и плавной получится карта нормалей.

Пишем шейдер и RenderNode

Сердце фильтра — фрагментный шейдер, написанный на GLSL. Он выполняется для каждого пикселя (текселя) обрабатываемого изображения. Наш шейдер считывает яркость соседних пикселей, чтобы рассчитать вектор нормали.

Ключевой момент: шейдерная строка должна содержать макрос #pragma phaserTemplate(shaderName) и объявлять униформы, которые вы планируете использовать.

const fragmentShaderBumpToNormal =
`
#pragma phaserTemplate(shaderName)

precision mediump float;

uniform sampler2D uMainSampler;
uniform vec2 resolution;
uniform float radius;

varying vec2 outTexCoord;

void main ()
{
    vec2 texelSize = radius / resolution;
    vec4 center = texture2D(uMainSampler, outTexCoord);
    vec4 right = texture2D(uMainSampler, outTexCoord + vec2(texelSize.x, 0.0));
    // ... код для left, top, bottom

    float dx = (left.r + left.g + left.b) - (right.r + right.g + right.b);
    float dy = (top.r + top.g + top.b) - (bottom.r + bottom.g + bottom.b);
    vec3 normal = normalize(vec3(dx, dy, 1.0));

    gl_FragColor = vec4(normal * 0.5 + 0.5, center.a);
}
`;

Затем мы создаем класс RenderNode. В его методе setupUniforms происходит привязка значений из контроллера к переменным шейдера.

class FilterBumpToNormal extends Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader
{
    constructor (manager)
    {
        super('FilterBumpToNormal', manager, null, fragmentShaderBumpToNormal);
    }

    setupUniforms (controller, drawingContext)
    {
        const programManager = this.programManager;
        programManager.setUniform('resolution', [ drawingContext.width, drawingContext.height ]);
        programManager.setUniform('radius', controller.radius);
    }
}

Регистрация и применение фильтра в сцене

Перед использованием кастомный фильтр необходимо зарегистрировать в системе рендеринга. Это делается один раз, обычно в методе create сцены.

if (!this.renderer.renderNodes.hasNode('FilterBumpToNormal'))
{
    this.renderer.renderNodes.addNodeConstructor('FilterBumpToNormal', FilterBumpToNormal);
}

После регистрации вы можете создать контроллер и добавить его к фильтрам любого объекта с помощью метода add. Обратите внимание, что контроллеру требуется камера объекта (image.filterCamera).

const image = this.add.image(1280, 360, 'distortion8').enableFilters();
const bumpToNormal = image.filters.internal.add(new BumpToNormal(image.filterCamera));
bumpToNormal.radius = 8;

Фильтры можно комбинировать. В примере к кастомному фильтру BumpToNormal также добавлен встроенный фильтр размытия (addBlur()), параметры которого анимируются твином.

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

Создание кастомных фильтров открывает безграничные возможности для визуального оформления игры. Вы научились создавать архитектуру из Controller и RenderNode, писать простой GLSL-шейдер и применять его к игровым объектам. Для экспериментов попробуйте изменить алгоритм в шейдере: например, создать эффект рельефа, цветовой инверсии или волновых искажений на основе текстуры. Также поэкспериментируйте с динамическим изменением униформ (как radius) в реальном времени в ответ на действия игрока.