О чем этот пример
Визуальные эффекты в играх часто требуют многоуровневой постобработки, где результат одного шейдера служит входными данными для другого. Phaser 3 предоставляет для этого мощный, но малоизвестный инструмент — систему шейдерных буферов. Эта статья на практическом примере покажет, как связать два GLSL-шейдера в цепочку, создавая бесконечную анимацию с обратной связью. Вы научитесь рендерить шейдеры в текстуры и передавать их между собой, открывая путь к сложным эффектам вроде плазмы, водных симуляций или психоделических переходов.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
create ()
{
const s1 = `
precision mediump float;
uniform float time;
uniform vec2 resolution;
uniform sampler2D iChannel0;
varying vec2 outTexCoord;
#define iTime time
#define iResolution resolution
vec4 texture(sampler2D s, vec2 c) { return texture2D(s,c); }
// Created by Stephane Cuillerdier - @Aiekick/2016
// License Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
void mainImage( out vec4 f, vec2 g )
{
float
t = time,
p;
vec2
s = iResolution.xy,
u = (g+g-s)/s.y,
ar = vec2(
atan(u.x, u.y) * 3.18 + t*2.,
length(u)*3. + sin(t*.5)*10.);
p = floor(ar.y)/5.;
ar = abs(fract(ar)-.5);
f =
mix(
vec4(1,.3,0,1),
vec4(.3,.2,.5,1),
vec4(p))
* .1/dot(ar,ar) * .1
+ texture(iChannel0, g / s) * .9;
}
void main(void)
{
mainImage(gl_FragColor, outTexCoord.xy * iResolution.xy);
}
`;
const s2 = `
precision mediump float;
uniform float time;
uniform vec2 resolution;
uniform sampler2D iChannel0;
varying vec2 outTexCoord;
#define iTime time
#define iResolution resolution
vec4 texture(sampler2D s, vec2 c) { return texture2D(s,c); }
void mainImage( out vec4 f, vec2 g )
{
f = texture(iChannel0, g/iResolution.xy);
}
void main(void)
{
mainImage(gl_FragColor, outTexCoord.xy * iResolution.xy);
}
`;
const shader1 = this.add.shader(
{
name: 'BufferA',
fragmentSource: s1,
setupUniforms: (setUniform, drawingContext) => {
setUniform('time', this.game.loop.getDuration());
},
initialUniforms: {
iChannel0: 0,
resolution: [ 512, 512 ]
}
},
0, 0, 512, 512, [ '__DEFAULT' ]
)
.setRenderToTexture('shader1');
const shader2 = this.add.shader(
{
name: 'BufferB',
fragmentSource: s2,
setupUniforms: (setUniform, drawingContext) => {
setUniform('time', this.game.loop.getDuration());
},
initialUniforms: {
iChannel0: 0,
resolution: [ 512, 512 ]
}
},
0, 0, 512, 512, [ 'shader1' ]
)
.setRenderToTexture('shader2');
shader1.setTextures([ 'shader2' ]);
this.add.image(400, 300, 'shader2');
}
}
const config = {
type: Phaser.WEBGL,
parent: 'phaser-example',
width: 800,
height: 600,
scene: Example
};
const game = new Phaser.Game(config);
Суть техники: рендеринг в текстуру и обратная связь
Ключевая идея примера — создать два шейдерных объекта (шейдерных спрайта), которые рендерят свой результат не на экран, а в текстуру. Эти текстуры затем используются как входные данные (sampler2D) для шейдеров в следующем кадре, создавая цикл обратной связи.
В данном случае: - **Шейдер 1 (BufferA)** генерирует сложный анимированный узор (спирали, завихрения). - **Шейдер 2 (BufferB)** — это простой «пасс-сёру», который просто отрисовывает переданную ему текстуру без изменений.
Магия происходит в настройке их зависимостей: BufferA в качестве входной текстуры iChannel0 получает результат BufferB предыдущего кадра, а BufferB, в свою очередь, получает результат BufferA. Это создаёт вечный цикл, где изображение постоянно трансформируется, порождая органичную, бесконечную анимацию.
shader1.setTextures([ 'shader2' ]);
Этот вызов — сердце механизма. Он динамически привязывает текстуру под именем 'shader2' (в которую рендерит второй шейдер) к первому шейдеру в качестве семплера iChannel0.
Создание шейдеров с рендерингом в текстуру
В Phaser для создания шейдера, который рендерит в текстуру, используется метод this.add.shader(). Критически важны последние два параметра: размеры области рендера и массив имён входных текстур.
Посмотрите на создание первого шейдера:
const shader1 = this.add.shader(
{
name: 'BufferA',
fragmentSource: s1,
setupUniforms: (setUniform, drawingContext) => {
setUniform('time', this.game.loop.getDuration());
},
initialUniforms: {
iChannel0: 0,
resolution: [ 512, 512 ]
}
},
0, 0, 512, 512, [ '__DEFAULT' ]
).setRenderToTexture('shader1');
Разберём ключевые моменты:
1. **Параметры рендера**: 0, 0, 512, 512 — это координаты и размер прямоугольника рендера *внутри текстуры*. По сути, мы создаём текстуру 512x512 пикселей.
2. **Массив текстур [ '__DEFAULT' ]**: Это начальный источник текстуры для семплера iChannel0. '__DEFAULT' — специальная белая текстура 2x2 пикселя от Phaser.
3. **Метод .setRenderToTexture('shader1')**: Именно он переключает шейдер в режим рендера в текстуру. Текстура регистрируется в текстовом кеше игры под переданным именем ('shader1').
Аналогично создаётся второй шейдер, но с важным отличием:
const shader2 = this.add.shader(
... // конфиг шейдера
0, 0, 512, 512, [ 'shader1' ] // Использует текстуру от первого шейдера!
).setRenderToTexture('shader2');
Здесь массив [ 'shader1' ] указывает, что изначально семплер iChannel0 этого шейдера должен брать данные из текстуры 'shader1', которую создал первый шейдер. Так устанавливается первоначальная связь.
GLSL-код: как работает анимация
Шейдеры написаны на GLSL с использованием шаблона mainImage от Shadertoy, который популярен в демосцене.
**Шейдер BufferA (основной эффект):**
Его код создаёт спиралевидный узор, который зависит от времени (time) и расстояния пикселя от центра экрана.
glsl
vec2 u = (g+g-s)/s.y;
vec2 ar = vec2(atan(u.x, u.y) * 3.18 + t*2., length(u)*3. + sin(t*.5)*10.);
Здесь ar.x — угол, к которому прибавляется время, заставляя узор вращаться. ar.y — радиус, который пульсирует с помощью синуса от времени. Функция fract() и вычитание 0.5 создают повторяющийся узор с резкими гранями.
**Шейдер BufferB (прокси):** Его код предельно прост — он лишь передаёт пиксель из входной текстуры.
glsl
void mainImage( out vec4 f, vec2 g )
{
f = texture(iChannel0, g/iResolution.xy);
}
Его задача — сохранить результат BufferA в текстуру shader2 для использования в следующем кадре.
Важный нюанс в коде BufferA — смешивание (mix) собственного узора с входной текстурой:
glsl
f = mix(...) * .1/dot(ar,ar) * .1 + texture(iChannel0, g / s) * .9;
Здесь texture(iChannel0, g / s) * .9 — это результат BufferB (т.е., BufferA с предыдущего кадра), который на 90% определяет итоговый цвет. Это создаёт эффект "послесвечения" и плавной эволюции изображения, а не его резкой смены.
Замыкание цикла и отображение результата
После создания обоих шейдеров необходимо замкнуть цикл обратной связи. Мы уже установили, что BufferB берёт данные из BufferA. Теперь нужно сделать наоборот — заставить BufferA использовать результат BufferB.
Это делается постфактум, с помощью метода setTextures:
shader1.setTextures([ 'shader2' ]);
Этот вызов переопределяет источник для семплера iChannel0 шейдера shader1. Теперь в каждом кадре будет происходить следующее:
1. BufferA рисует, используя текстуру shader2 (результат BufferB с прошлого кадра).
2. Его результат сохраняется в текстуру shader1.
3. BufferB рисует, используя текстуру shader1 (только что вычисленный результат BufferA).
4. Его результат сохраняется в текстуру shader2.
5. Цикл повторяется с пункта 1.
Чтобы увидеть результат, мы просто создаём обычное изображение (this.add.image), которое использует итоговую текстуру shader2 в качестве источника.
this.add.image(400, 300, 'shader2');
Поскольку текстура shader2 обновляется каждый кадр, изображение на экране тоже анимируется.
Практические советы и отладка
Работа с шейдерными буферами требует внимательности. Вот что важно помнить:
1. **Размеры имеют значение:** Убедитесь, что размеры области рендера (width, height в add.shader) соответствуют размеру, указанному в uniform resolution. Несоответствие вызовет искажения.
2. **Порядок обновления:** Phaser обновляет шейдеры в порядке их добавления на сцену. В данном примере порядок создания (shader1, затем shader2) и настройки обратной связи корректен.
3. **Инициализация текстур:** Массив входных текстур в конструкторе ([ '__DEFAULT' ], [ 'shader1' ]) задаёт начальные значения *до* первого вызова setTextures. Без этого шейдер может не скомпилироваться из-за отсутствующего семплера.
4. **Используйте name:** Задание свойства name в конфиге шейдера помогает при отладке в инструментах разработчика.
5. **Производительность:** Рендеринг в текстуру и обратная связь — дорогие операции. Оптимизируйте шейдерный код, избегайте сложных ветвлений (if) и используйте умеренные разрешения (512x512, как в примере — хороший выбор).
Что попробовать дальше
Система шейдерных буферов в Phaser 3 — это портал в мир сложных динамических визуалов. Вы научились создавать цепочки шейдеров с обратной связью, что является основой для множества эффектов.
**Идеи для экспериментов:**
1. Измените коэффициенты смешивания в шейдере BufferA (.1 и .9), чтобы сделать эволюцию более резкой или, наоборот, плавной.
2. Добавьте третий шейдерный буфер в цепочку для ещё более сложной постобработки.
3. Используйте входную текстуру не от другого буфера, а от реального игрового объекта (например, RenderTexture с отрисованными спрайтами), чтобы применять шейдерные эффекты к игровой сцене.
4. Модифицируйте GLSL-код BufferA: попробуйте другие математические функции (sin, cos, tan, mod) для создания иных паттернов — клеток, волн, шума.
