О чем этот пример
Визуальные эффекты — ключевой элемент атмосферы в играх. Статические текстуры часто выглядят скучно, особенно для фонов, жидкостей, магии или дыма. В этой статье мы разберем, как использовать шейдеры в Phaser для генерации динамичного шума Перлина прямо в реальном времени. Вы научитесь создавать «живые», плавно меняющиеся текстуры без использования готовых изображений, что отлично подходит для эффектов искажения, анимированных фонов или procedural generation.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
const perlinNoiseFunction = `
// Assign this snippet to \`fragmentHeader\` in the shader config.
// This places it above the \`main\` function in the fragment shader.
// We declare this here so it can be used in the fragment process.
uniform float time;
vec2 rand2 (vec2 co) {
co = vec2(dot(co, vec2(127.1, 311.7)), dot(co, vec2(269.5, 183.3)));
return fract(sin(co) * 43758.5453);
}
float perlinNoise (vec2 uv)
{
vec2 i = floor(uv);
vec2 f = fract(uv);
return mix(mix(dot(rand2(i + vec2(0.0, 0.0)), f - vec2(0.0, 0.0)),
dot(rand2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), f.x),
mix(dot(rand2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
dot(rand2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), f.x), f.y) * 0.5 + 0.5;
}
`;
const waveProcess = `
// Assign this snippet to \`fragmentProcess\` in the shader config.
// This places it within the \`main\` function in the fragment shader.
// The \`fragColor\` variable is defined above this, and output below this.
// Direct the noise down the screen.
vec2 timeOffset = vec2(0.0, time);
// Base frequency, to match screen aspect ratio of this example.
// Change this or set it as a uniform to match your game.
vec2 frequency = vec2(16, 9);
// Accumulate noise from three different frequencies.
float noise0 = perlinNoise(outTexCoord * frequency * 3.0 + timeOffset * 2.0);
float noise1 = perlinNoise(outTexCoord * frequency * 5.0 + timeOffset / 1.345);
float noise2 = perlinNoise(outTexCoord * frequency * 7.0 + timeOffset / 2.1);
float sum = noise0 * 0.5 + noise1 * 0.3 + noise2 * 0.2;
// Round the sum up to 0.5 if below, to create flat areas.
sum = max(sum, 0.5);
fragColor = vec4(vec3(sum), 1.0);
`;
class Example extends Phaser.Scene
{
constructor()
{
super();
}
preload()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('bg', 'assets/pics/purple-bars.jpg');
}
create()
{
const shaderConfig = {
name: 'Noise',
// By omitting `fragmentSource`, the default fragment shader is used.
// It has template points for accepting additions.
shaderAdditions: [
{
name: 'PerlinNoise',
additions: {
fragmentHeader: perlinNoiseFunction,
fragmentProcess: waveProcess
}
}
],
setupUniforms: (setUniform, drawingContext) => {
// Don't let time grow too large, or it will lose precision.
setUniform('time', (this.game.loop.time % 1000000) / 500);
}
};
const shader = this.add.shader(shaderConfig, 640, 360, 1280, 720);
shader.setRenderToTexture('ooze');
const bg = this.add.image(640, 360, 'bg')
.enableFilters()
.setDisplaySize(1280, 720);
bg.filters.internal.addDisplacement('ooze', 0, 0.5);
}
}
const config = {
type: Phaser.WEBGL,
parent: 'phaser-example',
width: 1280,
height: 720,
scene: Example
};
const game = new Phaser.Game(config);
Суть подхода: кастомные фрагменты шейдера
Phaser предоставляет гибкую систему шейдеров. Вместо написания шейдера с нуля, можно модифицировать стандартный, добавляя в него свои куски кода (shader additions). Это позволяет сосредоточиться на конкретной логике, например, на генерации шума, не заботясь о базовой структуре GLSL.
В примере используется конфигурационный объект shaderConfig. Ключевое поле shaderAdditions принимает массив объектов, каждый из которых содержит именованные дополнения для фрагментного шейдера.
const shaderConfig = {
name: 'Noise',
shaderAdditions: [
{
name: 'PerlinNoise',
additions: {
fragmentHeader: perlinNoiseFunction,
fragmentProcess: waveProcess
}
}
],
setupUniforms: (setUniform, drawingContext) => {
setUniform('time', (this.game.loop.time % 1000000) / 500);
}
};
- fragmentHeader: Код, вставляемый в начало шейдера, до функции main. Здесь объявляются uniform-переменные (например, time) и функции (наш perlinNoise).
- fragmentProcess: Код, вставляемый внутрь функции main. Здесь происходят основные вычисления цвета пикселя (fragColor).
- setupUniforms: Функция, вызываемая каждый кадр для обновления uniform-переменных, передаваемых в шейдер из основного кода игры.
Генерация шума Перлина в GLSL
Шум Перлина создает более естественные, органичные паттерны по сравнению с простым случайным шумом. Его алгоритм интерполирует псевдослучайные градиенты в узлах целочисленной сетки.
Функция perlinNoise написана на GLSL и помещается в fragmentHeader. Она работает с двумерными координатами (uv).
const perlinNoiseFunction = `
uniform float time;
vec2 rand2 (vec2 co) {
co = vec2(dot(co, vec2(127.1, 311.7)), dot(co, vec2(269.5, 183.3)));
return fract(sin(co) * 43758.5453);
}
float perlinNoise (vec2 uv) {
vec2 i = floor(uv);
vec2 f = fract(uv);
return mix(mix(dot(rand2(i + vec2(0.0, 0.0)), f - vec2(0.0, 0.0)),
dot(rand2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), f.x),
mix(dot(rand2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)),
dot(rand2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), f.x), f.y) * 0.5 + 0.5;
}
`;
- rand2: Генерирует псевдослучайный вектор на основе входных координат. Используется для создания градиентов в узлах сетки.
- perlinNoise: Основная функция. Она:
1. Находит целочисленные координаты узла (`i) и дробную часть внутри ячейки (f`).
2. Для четырех углов текущей ячейки вычисляет скалярное произведение случайного градиента и вектора от угла к точке.
3. Дважды интерполирует (с помощью mix) результаты по осям X и Y.
4. Масштабирует и смещает результат в диапазон [0.0, 1.0].
Анимация и наслоение шума
Один слой шума Перлина выглядит слишком однородно. Чтобы создать сложную, «турбулентную» текстуру, мы накладываем несколько слоев (октав) с разной частотой и скоростью.
Этот код помещается в fragmentProcess и управляет итоговым цветом пикселя.
const waveProcess = `
vec2 timeOffset = vec2(0.0, time);
vec2 frequency = vec2(16, 9);
float noise0 = perlinNoise(outTexCoord * frequency * 3.0 + timeOffset * 2.0);
float noise1 = perlinNoise(outTexCoord * frequency * 5.0 + timeOffset / 1.345);
float noise2 = perlinNoise(outTexCoord * frequency * 7.0 + timeOffset / 2.1);
float sum = noise0 * 0.5 + noise1 * 0.3 + noise2 * 0.2;
sum = max(sum, 0.5);
fragColor = vec4(vec3(sum), 1.0);
`;
- timeOffset: Вектор, зависящий от времени. Добавляется к координатам текстуры (outTexCoord), создавая эффект «плывущего» вниз шума.
- frequency: Базовый вектор частоты, подобранный под соотношение сторон экрана (1280/720 ≈ 16/9). Умножаясь на разные множители (3.0, 5.0, 7.0), создает октавы с разной детализацией.
- noise0, noise1, noise2: Три слоя шума с разной частотой и скоростью анимации.
- sum: Наложение слоев с разным весом. Веса (0.5, 0.3, 0.2) определяют вклад каждой октавы.
- max(sum, 0.5): Обрезает низкие значения, создавая плоские «плато» в текстуре.
- fragColor: Итоговый цвет пикселя — оттенки серого (все каналы RGB равны sum).
Интеграция в сцену: шейдер как текстура искажения
Сгенерированный шум редко используется как самостоятельное изображение. Чаще он служит картой смещения (displacement map) для искажения других текстур.
create() {
const shader = this.add.shader(shaderConfig, 640, 360, 1280, 720);
shader.setRenderToTexture('ooze');
const bg = this.add.image(640, 360, 'bg')
.enableFilters()
.setDisplaySize(1280, 720);
bg.filters.internal.addDisplacement('ooze', 0, 0.5);
}
1. this.add.shader(...): Создает игровой объект шейдера, центрирует его и задает размеры, равные размеру сцены.
2. setRenderToTexture('ooze'): Ключевой метод. Отрисовывает результат выполнения шейдера не на экран, а в именованную текстуру 'ooze', которая становится доступна для других объектов.
3. enableFilters(): Включает систему фильтров для изображения фона.
4. addDisplacement('ooze', 0, 0.5): Применяет к фону фильтр смещения (displacement filter). Он использует текстуру 'ooze' как карту: яркость каждого пикселя этой карты определяет, насколько сместить соответствующий пиксель фона. Параметры `0и0.5` задают смещение по осям X и Y. В результате статичный фон начинает «течь» и искажаться в соответствии с нашим анимированным шумом.
Что попробовать дальше
Использование кастомных шейдеров для генерации procedural-тектур открывает огромные возможности для визуализации в Phaser. Вы можете менять параметры частоты, количества октав, формулу анимации, чтобы создавать огонь, воду, туман или странные инопланетные ландшафты. Для экспериментов попробуйте:
- Изменить frequency и веса октав для получения более резкого или плавного шума.
- Использовать шум для управления не только смещением, но и цветом (fragColor.rgba).
- Привязать uniform-переменную time не к игровому времени, а к положению курсора или состоянию игрового объекта.
