From 5c1cc7ab079b80c2cd0e79e246ad0ca19121daf9 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 02:20:38 +0100
Subject: [PATCH] MONKEY (post): add world-only HDR bloom pass
Co-Authored-By: GPT-5.6 Sol (Codex)
---
crates/benilla-app/src/cvars.rs | 10 +-
crates/benilla-app/src/game_plugins.rs | 2 +
crates/benilla-app/src/lib.rs | 4 +-
crates/benilla-app/src/post/bloom.rs | 366 +++++++++++++++++++++++++
crates/benilla-app/src/post/bloom.wgsl | 68 +++++
crates/benilla-app/src/post/mod.rs | 11 +
crates/benilla-app/src/video.rs | 4 +-
crates/benilla-world/src/ffx_glow.rs | 3 +-
8 files changed, 463 insertions(+), 5 deletions(-)
create mode 100644 crates/benilla-app/src/post/bloom.rs
create mode 100644 crates/benilla-app/src/post/bloom.wgsl
create mode 100644 crates/benilla-app/src/post/mod.rs
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 66156e99..ff3c06ce 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -922,7 +922,7 @@ pub(crate) const REGISTERED: &[Registered] = &[
// MONKEY (volumetric fog): saved live tier; capture override stays session-only.
ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"),
// MONKEY (post): tier 0 leaves every emissive site and the frame byte-identical.
- ours("bloom", "0", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"),
+ ours("bloom", "2", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"),
ours(
"waterQuality",
"1",
@@ -2010,6 +2010,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("waterQuality", "0"),
// MONKEY (volumetric fog): Off preset restores the original atmosphere.
("volumetricFog", "0"),
+ // MONKEY (post): no HDR lift or post pass is the byte-identical baseline.
+ ("bloom", "0"),
("lavaLightGain", "0"),
("fireLightGain", "0"),
("nightGain", "1.0"),
@@ -2033,6 +2035,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("waterQuality", "1"),
// MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
("volumetricFog", "1"),
+ // MONKEY (post): quarter-resolution halo.
+ ("bloom", "1"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2059,6 +2063,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("waterQuality", "1"),
// MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
("volumetricFog", "1"),
+ // MONKEY (post): quarter-resolution halo.
+ ("bloom", "1"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2086,6 +2092,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[
("waterQuality", "1"),
// MONKEY (volumetric fog): preset atmosphere uses the default cheap tier.
("volumetricFog", "1"),
+ // MONKEY (post): half-resolution halo.
+ ("bloom", "2"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs
index adda66ec..34ae7853 100644
--- a/crates/benilla-app/src/game_plugins.rs
+++ b/crates/benilla-app/src/game_plugins.rs
@@ -245,6 +245,8 @@ impl PluginGroup for GamePlugins {
.add(crate::dynamic_interior::DynamicInteriorPlugin)
// MONKEY (volumetric fog): bridge the live setting to Bevy.
.add(crate::volumetric_fog::VolumetricFogPlugin)
+ // MONKEY (post): world-only HDR effects, before the legacy FFX clamp/UI composite.
+ .add(crate::post::PostPlugin)
// The realmlist (decision 1667) — the logon address the login screen edits. Same reason as
// VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`.
.add(crate::realmlist::RealmlistPlugin)
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index a7f9ac23..61e277a9 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -72,9 +72,11 @@ mod debug_panel;
mod dev;
mod doodad_events;
/// MONKEY (dynamic interiors): the cvar → benilla-world bridge for the fixture-lit interior lane.
+mod dynamic_interior;
// MONKEY (volumetric fog): optional near-field atmosphere plugin.
mod volumetric_fog;
-mod dynamic_interior;
+// MONKEY (post): optional world-only HDR post-processing.
+mod post;
mod entities;
mod fishing_line;
mod footprints;
diff --git a/crates/benilla-app/src/post/bloom.rs b/crates/benilla-app/src/post/bloom.rs
new file mode 100644
index 00000000..336388ea
--- /dev/null
+++ b/crates/benilla-app/src/post/bloom.rs
@@ -0,0 +1,366 @@
+//! MONKEY (post): HDR-excess bloom. This runs on the world view before FFXGlow clamps it;
+//! the later UI camera therefore never enters either the extraction or halo combine.
+
+use crate::video::VideoConfig;
+use benilla_world::{ffx_glow::FfxGlowLabel, view::WorldCamera};
+use bevy::{
+ core_pipeline::{
+ core_3d::graph::{Core3d, Node3d},
+ FullscreenShader,
+ },
+ ecs::query::QueryItem,
+ prelude::*,
+ render::{
+ camera::ExtractedCamera,
+ diagnostic::RecordDiagnostics,
+ extract_component::{ExtractComponent, ExtractComponentPlugin},
+ render_graph::{
+ NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
+ },
+ render_resource::{binding_types::*, *},
+ renderer::{RenderContext, RenderDevice, RenderQueue},
+ texture::{CachedTexture, TextureCache},
+ view::ViewTarget,
+ Render, RenderApp, RenderStartup, RenderSystems,
+ },
+};
+
+pub(super) struct BloomPlugin;
+
+#[derive(Component, Clone, Copy, ExtractComponent)]
+struct BloomView {
+ tier: u32,
+}
+
+impl Plugin for BloomPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_plugins(ExtractComponentPlugin::::default())
+ .add_systems(Last, update_views);
+ if !app.is_plugin_added::() {
+ return;
+ }
+ let shader = app
+ .world_mut()
+ .resource_mut::>()
+ .add(Shader::from_wgsl(
+ include_str!("bloom.wgsl"),
+ "post/bloom.wgsl",
+ ));
+ let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
+ return;
+ };
+ render_app
+ .insert_resource(BloomShader(shader))
+ .add_systems(RenderStartup, init_pipeline)
+ .add_systems(
+ Render,
+ prepare_textures.in_set(RenderSystems::PrepareResources),
+ )
+ .add_render_graph_node::>(Core3d, BloomLabel)
+ .add_render_graph_edges(
+ Core3d,
+ (
+ Node3d::StartMainPassPostProcessing,
+ BloomLabel,
+ FfxGlowLabel,
+ ),
+ );
+ }
+}
+
+fn update_views(
+ mut commands: Commands,
+ video: Res,
+ cameras: Query<(Entity, &Camera, Option<&BloomView>), With>,
+) {
+ let tier = u32::from(video.bloom.min(2));
+ for (entity, camera, old) in &cameras {
+ if tier == 0 || !camera.is_active {
+ if old.is_some() {
+ commands.entity(entity).remove::();
+ }
+ } else if old.is_none_or(|old| old.tier != tier) {
+ commands.entity(entity).insert(BloomView { tier });
+ }
+ }
+}
+
+#[derive(Resource)]
+struct BloomShader(Handle);
+
+#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
+pub(super) struct BloomLabel;
+
+#[derive(Resource)]
+struct BloomPipelines {
+ layout: BindGroupLayoutDescriptor,
+ sampler: Sampler,
+ extract: CachedRenderPipelineId,
+ blur_h: CachedRenderPipelineId,
+ blur_v: CachedRenderPipelineId,
+ combine: CachedRenderPipelineId,
+}
+
+fn init_pipeline(
+ mut commands: Commands,
+ render_device: Res,
+ fullscreen: Res,
+ shader: Res,
+ pipeline_cache: Res,
+) {
+ let layout = BindGroupLayoutDescriptor::new(
+ "post_bloom_layout",
+ &BindGroupLayoutEntries::sequential(
+ ShaderStages::FRAGMENT,
+ (
+ texture_2d(TextureSampleType::Float { filterable: true }),
+ sampler(SamplerBindingType::Filtering),
+ texture_2d(TextureSampleType::Float { filterable: true }),
+ uniform_buffer_sized(false, std::num::NonZeroU64::new(16)),
+ ),
+ ),
+ );
+ let sampler = render_device.create_sampler(&SamplerDescriptor {
+ min_filter: FilterMode::Linear,
+ mag_filter: FilterMode::Linear,
+ address_mode_u: AddressMode::ClampToEdge,
+ address_mode_v: AddressMode::ClampToEdge,
+ ..default()
+ });
+ let pipeline = |label: &'static str, entry: &'static str| RenderPipelineDescriptor {
+ label: Some(label.into()),
+ layout: vec![layout.clone()],
+ vertex: fullscreen.to_vertex_state(),
+ fragment: Some(FragmentState {
+ shader: shader.0.clone(),
+ shader_defs: vec![],
+ entry_point: Some(entry.into()),
+ targets: vec![Some(ColorTargetState {
+ format: ViewTarget::TEXTURE_FORMAT_HDR,
+ blend: None,
+ write_mask: ColorWrites::ALL,
+ })],
+ }),
+ ..default()
+ };
+ let extract =
+ pipeline_cache.queue_render_pipeline(pipeline("post_bloom_extract", "fs_extract"));
+ let blur_h = pipeline_cache.queue_render_pipeline(pipeline("post_bloom_blur_h", "fs_blur_h"));
+ let blur_v = pipeline_cache.queue_render_pipeline(pipeline("post_bloom_blur_v", "fs_blur_v"));
+ let combine =
+ pipeline_cache.queue_render_pipeline(pipeline("post_bloom_combine", "fs_combine"));
+ commands.insert_resource(BloomPipelines {
+ layout,
+ sampler,
+ extract,
+ blur_h,
+ blur_v,
+ combine,
+ });
+}
+
+#[derive(Component)]
+struct BloomTextures {
+ a: CachedTexture,
+ b: CachedTexture,
+ uniform: Buffer,
+ bind_ab: BindGroup,
+ bind_ba: BindGroup,
+ tier: u32,
+}
+
+fn prepare_textures(
+ mut commands: Commands,
+ mut cache: ResMut,
+ render_device: Res,
+ pipeline_cache: Res,
+ pipelines: Res,
+ views: Query<(Entity, &ExtractedCamera, &BloomView, Option<&BloomTextures>)>,
+) {
+ let layout = pipeline_cache.get_bind_group_layout(&pipelines.layout);
+ for (entity, camera, view, old) in &views {
+ let Some(size) = camera.physical_viewport_size else {
+ continue;
+ };
+ let divisor = if view.tier == 1 { 4 } else { 2 };
+ let mut texture = |label: &'static str, width: u32, height: u32| {
+ cache.get(
+ &render_device,
+ TextureDescriptor {
+ label: Some(label),
+ size: Extent3d {
+ width: width.max(8),
+ height: height.max(8),
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: TextureDimension::D2,
+ format: ViewTarget::TEXTURE_FORMAT_HDR,
+ usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING,
+ view_formats: &[],
+ },
+ )
+ };
+ let a = texture("post_bloom_a", size.x / divisor, size.y / divisor);
+ let b = texture("post_bloom_b", size.x / divisor, size.y / divisor);
+ if old.is_some_and(|old| {
+ old.tier == view.tier
+ && old.a.texture.id() == a.texture.id()
+ && old.b.texture.id() == b.texture.id()
+ }) {
+ continue;
+ }
+ let uniform = render_device.create_buffer(&BufferDescriptor {
+ label: Some("post_bloom_uniform"),
+ size: 16,
+ usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ let bind = |label, source: &TextureView, auxiliary: &TextureView| {
+ render_device.create_bind_group(
+ label,
+ &layout,
+ &BindGroupEntries::sequential((
+ source,
+ &pipelines.sampler,
+ auxiliary,
+ uniform.as_entire_binding(),
+ )),
+ )
+ };
+ let bind_ab = bind("post_bloom_a_to_b", &a.default_view, &a.default_view);
+ let bind_ba = bind("post_bloom_b_to_a", &b.default_view, &b.default_view);
+ commands.entity(entity).insert(BloomTextures {
+ a,
+ b,
+ uniform,
+ bind_ab,
+ bind_ba,
+ tier: view.tier,
+ });
+ }
+}
+
+#[derive(Default)]
+struct BloomNode;
+
+impl ViewNode for BloomNode {
+ type ViewQuery = (
+ &'static ViewTarget,
+ &'static BloomView,
+ &'static BloomTextures,
+ );
+
+ fn run<'w>(
+ &self,
+ _graph: &mut RenderGraphContext,
+ render_context: &mut RenderContext<'w>,
+ (target, view, textures): QueryItem<'w, '_, Self::ViewQuery>,
+ world: &'w World,
+ ) -> Result<(), NodeRunError> {
+ let pipelines = world.resource::();
+ let cache = world.resource::();
+ let (Some(extract), Some(blur_h), Some(blur_v), Some(combine)) = (
+ cache.get_render_pipeline(pipelines.extract),
+ cache.get_render_pipeline(pipelines.blur_h),
+ cache.get_render_pipeline(pipelines.blur_v),
+ cache.get_render_pipeline(pipelines.combine),
+ ) else {
+ return Ok(());
+ };
+ let gain = if view.tier == 1 { 0.55 } else { 0.78 };
+ let params = [view.tier as f32, gain, 0.0, 0.0];
+ world.resource::().write_buffer(
+ &textures.uniform,
+ 0,
+ bytemuck::cast_slice(¶ms),
+ );
+ let device = render_context.render_device().clone();
+ let layout = cache.get_bind_group_layout(&pipelines.layout);
+ let source = target.main_texture_view();
+ let extract_bind = device.create_bind_group(
+ "post_bloom_extract",
+ &layout,
+ &BindGroupEntries::sequential((
+ source,
+ &pipelines.sampler,
+ &textures.b.default_view,
+ textures.uniform.as_entire_binding(),
+ )),
+ );
+ let diagnostics = render_context.diagnostic_recorder();
+ for (label, pipeline, bind, destination) in [
+ (
+ "post_bloom_extract",
+ extract,
+ &extract_bind,
+ &textures.a.default_view,
+ ),
+ (
+ "post_bloom_blur_h",
+ blur_h,
+ &textures.bind_ab,
+ &textures.b.default_view,
+ ),
+ (
+ "post_bloom_blur_v",
+ blur_v,
+ &textures.bind_ba,
+ &textures.a.default_view,
+ ),
+ ] {
+ let mut pass =
+ render_context
+ .command_encoder()
+ .begin_render_pass(&RenderPassDescriptor {
+ label: Some(label),
+ color_attachments: &[Some(RenderPassColorAttachment {
+ view: destination,
+ depth_slice: None,
+ resolve_target: None,
+ ops: Operations::default(),
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ let span = diagnostics.pass_span(&mut pass, label);
+ pass.set_pipeline(pipeline);
+ pass.set_bind_group(0, bind, &[]);
+ pass.draw(0..3, 0..1);
+ span.end(&mut pass);
+ }
+ let output = target.post_process_write();
+ let combine_bind = device.create_bind_group(
+ "post_bloom_combine",
+ &layout,
+ &BindGroupEntries::sequential((
+ output.source,
+ &pipelines.sampler,
+ &textures.a.default_view,
+ textures.uniform.as_entire_binding(),
+ )),
+ );
+ let mut pass = render_context
+ .command_encoder()
+ .begin_render_pass(&RenderPassDescriptor {
+ label: Some("post_bloom_combine"),
+ color_attachments: &[Some(RenderPassColorAttachment {
+ view: output.destination,
+ depth_slice: None,
+ resolve_target: None,
+ ops: Operations::default(),
+ })],
+ depth_stencil_attachment: None,
+ timestamp_writes: None,
+ occlusion_query_set: None,
+ });
+ let span = diagnostics.pass_span(&mut pass, "post_bloom_combine");
+ pass.set_pipeline(combine);
+ pass.set_bind_group(0, &combine_bind, &[]);
+ pass.draw(0..3, 0..1);
+ span.end(&mut pass);
+ Ok(())
+ }
+}
diff --git a/crates/benilla-app/src/post/bloom.wgsl b/crates/benilla-app/src/post/bloom.wgsl
new file mode 100644
index 00000000..7313a1d4
--- /dev/null
+++ b/crates/benilla-app/src/post/bloom.wgsl
@@ -0,0 +1,68 @@
+// MONKEY (post): world-only HDR excess extraction, dual-filter blur, and halo composite.
+
+#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
+
+@group(0) @binding(0) var source_tex: texture_2d;
+@group(0) @binding(1) var linear_sampler: sampler;
+@group(0) @binding(2) var auxiliary_tex: texture_2d;
+
+struct BloomParams {
+ tier: f32,
+ gain: f32,
+ unused: vec2,
+};
+@group(0) @binding(3) var bloom: BloomParams;
+
+fn tap(tex: texture_2d, uv: vec2, offset: vec2) -> vec4 {
+ let size = vec2(textureDimensions(tex));
+ return textureSampleLevel(tex, linear_sampler, uv + offset / size, 0.0);
+}
+
+@fragment
+fn fs_extract(in: FullscreenVertexOutput) -> @location(0) vec4 {
+ // 13-tap downfilter: a broad, stable footprint when Low uses a quarter-size target.
+ var c = tap(source_tex, in.uv, vec2(0.0)) * 0.20;
+ c += tap(source_tex, in.uv, vec2( 1.0, 0.0)) * 0.10;
+ c += tap(source_tex, in.uv, vec2(-1.0, 0.0)) * 0.10;
+ c += tap(source_tex, in.uv, vec2( 0.0, 1.0)) * 0.10;
+ c += tap(source_tex, in.uv, vec2( 0.0, -1.0)) * 0.10;
+ c += tap(source_tex, in.uv, vec2( 1.0, 1.0)) * 0.06;
+ c += tap(source_tex, in.uv, vec2(-1.0, 1.0)) * 0.06;
+ c += tap(source_tex, in.uv, vec2( 1.0, -1.0)) * 0.06;
+ c += tap(source_tex, in.uv, vec2(-1.0, -1.0)) * 0.06;
+ c += tap(source_tex, in.uv, vec2( 2.0, 0.0)) * 0.04;
+ c += tap(source_tex, in.uv, vec2(-2.0, 0.0)) * 0.04;
+ c += tap(source_tex, in.uv, vec2( 0.0, 2.0)) * 0.04;
+ c += tap(source_tex, in.uv, vec2( 0.0, -2.0)) * 0.04;
+ let excess = max(c.rgb - vec3(1.0), vec3(0.0));
+ let knee = excess * excess / (excess + vec3(0.50));
+ // Frost-Nova/firework stacks can reach 5-10x; compress them without hard clipping.
+ let capped = vec3(5.0) * (vec3(1.0) - exp(-knee / vec3(5.0)));
+ return vec4(capped, 1.0);
+}
+
+fn blur_axis(uv: vec2, axis: vec2) -> vec4 {
+ var c = tap(source_tex, uv, vec2(0.0)) * 0.227027;
+ c += tap(source_tex, uv, axis * 1.384615) * 0.316216;
+ c += tap(source_tex, uv, axis * -1.384615) * 0.316216;
+ c += tap(source_tex, uv, axis * 3.230769) * 0.070270;
+ c += tap(source_tex, uv, axis * -3.230769) * 0.070270;
+ return c;
+}
+
+@fragment
+fn fs_blur_h(in: FullscreenVertexOutput) -> @location(0) vec4 {
+ return blur_axis(in.uv, vec2(1.0, 0.0));
+}
+
+@fragment
+fn fs_blur_v(in: FullscreenVertexOutput) -> @location(0) vec4 {
+ return blur_axis(in.uv, vec2(0.0, 1.0));
+}
+
+@fragment
+fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4 {
+ let scene = textureSample(source_tex, linear_sampler, in.uv);
+ let halo = textureSample(auxiliary_tex, linear_sampler, in.uv).rgb;
+ return vec4(scene.rgb + halo * bloom.gain, scene.a);
+}
diff --git a/crates/benilla-app/src/post/mod.rs b/crates/benilla-app/src/post/mod.rs
new file mode 100644
index 00000000..0fc62e25
--- /dev/null
+++ b/crates/benilla-app/src/post/mod.rs
@@ -0,0 +1,11 @@
+//! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp.
+
+mod bloom;
+
+pub(crate) struct PostPlugin;
+
+impl bevy::prelude::Plugin for PostPlugin {
+ fn build(&self, app: &mut bevy::prelude::App) {
+ app.add_plugins(bloom::BloomPlugin);
+ }
+}
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index 30a2b12f..adf4e587 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -469,8 +469,8 @@ impl Default for VideoConfig {
water_quality: 1,
// MONKEY (volumetric fog): default to the inexpensive atmosphere.
volumetric_fog: 1,
- // MONKEY (post): opt-in; the future High graphics preset selects tier 2.
- bloom: 0,
+ // MONKEY (post): the shipped High graphics preset uses the full-resolution tier.
+ bloom: 2,
lava_light_gain: 1.0,
fire_flicker: 1.0,
display: if windowed_env() {
diff --git a/crates/benilla-world/src/ffx_glow.rs b/crates/benilla-world/src/ffx_glow.rs
index 36e2b30e..c6f9ddf1 100644
--- a/crates/benilla-world/src/ffx_glow.rs
+++ b/crates/benilla-world/src/ffx_glow.rs
@@ -308,7 +308,8 @@ fn ensure_ffx_glow(
// ---------------------------------------------------------------- render world
#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]
-struct FfxGlowLabel;
+// MONKEY (post): world-only HDR passes explicitly order themselves before the legacy clamp.
+pub struct FfxGlowLabel;
/// The layouts, samplers, wave LUT and pipelines, built once at startup.
#[derive(Resource)]