О чем этот пример
Готовые фильтры размытия в 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.
