О чем этот пример
Визуальные эффекты — это то, что делает игру запоминающейся. В этом руководстве мы разберем, как создать динамический фон с плавными градиентными волнами и зеркальным отражением объектов, используя шейдеры и систему рендер-текстур Phaser 3. Вы научитесь комбинировать несколько техник для генерации сложных визуалов прямо во время выполнения игры, что отлично подходит для создания атмосферных меню, кат-сцен или магических эффектов.
Версия Phaser: код и демо в этой статье рассчитаны на Phaser 3.90.0.
Живой запуск
Ниже встроен рабочий билд примера. Оригинальный источник: GitHub.
Исходный код
class Example extends Phaser.Scene
{
preload ()
{
this.load.setBaseURL('https://raw.githubusercontent.com/phaserjs/examples/master/public/');
this.load.image('spooky', 'assets/skies/spooky.png');
this.load.image('wizball', 'assets/sprites/wizball.png');
this.load.glsl('gradient-color', 'assets/shaders/gradients/gradient-color.glsl');
this.load.glsl('gradient-process', 'assets/shaders/gradients/gradient-process.glsl');
this.load.glsl('srgb', 'assets/shaders/gradients/srgb-color.glsl');
this.load.glsl('value-bicircle', 'assets/shaders/gradients/value-bicircle.glsl');
}
create ()
{
const w = this.scale.gameSize.width;
const h = this.scale.gameSize.height;
// Define a space a bit larger than the screen, to absorb displacement.
const wPlus = w + 100;
const hPlus = h + 100;
const getSource = (key) => this.cache.shader.get(key).glsl;
const gradientShader = this.add.shader({
name: 'Gradient',
shaderAdditions: [
// This addition controls a bi-circle gradient.
// It must be defined in the shader before the gradient function is called.
{
name: 'BICIRCLE',
additions: { fragmentHeader: getSource('value-bicircle') }
},
// This addition defines standard gradient functionality.
{
name: 'STANDARD',
additions: {
fragmentHeader: [
getSource('srgb'),
getSource('gradient-color')
].join('\n'),
fragmentProcess: getSource('gradient-process')
}
}
],
setupUniforms: (setUniform) => {
setUniform('center', [0.5, 0.33]);
setUniform('radius', 1.4);
setUniform('feather', 1.4);
setUniform('scale', [1, 0.5]);
setUniform('color1', [1, 1, 1, 1]); // Interior color.
setUniform('color2', [0, 0, 0, 0]); // Exterior color.
setUniform('steps', 0); // Smooth gradient.
setUniform('repeat', 32);
setUniform('offset', (-this.game.loop.time / 30000) % (1.4 / 32) + (1.4 / 32)); // Animation.
// 1.4 / 32 is the width of a single gradient step.
}
}, 0, 0, wPlus, hPlus)
.setRenderToTexture('gradient');
// Create a dynamic texture for inverting the gradient.
// Using `redraw` mode, we automatically update the texture every frame.
// Note that this is the size of `gradientShader`, not the screen.
const gradientInverse = this.add.renderTexture(
0, 0,
wPlus, hPlus
);
gradientInverse
.clear()
.stamp('gradient', null, wPlus / 2, hPlus / 2, { scaleY: -1 })
.preserve(true)
.setRenderMode('redraw')
.saveTexture('gradient-inverse');
// Add a scaled-up background to be reflected.
const spookyTextureFrame = this.textures.get('spooky').get();
const bg = this.add.image(wPlus / 2, hPlus / 2, 'spooky').setScale(
wPlus / spookyTextureFrame.width,
hPlus / spookyTextureFrame.height
);
// Add a reflection ball.
const reflection = this.add.image(wPlus / 2, hPlus * 1 / 8, 'wizball');
this.reflection = reflection;
// Create a container for the reflected world.
const reflectionContainer = this.add.container(0, 0)
.add([ bg, reflection ])
.setVisible(false);
// Render the container to a texture.
const reflectionTexture = this.add.renderTexture(w / 2, h / 2, wPlus, hPlus)
.setFlipY(true);
reflectionTexture.enableFilters();
reflectionTexture.filters.internal.addBlur(1, 2, 2);
reflectionTexture.filters.internal.addDisplacement('gradient-inverse');
reflectionTexture
.clear()
.draw(reflectionContainer)
.preserve(true)
.setRenderMode('all');
const rippleBase = this.add.image(w / 2, h / 2, 'gradient')
.setAlpha(0.125)
.setTint(0x1800cc)
.setBlendMode(Phaser.BlendModes.ADD);
const rippleTop = this.add.image(w / 2, h / 2 - 8, 'gradient')
.setAlpha(0.125)
.setTint(0x66aaff)
.setBlendMode(Phaser.BlendModes.ADD);
const ball = this.add.image(w / 2, h / 2, 'wizball');
this.ball = ball;
// this.cameras.main.filters.internal.addTiltShift();
this.tweens.add({
targets: ball,
y: h / 2 + 32,
duration: 1000,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
delay: 0
});
}
update ()
{
// Sync the reflection with the ball.
const height = this.scale.gameSize.height;
const ball = this.ball;
const reflection = this.reflection;
const surface = height * 0.66;
const distance = ball.y - surface;
reflection.y = (height - surface) + distance;
}
}
const config = {
type: Phaser.AUTO,
width: 1280,
height: 720,
backgroundColor: '#0a0033',
parent: 'phaser-example',
scene: Example
};
let game = new Phaser.Game(config);
Подготовка ресурсов и загрузка шейдеров
Ключевой элемент примера — использование GLSL-шейдеров для создания анимированного градиента. Phaser позволяет загружать шейдеры как отдельные ресурсы с помощью метода load.glsl().
this.load.glsl('gradient-color', 'assets/shaders/gradients/gradient-color.glsl');
this.load.glsl('gradient-process', 'assets/shaders/gradients/gradient-process.glsl');
В методе preload мы загружаем несколько шейдерных файлов и спрайты. Обратите внимание на load.setBaseURL — он задает базовый путь для всех последующих загрузок, что удобно для работы с удаленными ресурсами. Загруженные шейдеры становятся доступны через кэш this.cache.shader.
Создание анимированного шейдерного градиента
Основной визуальный паттерн — волны — создается с помощью шейдерного объекта. Мы используем this.add.shader(), передавая ему конфигурацию с "дополнениями" (additions) и униформ-переменными.
const gradientShader = this.add.shader({
name: 'Gradient',
shaderAdditions: [
{
name: 'BICIRCLE',
additions: { fragmentHeader: getSource('value-bicircle') }
}
],
setupUniforms: (setUniform) => {
setUniform('offset', (-this.game.loop.time / 30000) % (1.4 / 32) + (1.4 / 32));
}
}, 0, 0, wPlus, hPlus)
.setRenderToTexture('gradient');
Параметр shaderAdditions позволяет модульно собрать шейдер из отдельных GLSL-кодов. Функция setupUniforms задает параметры градиента, такие как центр, радиус и цвета. Униформа offset анимируется на основе игрового времени (this.game.loop.time), создавая плавное движение волн. Вызов .setRenderToTexture('gradient') рендерит результат шейдера в текстуру с указанным ключом для последующего использования.
Работа с Render Texture: инверсия и отражение
Phaser предоставляет мощный инструмент — RenderTexture. В примере она используется для двух целей: создания инвертированной копии градиента и рендеринга отраженной сцены.
const gradientInverse = this.add.renderTexture(0, 0, wPlus, hPlus);
gradientInverse
.clear()
.stamp('gradient', null, wPlus / 2, hPlus / 2, { scaleY: -1 })
.saveTexture('gradient-inverse');
Метод .stamp() накладывает существующую текстуру 'gradient' с инверсией по вертикали (scaleY: -1). Режим рендера 'redraw' гарантирует, что текстура будет перерисовываться каждый кадр, если ее содержимое динамическое.
Вторая RenderTexture используется для создания отражения с применением фильтров:
reflectionTexture.enableFilters();
reflectionTexture.filters.internal.addBlur(1, 2, 2);
reflectionTexture.filters.internal.addDisplacement('gradient-inverse');
Фильтр размытия (addBlur) и смещения (addDisplacement) применяются к отрисованной сцене, имитируя искажение в воде. Текстура 'gradient-inverse' выступает в качестве карты смещения.
Композиция и наложение слоев
Финальный визуальный эффект достигается наложением нескольких слоев с разными режимами смешивания (Blend Modes).
const rippleBase = this.add.image(w / 2, h / 2, 'gradient')
.setAlpha(0.125)
.setTint(0x1800cc)
.setBlendMode(Phaser.BlendModes.ADD);
Создается два экземпляра текстуры 'gradient' с разными оттенками (setTint) и низкой прозрачностью. Режим смешивания ADD приводит к осветлению фона в местах наложения, создавая эффект свечения. Анимированный шар добавляется поверх для завершения композиции.
Синхронизация анимации и логика обновления
Движение отражения шара синхронизировано с движением самого шара в методе update. Это простой, но эффективный пример зеркальной симметрии.
const surface = height * 0.66;
const distance = ball.y - surface;
reflection.y = (height - surface) + distance;
Переменная surface определяет условную "поверхность воды". Положение отражения (reflection.y) вычисляется относительно этой поверхности, создавая иллюзию, что шар и его отражение движутся согласованно. Анимация основного шара реализована через твин, который бесконечно повторяется с эффектом yoyo.
Что попробовать дальше
Комбинируя шейдеры, render textures и фильтры, вы можете создавать в Phaser 3 сложные динамические фоны без использования предварительно отрисованных видео. Для экспериментов попробуйте изменить параметры униформ в шейдере (например, feather или steps), чтобы получить другой рисунок волн. Замените карту смещения или добавьте новые фильтры к текстуре отражения. Также можно адаптировать эту технику для создания эффектов магических щитов, искажений пространства или подводного мира.
