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

Готовые фильтры размытия в Phaser применяются ко всему объекту равномерно. Но что, если нужно размыть только определённые области, например, по маске текстуры? В этом примере мы разберём, как создать сложный кастомный фильтр, который использует несколько проходов рендера и внешнюю текстуру для контроля силы эффекта. Вы научитесь работать с низкоуровневой системой рендер-нод WebGL в Phaser, создавая гибкие визуальные эффекты, недоступные "из коробки".

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

Живой запуск

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

Исходный код


// Define custom filter.

// This is a complex example of a custom filter which uses multiple passes
// and samples an image texture to control blur levels.
// This is a simplified version of the Blur filter, with extra texture input.

// The Controller is the main interface for the filter.
class BlurByTexture extends Phaser.Filters.Controller
{
    constructor (camera, x, y, steps, strengthMap)
    {
        // The name 'FilterBlurByTexture' is defined in FilterBlurByTexture.
        super(camera, 'FilterBlurByTexture');

        if (x === undefined) { x = 1; }
        if (y === undefined) { y = 1; }
        if (steps === undefined) { steps = 4; }
        if (strengthMap === undefined) { strengthMap = '__WHITE'; }

        // Properties on the controller are used to pass data to the shader.
        this.x = x;
        this.y = y;
        this.steps = steps;
        this.strengthMap = strengthMap;
    }
}

// This RenderNode is required for rendering the custom filter.
// Note that this doesn't use BaseFilterShader.
// It calls additional RenderNodes instead.
class FilterBlurByTexture extends Phaser.Renderer.WebGL.RenderNodes.BaseFilter
{
    constructor (manager)
    {
        // The name 'FilterBlurByTexture' is used by the Controller.
        // Because this is a BaseFilter, we don't need to pass the fragment shader.
        super('FilterBlurByTexture', manager);
    }
    
    // This method is called when the filter is run.
    // When extending BaseFilter, this method must be implemented.
    run (controller, inputDrawingContext, outputDrawingContext, padding)
    {
        // This method must be called when the filter is run.
        this.onRunBegin(outputDrawingContext);

        // Padding must be defined for the filter to work.
        // The base Controller returns a default padding of 0 on all sides.
        if (!padding)
        {
            padding = controller.getPadding();
        }


        // We define a proxy controller so we can modify it without affecting the original.
        var proxyController = {
            camera: controller.camera,
            x: controller.x,
            y: controller.y,
            steps: controller.steps,
            strengthMap: controller.strengthMap
        };

        var currentContext = inputDrawingContext;
        var filter = this.manager.getNode('FilterBlurByTexturePass');
        var steps = controller.steps;

        for (var i = 0; i < steps; i++)
        {
            /*
            Render alternating horizontal and vertical passes.
            Gaussian blurs are axis-separable,
            so this creates the same effect as a single pass with more samples,
            but is faster.
            We have to break this down into steps at this level
            because GLSL doesn't support a variable number of loop iterations,
            so we can't pass the number of steps as a uniform.
            */

            // Horizontal pass
            proxyController.x = controller.x;
            proxyController.y = 0;
            currentContext = filter.run(proxyController, currentContext, null, padding);

            if (i === 0)
            {
                // Stop adding padding after the first pass.
                padding = new Phaser.Geom.Rectangle();
            }

            // Vertical pass
            var output = (i === steps - 1) ? outputDrawingContext : null;
            proxyController.x = 0;
            proxyController.y = controller.y;
            currentContext = filter.run(proxyController, currentContext, output, padding);
        }

        outputDrawingContext = currentContext;


        // This method must be called when the filter is run.
        this.onRunEnd(outputDrawingContext);

        // The method must return the output drawing context.
        return outputDrawingContext;
    }
}

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

precision mediump float;

uniform sampler2D uMainSampler;
uniform sampler2D strengthMap;
uniform vec2 resolution;
uniform vec2 offset;

varying vec2 outTexCoord;

void main ()
{
    vec2 uv = outTexCoord;

    vec4 strengthSample = texture2D(strengthMap, uv);
    float strength = (strengthSample.r + strengthSample.g + strengthSample.b) / 3.0;

    vec4 col = vec4(0.0);

    vec2 offset = vec2(1.333) * offset * strength;

    col += texture2D(uMainSampler, uv) * 0.29411764705882354;
    col += texture2D(uMainSampler, uv + (offset / resolution)) * 0.35294117647058826;
    col += texture2D(uMainSampler, uv - (offset / resolution)) * 0.35294117647058826;

    gl_FragColor = col;
}
`;

class FilterBlurByTexturePass extends Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader
{
    constructor (manager)
    {
        // The name 'FilterBlurByTexturePass' is used by the FilterBlurByTexture node to access this node.
        super('FilterBlurByTexturePass', manager, null, fragmentShaderBlurByTexture);
    }

    // This method adds extra textures to the shader.
    // `textures` is an array of WebGLTextureWrapper objects.
    // `textures[0]` is always the framebuffer being filtered.
    setupTextures (controller, textures)
    {
        textures[1] = controller.camera.scene.sys.textures.getFrame(controller.strengthMap).glTexture;
    }

    setupUniforms (controller, drawingContext)
    {
        var programManager = this.programManager;

        // Tell the shader which texture unit to use.
        programManager.setUniform('strengthMap', 1);

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

// End custom filter definition.


class Example extends Phaser.Scene
{
    preload ()
    {
        
        this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('skull-and-bones', 'assets/pics/skull-and-bones.jpg');
        this.load.image('distortion5', 'assets/textures/distortion5.png');
    }

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

        // Add an image to selectively blur.
        const image = this.add.image(640, 360, 'skull-and-bones')
        image.setScale(this.scale.width / image.width);
        image.enableFilters();
        const blurByTexture = image.filters.internal.add(
            new BlurByTexture(image.filterCamera, 4, 4, 4, 'distortion5')
        );

        // Tween the blur strength.
        this.tweens.add({
            targets: blurByTexture,
            x: 0,
            y: 0,
            duration: 3000,
            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 предоставляет систему рендер-нод (Render Nodes) для создания сложных фильтров на уровне WebGL. В отличие от простых шейдеров, эта система позволяет управлять несколькими проходами рендера. В нашем примере фильтр состоит из трёх основных классов:

- BlurByTexture — Контроллер (Controller). Это публичный интерфейс для настройки фильтра из игрового кода. Он хранит параметры: силу размытия по осям, количество проходов и ключ текстуры-маски. - FilterBlurByTexture — Основной узел фильтра (BaseFilter). Он управляет логикой выполнения нескольких проходов (горизонтальных и вертикальных), вызывая шейдерный узел. - FilterBlurByTexturePass — Шейдерный узел (BaseFilterShader). Непосредственно применяет GLSL-шейдер к кадру, используя переданные текстуры и uniform-переменные.

Такое разделение ответственности позволяет повторно использовать шейдерный узел в нескольких проходах.

Контроллер и основной узел: управление проходами

Контроллер BlurByTexture наследуется от Phaser.Filters.Controller. Его конструктор принимает камеру, силу размытия по X и Y, количество шагов и ключ текстуры-маски. Эти свойства автоматически передаются в шейдер.

class BlurByTexture extends Phaser.Filters.Controller
{
    constructor (camera, x, y, steps, strengthMap)
    {
        super(camera, 'FilterBlurByTexture');
        this.x = x;
        this.y = y;
        this.steps = steps;
        this.strengthMap = strengthMap;
    }
}

Основной узел FilterBlurByTexture наследуется от Phaser.Renderer.WebGL.RenderNodes.BaseFilter. Его ключевой метод run организует многопроходный рендеринг. Размытие Гаусса разделимо по осям, поэтому мы эмулируем его, попеременно применяя горизонтальные и вертикальные размытия. Это эффективнее, чем один сложный проход.

run (controller, inputDrawingContext, outputDrawingContext, padding)
{
    this.onRunBegin(outputDrawingContext);
    // ... логика проходов ...
    for (var i = 0; i < steps; i++)
    {
        // Горизонтальный проход
        proxyController.x = controller.x;
        proxyController.y = 0;
        currentContext = filter.run(proxyController, currentContext, null, padding);
        // Вертикальный проход
        proxyController.x = 0;
        proxyController.y = controller.y;
        currentContext = filter.run(proxyController, currentContext, output, padding);
    }
    this.onRunEnd(outputDrawingContext);
    return outputDrawingContext;
}

Обратите внимание на прокси-контроллер: он создаётся, чтобы изменять направление размытия (`xилиy`) для каждого прохода, не затрагивая оригинальный объект.

Шейдерный узел: текстурная маска и uniforms

Класс FilterBlurByTexturePass наследуется от Phaser.Renderer.WebGL.RenderNodes.BaseFilterShader. В него передаётся исходный код фрагментного шейдера. Два ключевых метода:

1. setupTextures — загружает дополнительную текстуру-маску (strengthMap) в слот 1. Слот 0 всегда занят основным фреймбуфером (uMainSampler).

setupTextures (controller, textures)
{
    textures[1] = controller.camera.scene.sys.textures.getFrame(controller.strengthMap).glTexture;
}

2. setupUniforms — передаёт в шейдер uniform-переменные: разрешение текущего контекста рендера (resolution), вектор смещения (offset), и привязывает текстуру-маску к нужному текстурному юниту.

setupUniforms (controller, drawingContext)
{
    var programManager = this.programManager;
    programManager.setUniform('strengthMap', 1);
    programManager.setUniform('resolution', [ drawingContext.width, drawingContext.height ]);
    programManager.setUniform('offset', [ controller.x, controller.y ]);
}

GLSL-шейдер: математика условного размытия

Сердце фильтра — фрагментный шейдер. Он выполняет одно направленное размытие (горизонтальное или вертикальное), но сила эффекта в каждом пикселе зависит от яркости текстуры-маски.

glsl
vec4 strengthSample = texture2D(strengthMap, uv);
float strength = (strengthSample.r + strengthSample.g + strengthSample.b) / 3.0;
vec2 offset = vec2(1.333) * offset * strength;

Здесь strengthMap — текстура-маска. Мы усредняем её RGB-каналы, чтобы получить значение силы размытия от 0.0 до 1.0. Затем вектор смещения (offset) умножается на это значение. В областях, где маска чёрная (strength = 0), смещение обнуляется, и размытия не происходит. Где маска белая — размытие максимально.

Далее шейдер использует три текселя (текущий и два смежных) с предрассчитанными весами для аппроксимации ядра размытия Гаусса, что даёт плавный результат.

Интеграция фильтра в сцену Phaser

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

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

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

const image = this.add.image(640, 360, 'skull-and-bones');
image.enableFilters();
const blurByTexture = image.filters.internal.add(
    new BlurByTexture(image.filterCamera, 4, 4, 4, 'distortion5')
);

Параметры конструктора: камера объекта, сила размытия по X, по Y, количество шагов и ключ загруженной текстуры-маски. Фильтром можно управлять через твины, меняя свойства контроллера `xиy`.

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

Вы создали мощный кастомный фильтр, который размывает изображение с переменной силой, заданной текстурной маской. Этот подход открывает путь к сложным эффектам: селективному размытию фона, имитации глубины резкости, эффектам теплового искажения. Для экспериментов попробуйте: 1. Использовать анимированную текстуру (спрайт-лист) для strengthMap, чтобы эффект "плыл" по объекту. 2. Заменить алгоритм размытия в шейдере на другой (например, радиальное размытие). 3. Комбинировать несколько кастомных фильтров в цепочку для создания уникальных визуальных стилей. Изучение системы рендер-нод — это ключ к полному контролю над графическим конвейером в ваших играх на Phaser.