О чем этот пример
Визуальные эффекты — важная часть игрового впечатления. В этой статье мы разберем, как создать сложную систему частиц с эффектом motion blur (размытие в движении) с помощью шейдеров и буферов в Phaser. Этот подход позволяет добиться плавности и визуальной глубины, недоступной стандартными средствами рендеринга, и подходит для создания магических заклинаний, космических полей или энергетических следов. Мы рассмотрим пример, где два шейдера работают в паре, используя текстуры друг друга для создания эффекта накопления и размытия, что является классическим приемом в шейдерном программировании.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
create ()
{
const s1 = `
/*
"Magic particles" by Emmanuel Keller aka Tambako - December 2015
License Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License.
Contact: tamby@tambako.ch
*/
precision mediump float;
uniform float time;
uniform sampler2D iChannel0;
varying vec2 outTexCoord;
#define iTime time
vec4 texture(sampler2D s, vec2 c) { return texture2D(s,c); }
vec4 texture(sampler2D s, vec2 c, float b) { return texture2D(s,c,b); }
vec4 texture(samplerCube s, vec3 c ) { return textureCube(s,c); }
vec4 texture(samplerCube s, vec3 c, float b) { return textureCube(s,c,b); }
#define twopi 6.28319
// Please be careful, setting complexity > 1 may crash your browser!
// 1: for mac computers
// 2: for computers with normal graphic card
// 3: for computers with good graphic cards
// 4: for gaming computers
#define complexity 1
// General particles constants
#if complexity == 1
const int nb_particles = 95; // Number of particles on the screen at the same time. Be CAREFUL with big numbers of particles, 1000 is already a lot!
#elif complexity == 2
const int nb_particles = 160;
#elif complexity == 3
const int nb_particles = 280;
#elif complexity == 4
const int nb_particles = 500;
#endif
const vec2 gen_scale = vec2(0.60, 0.45); // To scale the particle positions, not the particles themselves
const vec2 middlepoint = vec2(0.35, 0.15); // Offset of the particles
// Particle movement constants
const vec2 gravitation = vec2(-0., -4.5); // Gravitation vector
const vec3 main_x_freq = vec3(0.4, 0.66, 0.78); // 3 frequences (in Hz) of the harmonics of horizontal position of the main particle
const vec3 main_x_amp = vec3(0.8, 0.24, 0.18); // 3 amplitudes of the harmonics of horizontal position of the main particle
const vec3 main_x_phase = vec3(0., 45., 55.); // 3 phases (in degrees) of the harmonics of horizontal position of the main particle
const vec3 main_y_freq = vec3(0.415, 0.61, 0.82); // 3 frequences (in Hz) of the harmonics of vertical position of the main particle
const vec3 main_y_amp = vec3(0.72, 0.28, 0.15); // 3 amplitudes of the harmonics of vertical position of the main particle
const vec3 main_y_phase = vec3(90., 120., 10.); // 3 phases (in degrees) of the harmonics of vertical position of the main particle
const float part_timefact_min = 6.; // Specifies the minimum how many times the particle moves slower than the main particle when it's "launched"
const float part_timefact_max = 20.; // Specifies the maximum how many times the particle moves slower than the main particle when it's "launched"
const vec2 part_max_mov = vec2(0.28, 0.28); // Maxumum movement out of the trajectory in display units / s
// Particle time constants
const float time_factor = 0.75; // Time in s factor, <1. for slow motion, >1. for faster movement
const float start_time = 2.5; // Time in s needed until all the nb_particles are "launched"
const float grow_time_factor = 0.15; // Time in s particles need to reach their max intensity after they are "launched"
#if complexity == 1
const float part_life_time_min = 0.9; // Minimum life time in s of a particle
const float part_life_time_max = 1.9; // Maximum life time in s of a particle
#elif complexity == 2
const float part_life_time_min = 1.0;
const float part_life_time_max = 2.5;
#elif complexity == 3
const float part_life_time_min = 1.1;
const float part_life_time_max = 3.2;
#elif complexity == 4
const float part_life_time_min = 1.2;
const float part_life_time_max = 4.0;
#endif
// Particle intensity constants
const float part_int_div = 40000.; // Divisor of the particle intensity. Tweak this value to make the particles more or less bright
const float part_int_factor_min = 0.1; // Minimum initial intensity of a particle
const float part_int_factor_max = 3.2; // Maximum initial intensity of a particle
const float part_spark_min_int = 0.25; // Minimum sparkling intensity (factor of initial intensity) of a particle
const float part_spark_max_int = 0.88; // Minimum sparkling intensity (factor of initial intensity) of a particle
const float part_spark_min_freq = 2.5; // Minimum sparkling frequence in Hz of a particle
const float part_spark_max_freq = 6.0; // Maximum sparkling frequence in Hz of a particle
const float part_spark_time_freq_fact = 0.35; // Sparkling frequency factor at the end of the life of the particle
const float mp_int = 12.; // Initial intensity of the main particle
const float dist_factor = 3.; // Distance factor applied before calculating the intensity
const float ppow = 2.3; // Exponent of the intensity in function of the distance
// Particle color constants
const float part_min_hue = -0.13; // Minimum particle hue shift (spectrum width = 1.)
const float part_max_hue = 0.13; // Maximum particle hue shift (spectrum width = 1.)
const float part_min_saturation = 0.5; // Minimum particle saturation (0. to 1.)
const float part_max_saturation = 0.9; // Maximum particle saturation (0. to 1.)
const float hue_time_factor = 0.035; // Time-based hue shift
const float mp_hue = 0.5; // Hue (shift) of the main particle
const float mp_saturation = 0.18; // Saturation (delta) of the main particle
// Particle star constants
const vec2 part_starhv_dfac = vec2(9., 0.32); // x-y transformation vector of the distance to get the horizontal and vertical star branches
const float part_starhv_ifac = 0.25; // Intensity factor of the horizontal and vertical star branches
const vec2 part_stardiag_dfac = vec2(13., 0.61); // x-y transformation vector of the distance to get the diagonal star branches
const float part_stardiag_ifac = 0.19; // Intensity factor of the diagonal star branches
const float mb_factor = 0.73; // Mix factor for the multipass motion blur factor
// Variables
float pst;
float plt;
float runnr;
float time2;
float time3;
float time4;
// From https://www.shadertoy.com/view/ldtGDn
vec3 hsv2rgb (vec3 hsv) { // from HSV to RGB color vector
hsv.yz = clamp (hsv.yz, 0.0, 1.0);
return hsv.z*(0.63*hsv.y*(cos(twopi*(hsv.x + vec3(0.0, 2.0/3.0, 1.0/3.0))) - 1.0) + 1.0);
}
// Simple "random" function
float random(float co)
{
return fract(sin(co*12.989) * 43758.545);
}
// Gets the time at which a paticle is starting its "life"
float getParticleStartTime(int partnr)
{
return start_time*random(float(partnr*2));
}
// Harmonic calculation, base is a vec4
float harms(vec3 freq, vec3 amp, vec3 phase, float time)
{
float val = 0.;
for (int h=0; h<3; h++)
val+= amp[h]*cos(time*freq[h]*twopi + phase[h]/360.*twopi);
return (1. + val)/2.;
}
// Gets the position of a particle in function of its number and the time
vec2 getParticlePosition(int partnr)
{
// Particle "local" time, when a particle is "reborn" its time starts with 0.0
float part_timefact = mix(part_timefact_min, part_timefact_max, random(float(partnr*2 + 94) + runnr*1.5));
float ptime = (runnr*plt + pst)*(-1./part_timefact + 1.) + time2/part_timefact;
vec2 ppos = vec2(harms(main_x_freq, main_x_amp, main_x_phase, ptime), harms(main_y_freq, main_y_amp, main_y_phase, ptime)) + middlepoint;
// Particles randomly get away the main particle's orbit, in a linear fashion
vec2 delta_pos = part_max_mov*(vec2(random(float(partnr*3-23) + runnr*4.), random(float(partnr*7+632) - runnr*2.5))-0.5)*(time3 - pst);
// Calculation of the effect of the gravitation on the particles
vec2 grav_pos = gravitation*pow(time4, 2.)/250.;
return (ppos + delta_pos + grav_pos)*gen_scale;
}
// Gets the position of the main particle in function of the time
vec2 getParticlePosition_mp()
{
vec2 ppos = vec2(harms(main_x_freq, main_x_amp, main_x_phase, time2), harms(main_y_freq, main_y_amp, main_y_phase, time2)) + middlepoint;
return gen_scale*ppos;
}
// Gets the rgb color of a particle in function of its intensity and number
vec3 getParticleColor(int partnr, float pint)
{
float hue;
float saturation;
saturation = mix(part_min_saturation, part_max_saturation, random(float(partnr*6 + 44) + runnr*3.3))*0.45/pint;
hue = mix(part_min_hue, part_max_hue, random(float(partnr + 124) + runnr*1.5)) + hue_time_factor*time2;
return hsv2rgb(vec3(hue, saturation, pint));
}
// Gets the rgb color the main particle in function of its intensity
vec3 getParticleColor_mp( float pint)
{
float hue;
float saturation;
saturation = 0.75/pow(pint, 2.5) + mp_saturation;
hue = hue_time_factor*time2 + mp_hue;
return hsv2rgb(vec3(hue, saturation, pint));
}
// Main function to draw particles, outputs the rgb color.
vec3 drawParticles(vec2 uv, float timedelta)
{
// Here the time is "stetched" with the time factor, so that you can make a slow motion effect for example
time2 = time_factor*(iTime + timedelta);
vec3 pcol = vec3(0.);
// Main particles loop
for (int i=1; i<nb_particles; i++)
{
pst = getParticleStartTime(i); // Particle start time
plt = mix(part_life_time_min, part_life_time_max, random(float(i*2-35))); // Particle life time
time4 = mod(time2 - pst, plt);
time3 = time4 + pst;
// if (time2>pst) // Doesn't draw the paricle at the start
//{
runnr = floor((time2 - pst)/plt); // Number of the "life" of a particle
vec2 ppos = getParticlePosition(i);
float dist = distance(uv, ppos);
//if (dist<0.05) // When the current point is further than a certain distance, its impact is neglectable
//{
// Draws the eight-branched star
// Horizontal and vertical branches
vec2 uvppos = uv - ppos;
float distv = distance(uvppos*part_starhv_dfac + ppos, ppos);
float disth = distance(uvppos*part_starhv_dfac.yx + ppos, ppos);
// Diagonal branches
vec2 uvpposd = 0.707*vec2(dot(uvppos, vec2(1., 1.)), dot(uvppos, vec2(1., -1.)));
float distd1 = distance(uvpposd*part_stardiag_dfac + ppos, ppos);
float distd2 = distance(uvpposd*part_stardiag_dfac.yx + ppos, ppos);
// Initial intensity (random)
float pint0 = mix(part_int_factor_min, part_int_factor_max, random(runnr*4. + float(i-55)));
// Middle point intensity star inensity
float pint1 = 1./(dist*dist_factor + 0.015) + part_starhv_ifac/(disth*dist_factor + 0.01) + part_starhv_ifac/(distv*dist_factor + 0.01) + part_stardiag_ifac/(distd1*dist_factor + 0.01) + part_stardiag_ifac/(distd2*dist_factor + 0.01);
// One neglects the intentity smaller than a certain threshold
//if (pint0*pint1>16.)
//{
// Intensity curve and fading over time
float pint = pint0*(pow(pint1, ppow)/part_int_div)*(-time4/plt + 1.);
// Initial growing of the paricle's intensity
pint*= smoothstep(0., grow_time_factor*plt, time4);
// "Sparkling" of the particles
float sparkfreq = clamp(part_spark_time_freq_fact*time4, 0., 1.)*part_spark_min_freq + random(float(i*5 + 72) - runnr*1.8)*(part_spark_max_freq - part_spark_min_freq);
pint*= mix(part_spark_min_int, part_spark_max_int, random(float(i*7 - 621) - runnr*12.))*sin(sparkfreq*twopi*time2)/2. + 1.;
// Adds the current intensity to the global intensity
pcol+= getParticleColor(i, pint);
//}
//}
//}
}
// Main particle
vec2 ppos = getParticlePosition_mp();
float dist = distance(uv, ppos);
// Draws the eight-branched star
// Horizontal and vertical branches
vec2 uvppos = uv - ppos;
float distv = distance(uvppos*part_starhv_dfac + ppos, ppos);
float disth = distance(uvppos*part_starhv_dfac.yx + ppos, ppos);
// Diagonal branches
vec2 uvpposd = 0.7071*vec2(dot(uvppos, vec2(1., 1.)), dot(uvppos, vec2(1., -1.)));
float distd1 = distance(uvpposd*part_stardiag_dfac + ppos, ppos);
float distd2 = distance(uvpposd*part_stardiag_dfac.yx + ppos, ppos);
// Middle point intensity star inensity
float pint1 = 1./(dist*dist_factor + 0.015) + part_starhv_ifac/(disth*dist_factor + 0.01) + part_starhv_ifac/(distv*dist_factor + 0.01) + part_stardiag_ifac/(distd1*dist_factor + 0.01) + part_stardiag_ifac/(distd2*dist_factor + 0.01);
if (part_int_factor_max*pint1>6.)
{
float pint = part_int_factor_max*(pow(pint1, ppow)/part_int_div)*mp_int;
pcol+= getParticleColor_mp(pint);
}
return pcol;
}
void mainImage(out vec4 fragColor, in vec2 outTexCoord)
{
vec2 uv = outTexCoord.xy;
// Multipass motion blur
vec2 uv2 = outTexCoord.xy;
vec3 pcolor = texture(iChannel0,uv2).rgb*mb_factor;
// Background gradient
//vec3 pcolor = vec3(0., (0.6 - uv.y)/10., (1. - uv.y)/9.);
//vec3 pcolor = texture(iChannel0,uv).rgb*0.4;
pcolor+= drawParticles(uv,0.)*0.9;
// We're done!
fragColor = vec4(pcolor, 0.);
}
void main(void)
{
mainImage(gl_FragColor, outTexCoord.xy);
}
`;
const s2 = `
precision mediump float;
uniform sampler2D iChannel0;
varying vec2 outTexCoord;
vec4 texture(sampler2D s, vec2 c) { return texture2D(s,c); }
vec4 texture(sampler2D s, vec2 c, float b) { return texture2D(s,c,b); }
void mainImage( out vec4 fragColor, in vec2 outTexCoord )
{
vec2 uv = outTexCoord.xy;
fragColor = texture(iChannel0,uv);
}
void main(void)
{
mainImage(gl_FragColor, outTexCoord.xy);
}
`;
const shader1 = this.add.shader(
{
name: 'BufferA',
fragmentSource: s1,
setupUniforms: (setUniform, drawingContext) => {
setUniform('time', this.game.loop.getDuration());
},
initialUniforms: {
iChannel0: 0
}
},
400, 300, 512, 512, [ '__DEFAULT' ]
);
shader1.setRenderToTexture('blah');
const shader2 = this.add.shader(
{
name: 'BufferB',
fragmentSource: s2,
initialUniforms: {
iChannel0: 0
}
},
400, 300, 512, 512, [ '__DEFAULT' ]
);
shader2.setRenderToTexture('blah2');
shader1.setTextures([ 'blah2' ]);
shader2.setTextures([ 'blah' ]);
this.add.image(400, 300, 'blah2');
}
}
const config = {
type: Phaser.WEBGL,
parent: 'phaser-example',
width: 800,
height: 600,
scene: Example
};
const game = new Phaser.Game(config);
Основы: шейдеры и буферы в Phaser
Phaser позволяет создавать и управлять GLSL шейдерами прямо из JavaScript кода с помощью класса Phaser.GameObjects.Shader. Шейдер — это программа, выполняемая на GPU, которая определяет, как отрисовывается каждый пиксель объекта.
Ключевая концепция для сложных эффектов — рендеринг в текстуру (Render Texture или буфер). Это позволяет сохранить результат отрисовки шейдера в текстуру, а затем использовать эту текстуру как входные данные для другого шейдера или для отображения на экране.
В нашем примере создаются два шейдера и два буфера, которые образуют циклическую зависимость для накопления кадров.
const shader1 = this.add.shader(
{
name: 'BufferA',
fragmentSource: s1,
setupUniforms: (setUniform, drawingContext) => {
setUniform('time', this.game.loop.getDuration());
},
initialUniforms: {
iChannel0: 0
}
},
400, 300, 512, 512, [ '__DEFAULT' ]
);
shader1.setRenderToTexture('blah');
Метод setRenderToTexture('blah') указывает, что результат рендеринга shader1 должен сохраняться в текстуру с ключом 'blah'. Параметр iChannel0: 0 в initialUniforms говорит шейдеру, что его первый текстурый слот (sampler2D) изначально будет использовать текстуру по умолчанию ('__DEFAULT').
Анализ шейдера частиц (BufferA)
Первый шейдер (s1) — сердце эффекта. Он генерирует анимированные частицы в форме восьмиконечных звезд. Шейдер оперирует понятием «главной частицы», орбиту которой повторяют множество дочерних частиц с добавлением случайных отклонений, гравитации и эффекта искрения.
Основные этапы работы шейдера:
1. **Расчет времени:** Время (time) модифицируется фактором time_factor для управления скоростью анимации.
2. **Генерация позиций:** Позиция главной частицы вычисляется как сумма трех гармонических колебаний (синусоид) по осям X и Y. Дочерние частицы повторяют эту траекторию, но с задержкой (part_timefact) и случайным линейным уходом с орбиты (delta_pos).
3. **Расчет интенсивности:** Интенсивность свечения каждой частицы зависит от расстояния от текущего пикселя (uv) до центра частицы. Для создания формы звезды расстояние рассчитывается не только до центра (dist), но и до виртуальных точек, смещенных по горизонтали, вертикали и диагоналям (disth, distv, distd1, distd2). Эти значения комбинируются для формирования конечной интенсивности.
4. **Цвет:** Цвет частиц генерируется в HSV-пространстве с последующим преобразованием в RGB. Оттенок (hue) и насыщенность (saturation) варьируются случайным образом и зависят от времени, создавая плавные цветовые переходы.
5. **Motion Blur:** Эффект шлейфа достигается не внутри одного шейдера, а на системном уровне. Шейдер читает предыдущий кадр из текстуры iChannel0, умножает его на коэффициент mb_factor (меньше 1) и добавляет к новому кадру с частицами. Это создает эффект постепенного затухания и накопления.
// Внутри mainImage шейдера s1:
vec3 pcolor = texture(iChannel0,uv2).rgb*mb_factor;
pcolor+= drawParticles(uv,0.)*0.9;
fragColor = vec4(pcolor, 0.);
Создание цикла обратной связи
Для работы motion blur нужна текстура предыдущего кадра. В Phaser это организуется путем связывания двух шейдеров.
1. Создается shader1 (BufferA), который рендерит в текстуру 'blah'.
2. Создается shader2 (BufferB), который является простым пасс-слоем (копирует входную текстуру без изменений) и рендерит в текстуру 'blah2'.
3. Шейдерам явно задаются текстуры для сэмплера iChannel0.
shader1.setTextures([ 'blah2' ]); // BufferA читает результат BufferB
shader2.setTextures([ 'blah' ]); // BufferB читает результат BufferA
Таким образом, на каждом кадре происходит следующее:
- BufferA берет за основу слегка затемненное изображение с прошлого кадра из 'blah2', рисует поверх новые частицы и записывает результат в 'blah'.
- BufferB просто копирует свежеотрендеренный 'blah' в 'blah2'.
- На экран выводится текстура 'blah2'.
this.add.image(400, 300, 'blah2');
Этот цикл (A -> B -> A...) обеспечивает передачу кадра между шейдерами и создает персистентный эффект накопления и размытия.
Настройка и оптимизация
Шейдер содержит множество констант для тонкой настройки внешнего вида. Важнейшая из них — complexity, которая управляет количеством частиц (nb_particles) и их временем жизни в зависимости от мощности целевого устройства.
// В начале шейдера s1:
#define complexity 1 // От 1 (маки) до 4 (игровые ПК)
#if complexity == 1
const int nb_particles = 95;
const float part_life_time_min = 0.9;
const float part_life_time_max = 1.9;
#endif
Другие ключевые параметры:
- time_factor: Ускоряет или замедляет всю анимацию.
- part_int_div: Глобальный множитель яркости частиц.
- mb_factor: Сила эффекта motion blur (чем ближе к 1, тем дольше сохраняется след).
- gravitation: Вектор гравитации, влияющий на траекторию частиц.
Важно помнить, что шейдер выполняется для каждого пикселя. Большое количество частиц или сложные вычисления могут сказаться на производительности. Всегда тестируйте эффект на целевых устройствах.
Что попробовать дальше
Использование пары шейдеров с циклической передачей текстур — мощный метод создания сложных визуальных эффектов в Phaser, таких как motion blur, свечение или водные симуляции. Этот пример демонстрирует, как можно вынести тяжелую логику частиц на GPU и добиться плавной анимации.
**Идеи для экспериментов:**
1. Измените функцию getParticleColor, чтобы частицы меняли цвет в зависимости от скорости или времени жизни.
2. Замените простой пасс-шейдер (BufferB) на шейдер размытия по Гауссу для более мягкого motion blur.
3. Добавьте взаимодействие: пусть позиция главной частицы (middlepoint) управляется курсором мыши или следует за игровым персонажем. Для этого нужно передавать uniform-переменные с координатами из основного кода.
4. Используйте сгенерированную текстуру 'blah2' не только как фон, но и как световую карту, накладывая ее на другие игровые объекты с помощью blending modes.
