Bloom
Что уже должно быть понятно:
- compute passes, storage textures,
textureLoad/textureStore - HDR и tone mapping
- render-to-texture
Что появится в этой главе:
- bright extraction — выделение пикселей ярче порога
- separable Gaussian blur — размытие в два прохода (горизонтальный + вертикальный)
- аддитивное наложение:
scene + bloom - пять проходов: scene, bright extract, H-blur, V-blur, composite
Итог: ярко освещённые грани кубов «светятся» — ореол разливается за их границы
Bloom — эффект, при котором яркие объекты испускают ореол света, «разливаясь» за свои границы. В реальности это происходит из-за рассеивания света в линзе камеры или глазу.
Алгоритм bloom
Bloom состоит из трёх этапов:
- Bright extraction — выделить из HDR-изображения пиксели с яркостью выше порога
- Blur — размыть выделенные яркие области (Gaussian blur)
- Composite — добавить размытое свечение к оригинальному изображению
Scene (HDR) ──→ Bright Extract ──→ H-Blur ──→ V-Blur ──┐
│ │
└──────────────── scene + bloom ──── tone map ────┘→ ScreenBright extraction
Compute-шейдер проверяет каждый пиксель: если яркость (luminance) выше порога — пиксель попадает в «яркую» текстуру, иначе записывается чёрный:
let brightness = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
if (brightness > params.threshold) {
textureStore(output_tex, vec2<i32>(id.xy), color);
} else {
textureStore(output_tex, vec2<i32>(id.xy), vec4<f32>(0.0));
}Luminance — стандартный Rec. 709: threshold = 1.0 означает, что выбираются только HDR-значения (те, что не влезли бы в LDR).
Separable Gaussian blur
Полный 2D Gaussian blur с ядром
Веса для 9-точечного 1D Gaussian (5 уникальных весов из-за симметрии):
let weights = array<f32, 5>(0.227027, 0.1945946, 0.1216216, 0.054054, 0.016216);Для каждого пикселя суммируются взвешенные значения из соседних текселей:
var result = textureLoad(input_tex, vec2<i32>(id.xy), 0) * weights[0];
for (var i: i32 = 1; i < 5; i++) {
let offset = params.direction * f32(i);
// сэмпл в +offset и -offset
result += textureLoad(input_tex, coord1, 0) * weights[i];
result += textureLoad(input_tex, coord2, 0) * weights[i];
}Направление передаётся через uniform-буфер: (1/width, 0) для горизонтального, (0, 1/height) для вертикального. Два прохода используют один и тот же шейдер — разница только в bind group.
Ping-pong между двумя текстурами
Два прохода размытия чередуются между двумя текстурами:
| Pass | Input | Output |
|---|---|---|
| Bright extraction | scene | bright |
| H-blur | bright | blur |
| V-blur | blur | bright |
После V-blur результат оказывается в текстуре bright. Это позволяет не создавать третью текстуру.
Composite
Финальный полноэкранный квад складывает оригинальную сцену и bloom:
let combined = scene.rgb + bloom.rgb;
let mapped = aces(combined);
return vec4<f32>(mapped, 1.0);Аддитивное сложение (+) — ключевой момент: bloom не заменяет сцену, а добавляет свечение. Tone mapping (ACES) сжимает результат для вывода на монитор.
Uniform-буферы для параметров
Bright extraction и blur используют uniform-буферы для передачи параметров. С encase не нужно считать паддинг вручную — ShaderType автоматически выравнивает поля по правилам WGSL:
#[derive(ShaderType)]
struct BrightParams { threshold: f32 }
#[derive(ShaderType)]
struct BlurParams { direction: glam::Vec2 }Обе структуры благодаря uniform-выравниванию WGSL занимают в буфере 16 байт, хотя полезных данных в них меньше. encase::UniformBuffer записывает байты корректно:
let mut d = encase::UniformBuffer::new(Vec::new());
d.write(&BlurParams { direction: glam::Vec2::new(1.0 / width, 0.0) }).unwrap();
ctx.queue.write_buffer(&blur_params_ub, 0, &d.into_inner());Два bind group для blur создаются с разными направлениями:
// Horizontal: bright → blur
let hblur_bg = ... direction: [1.0/width, 0.0] ...
// Vertical: blur → bright
let vblur_bg = ... direction: [0.0, 1.0/height] ...Пять проходов в render
Порядок в render():
- Render pass: сцена →
scene_texture(HDR) - Compute pass: bright extraction →
bright_texture - Compute pass: H-blur:
bright→blur - Compute pass: V-blur:
blur→bright - Render pass: composite + ACES → screen
Все пять проходов записываются в один CommandEncoder и выполняются GPU последовательно.
Типичные ошибки
Забыли tone mapping
Без ACES аддитивное сложение scene + bloom легко даёт значения
Используют одну текстуру для input/output
Compute-шейдер может читать и писать одну и ту же текстуру только с read_write доступом и соответствующим GPU feature. Проще использовать ping-pong.
Неправильные веса Gaussian
Веса должны суммироваться ≈ 1.0, иначе яркость изменится.
Порог 0.0
Всё изображение попадёт в bloom, и размытие «завалит» контраст. Начинайте с threshold = 1.0.
Что получилось
Ярко освещённые грани кубов испускают ореол света, «разливаясь» за свои границы. Bloom состоит из трёх этапов: выделение ярких пикселей, separable Gaussian blur (два 1D-прохода вместо одного 2D) и аддитивное наложение на оригинальную сцену. Вся обработка выполняется пятью GPU-проходами (2 render + 3 compute) в одном CommandEncoder. Tone mapping обязателен — без него аддитивное сложение HDR-значений даст белый экран.
Попробуем
- Изменить
thresholdна 0.5 — больше пикселей попадёт в bloom, свечение станет сильнее - Увеличить радиус blur (больше 4) — более размытое свечение
- Поставить
thresholdна 2.0 — только самые яркие участки будут светиться