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)]