From 42635eeeff5e84e186730348d1d4660d236bfab2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:42:29 +0000 Subject: [PATCH 1/5] MONKEY (polish): sun shafts march a half-resolution sky mask The radial blur read the scene depth 28 times per full-resolution pixel. A new mask pre-pass (`fs_mask`, R8Unorm at ceil(w/2) x ceil(h/2)) loads the depth once per half-res texel and thresholds it with the same sky test (depth <= 1e-7 on infinite reverse-Z); the blur then takes 28 clamp-to-edge bilinear taps of the mask. Only the mask stage is MSAA-specialised (single and multisampled depth layouts, one pipeline each); the blur pipeline is keyed by the target format alone. Verified: cargo check -p benilla; all four WGSL variants (blur, blur + MULTISAMPLED, SHAFT_MASK, SHAFT_MASK + MULTISAMPLED) compose and validate under naga_oil 0.20 / naga 27 offline. No GPU or WoW data in this environment, so the image is not captured; built on Linux only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6yUWgPosSakvL23gtgvpH --- crates/benilla-app/src/post/sun_shafts.rs | 182 ++++++++++++++++---- crates/benilla-app/src/post/sun_shafts.wgsl | 49 +++--- 2 files changed, 172 insertions(+), 59 deletions(-) diff --git a/crates/benilla-app/src/post/sun_shafts.rs b/crates/benilla-app/src/post/sun_shafts.rs index f509fd48..04228740 100644 --- a/crates/benilla-app/src/post/sun_shafts.rs +++ b/crates/benilla-app/src/post/sun_shafts.rs @@ -1,4 +1,7 @@ //! MONKEY (post): screen-space sun shafts from the visible sky depth. +//! +//! MONKEY (polish): the depth is read once per half-resolution texel into an R8 sky mask +//! (`fs_mask`, the only MSAA-specialised stage); the 28-tap radial blur then samples the mask. use super::bloom::BloomLabel; use crate::video::VideoConfig; @@ -11,6 +14,7 @@ use bevy::{ ecs::query::QueryItem, prelude::*, render::{ + camera::ExtractedCamera, diagnostic::RecordDiagnostics, extract_component::{ExtractComponent, ExtractComponentPlugin}, render_graph::{ @@ -18,7 +22,8 @@ use bevy::{ }, render_resource::{binding_types::*, *}, renderer::{RenderContext, RenderDevice, RenderQueue}, - view::{Msaa, ViewDepthTexture, ViewTarget}, + texture::{CachedTexture, TextureCache}, + view::{ViewDepthTexture, ViewTarget}, Render, RenderApp, RenderStartup, RenderSystems, }, }; @@ -54,7 +59,13 @@ impl Plugin for SunShaftsPlugin { .insert_resource(ShaftShader(shader)) .init_resource::>() .add_systems(RenderStartup, init_pipeline) - .add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare)) + .add_systems( + Render, + ( + prepare_pipelines.in_set(RenderSystems::Prepare), + prepare_masks.in_set(RenderSystems::PrepareResources), + ), + ) .add_render_graph_node::>(Core3d, ShaftLabel) .add_render_graph_edges( Core3d, @@ -139,12 +150,21 @@ struct ShaftShader(Handle); #[derive(Resource)] struct ShaftPipeline { - layouts: [BindGroupLayoutDescriptor; 2], + /// The blur's layout; the mask's per MSAA (`[single, multisampled]`). + layout: BindGroupLayoutDescriptor, + mask_layouts: [BindGroupLayoutDescriptor; 2], + masks: [CachedRenderPipelineId; 2], shader: Handle, fullscreen: FullscreenShader, sampler: Sampler, } +/// The half-resolution sky mask the blur marches through. +const MASK_FORMAT: TextureFormat = TextureFormat::R8Unorm; + +#[derive(Component)] +struct ShaftMask(CachedTexture); + #[derive(Component)] struct ViewShaftPipeline(CachedRenderPipelineId); @@ -154,29 +174,61 @@ struct ShaftLabel; fn init_pipeline( mut commands: Commands, device: Res, + cache: Res, shader: Res, fullscreen: Res, ) { - let layouts = [false, true].map(|multisampled| { + let layout = BindGroupLayoutDescriptor::new( + "post_sun_shafts_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + texture_2d(TextureSampleType::Float { filterable: true }), + uniform_buffer::(false), + ), + ), + ); + let mask_layouts = [false, true].map(|multisampled| { BindGroupLayoutDescriptor::new( - "post_sun_shafts_layout", - &BindGroupLayoutEntries::sequential( + "post_sun_shafts_mask_layout", + &BindGroupLayoutEntries::single( ShaderStages::FRAGMENT, - ( - texture_2d(TextureSampleType::Float { filterable: true }), - sampler(SamplerBindingType::Filtering), - if multisampled { - texture_depth_2d_multisampled() - } else { - texture_depth_2d() - }, - uniform_buffer::(false), - ), + if multisampled { + texture_depth_2d_multisampled() + } else { + texture_depth_2d() + }, ), ) }); + let masks = [false, true].map(|multisampled| { + let mut shader_defs = vec!["SHAFT_MASK".into()]; + if multisampled { + shader_defs.push("MULTISAMPLED".into()); + } + cache.queue_render_pipeline(RenderPipelineDescriptor { + label: Some("post_sun_shafts_mask".into()), + layout: vec![mask_layouts[multisampled as usize].clone()], + vertex: fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: shader.0.clone(), + shader_defs, + entry_point: Some("fs_mask".into()), + targets: vec![Some(ColorTargetState { + format: MASK_FORMAT, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + ..default() + }) + }); commands.insert_resource(ShaftPipeline { - layouts, + layout, + mask_layouts, + masks, shader: shader.0.clone(), fullscreen: fullscreen.clone(), sampler: device.create_sampler(&SamplerDescriptor { @@ -190,19 +242,16 @@ fn init_pipeline( } impl SpecializedRenderPipeline for ShaftPipeline { - type Key = (TextureFormat, bool); + type Key = TextureFormat; - fn specialize(&self, (format, multisampled): Self::Key) -> RenderPipelineDescriptor { + fn specialize(&self, format: Self::Key) -> RenderPipelineDescriptor { RenderPipelineDescriptor { label: Some("post_sun_shafts".into()), - layout: vec![self.layouts[multisampled as usize].clone()], + layout: vec![self.layout.clone()], vertex: self.fullscreen.to_vertex_state(), fragment: Some(FragmentState { shader: self.shader.clone(), - shader_defs: multisampled - .then(|| "MULTISAMPLED".into()) - .into_iter() - .collect(), + shader_defs: vec![], entry_point: Some("fragment".into()), targets: vec![Some(ColorTargetState { format, @@ -220,18 +269,46 @@ fn prepare_pipelines( cache: Res, pipeline: Res, mut specialized: ResMut>, - views: Query<(Entity, &ViewTarget, &Msaa), With>, + views: Query<(Entity, &ViewTarget), With>, ) { - for (entity, target, msaa) in &views { - let id = specialized.specialize( - &cache, - &pipeline, - (target.main_texture_format(), msaa.samples() > 1), - ); + for (entity, target) in &views { + let id = specialized.specialize(&cache, &pipeline, target.main_texture_format()); commands.entity(entity).insert(ViewShaftPipeline(id)); } } +/// The half-size mask target, rounded up so an odd edge column still has a texel. +fn prepare_masks( + mut commands: Commands, + mut textures: ResMut, + device: Res, + views: Query<(Entity, &ExtractedCamera), With>, +) { + for (entity, camera) in &views { + let Some(size) = camera.physical_viewport_size else { + continue; + }; + let mask = textures.get( + &device, + TextureDescriptor { + label: Some("post_sun_shafts_mask"), + size: Extent3d { + width: size.x.div_ceil(2).max(1), + height: size.y.div_ceil(2).max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: MASK_FORMAT, + usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + ); + commands.entity(entity).insert(ShaftMask(mask)); + } +} + #[derive(Default)] struct ShaftNode; @@ -241,17 +318,23 @@ impl ViewNode for ShaftNode { &'static ViewDepthTexture, &'static ShaftView, &'static ViewShaftPipeline, + &'static ShaftMask, ); fn run<'w>( &self, _graph: &mut RenderGraphContext, context: &mut RenderContext<'w>, - (target, depth, shaft, id): QueryItem<'w, '_, Self::ViewQuery>, + (target, depth, shaft, id, mask): QueryItem<'w, '_, Self::ViewQuery>, world: &'w World, ) -> Result<(), NodeRunError> { let cache = world.resource::(); - let Some(pipeline) = cache.get_render_pipeline(id.0) else { + let settings = world.resource::(); + let multisampled = (depth.texture.sample_count() > 1) as usize; + let (Some(pipeline), Some(mask_pipeline)) = ( + cache.get_render_pipeline(id.0), + cache.get_render_pipeline(settings.masks[multisampled]), + ) else { return Ok(()); }; if !depth @@ -261,24 +344,47 @@ impl ViewNode for ShaftNode { { return Ok(()); } - let settings = world.resource::(); let device = context.render_device(); + let mask_bind = device.create_bind_group( + "post_sun_shafts_mask", + &cache.get_bind_group_layout(&settings.mask_layouts[multisampled]), + &BindGroupEntries::single(depth.view()), + ); let mut uniform = UniformBuffer::from(*shaft); uniform.write_buffer(device, world.resource::()); let out = target.post_process_write(); let bind = device.create_bind_group( "post_sun_shafts", - &cache.get_bind_group_layout( - &settings.layouts[(depth.texture.sample_count() > 1) as usize], - ), + &cache.get_bind_group_layout(&settings.layout), &BindGroupEntries::sequential(( out.source, &settings.sampler, - depth.view(), + &mask.0.default_view, uniform.binding().unwrap(), )), ); let diagnostics = context.diagnostic_recorder(); + { + let mut pass = context + .command_encoder() + .begin_render_pass(&RenderPassDescriptor { + label: Some("post_sun_shafts_mask"), + color_attachments: &[Some(RenderPassColorAttachment { + view: &mask.0.default_view, + 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_sun_shafts_mask"); + pass.set_pipeline(mask_pipeline); + pass.set_bind_group(0, &mask_bind, &[]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + } let mut pass = context .command_encoder() .begin_render_pass(&RenderPassDescriptor { diff --git a/crates/benilla-app/src/post/sun_shafts.wgsl b/crates/benilla-app/src/post/sun_shafts.wgsl index 2ddc1834..f7dec61f 100644 --- a/crates/benilla-app/src/post/sun_shafts.wgsl +++ b/crates/benilla-app/src/post/sun_shafts.wgsl @@ -1,14 +1,33 @@ // MONKEY (post): screen-space radial blur through sky pixels in the scene depth. +// MONKEY (polish): two passes. `SHAFT_MASK` reads the depth once per half-resolution texel into +// a sky mask; the blur then samples that mask 28 times instead of the full-size depth. #import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput +#ifdef SHAFT_MASK + +#ifdef MULTISAMPLED +@group(0) @binding(0) var depth: texture_depth_multisampled_2d; +#else +@group(0) @binding(0) var depth: texture_depth_2d; +#endif + +@fragment +fn fs_mask(in: FullscreenVertexOutput) -> @location(0) vec4 { + let dims = vec2(textureDimensions(depth)); + let p = min(vec2(in.position.xy) * 2, dims - vec2(1)); + // MONKEY (fix-post): one sample, not a max over all of them: the march is a blur. + // Only the cleared / sky-pinned depth (exactly 0 on infinite reverse-Z) is sky; a ramp on + // `near / z` would move with the live `nearclip` cvar and pour shafts through far terrain. + let sky = select(0.0, 1.0, textureLoad(depth, p, 0) <= 1.0e-7); + return vec4(sky, 0.0, 0.0, 1.0); +} + +#else + @group(0) @binding(0) var scene: texture_2d; @group(0) @binding(1) var scene_sampler: sampler; -#ifdef MULTISAMPLED -@group(0) @binding(2) var depth: texture_depth_multisampled_2d; -#else -@group(0) @binding(2) var depth: texture_depth_2d; -#endif +@group(0) @binding(2) var mask: texture_2d; struct Shafts { sun: vec4, @@ -16,18 +35,6 @@ struct Shafts { }; @group(0) @binding(3) var shafts: Shafts; -fn scene_depth(uv: vec2) -> f32 { - let dims = textureDimensions(depth); - let p = vec2(clamp(uv, vec2(0.0), vec2(0.999999)) * vec2(dims)); -#ifdef MULTISAMPLED - // MONKEY (fix-post): one sample, not a max over all of them: the march is a blur, and the - // per-sample loop cost 28 x N loads per pixel (224 at 8x). - return textureLoad(depth, p, 0); -#else - return textureLoad(depth, p, 0); -#endif -} - @fragment fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { let base = textureSample(scene, scene_sampler, in.uv); @@ -41,10 +48,8 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { if (i >= count) { break; } uv += step_uv; decay *= 0.965; - // MONKEY (fix-post): only the cleared / sky-pinned depth (exactly 0 on infinite reverse-Z) - // is sky. The old ramp read d = near / z, so it moved with the live `nearclip` cvar and - // let shafts pour through terrain beyond ~125 yd at nearclip 0.01. - let sky = select(0.0, 1.0, scene_depth(uv) <= 1.0e-7); + // The clamp-to-edge bilinear tap is the old clamped depth read, pre-thresholded. + let sky = textureSampleLevel(mask, scene_sampler, uv, 0.0).r; light += sky * decay; norm += decay; } @@ -56,3 +61,5 @@ fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { let rays = light / max(norm, 0.0001) * radial * radial; return vec4(base.rgb + shafts.color.rgb * rays * shafts.sun.z * 0.16, base.a); } + +#endif From 78d7e94433e81ee1befd3359f85793e64db5bc7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 17:46:00 +0000 Subject: [PATCH 2/5] MONKEY (polish): per-view post uniforms written at prepare, not allocated per frame Colour grading and sun shafts built a fresh `UniformBuffer` (a new GPU buffer) inside their render-graph node every frame. Each view now owns one small UNIFORM | COPY_DST buffer, created the first frame the effect is on and rewritten in place with `RenderQueue::write_buffer` in `RenderSystems::PrepareResources`, the same shape as bloom's prepare-time uniform. The node only binds it. The pass still gates on the extracted view component, so the kept buffer never runs a disabled pass. Verified: cargo check -p benilla-app; rustfmt clean on both files. No GPU here; built on Linux only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6yUWgPosSakvL23gtgvpH --- crates/benilla-app/src/post/grading.rs | 46 +++++++++++++++++++---- crates/benilla-app/src/post/sun_shafts.rs | 32 ++++++++++++---- 2 files changed, 63 insertions(+), 15 deletions(-) diff --git a/crates/benilla-app/src/post/grading.rs b/crates/benilla-app/src/post/grading.rs index 5a4dc42a..b3fd694d 100644 --- a/crates/benilla-app/src/post/grading.rs +++ b/crates/benilla-app/src/post/grading.rs @@ -25,7 +25,7 @@ use bevy::{ renderer::{RenderContext, RenderDevice, RenderQueue}, texture::GpuImage, view::ViewTarget, - RenderApp, RenderStartup, + Render, RenderApp, RenderStartup, RenderSystems, }, }; use std::collections::{HashMap, HashSet}; @@ -73,6 +73,10 @@ impl Plugin for GradingPlugin { render_app .insert_resource(GradeShader(shader)) .add_systems(RenderStartup, init_pipeline) + .add_systems( + Render, + prepare_uniforms.in_set(RenderSystems::PrepareResources), + ) .add_render_graph_node::>(Core3d, GradeLabel) .add_render_graph_edges( Core3d, @@ -344,17 +348,47 @@ fn init_pipeline( }); } +/// MONKEY (polish): the view's uniform, created once and rewritten in prepare, like bloom's. +#[derive(Component)] +struct GradeUniformBuffer(Buffer); + +fn prepare_uniforms( + mut commands: Commands, + device: Res, + queue: Res, + views: Query<(Entity, &GradeView, Option<&GradeUniformBuffer>)>, +) { + for (entity, grade, buffer) in &views { + let rows = [grade.control.to_array()]; + match buffer { + Some(buffer) => queue.write_buffer(&buffer.0, 0, bytemuck::cast_slice(&rows)), + None => { + let buffer = device.create_buffer_with_data(&BufferInitDescriptor { + label: Some("post_grading_uniform"), + contents: bytemuck::cast_slice(&rows), + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + }); + commands.entity(entity).insert(GradeUniformBuffer(buffer)); + } + } + } +} + #[derive(Default)] struct GradeNode; impl ViewNode for GradeNode { - type ViewQuery = (&'static ViewTarget, &'static GradeView); + type ViewQuery = ( + &'static ViewTarget, + &'static GradeView, + &'static GradeUniformBuffer, + ); fn run<'w>( &self, _graph: &mut RenderGraphContext, context: &mut RenderContext<'w>, - (target, grade): QueryItem<'w, '_, Self::ViewQuery>, + (target, grade, uniform): QueryItem<'w, '_, Self::ViewQuery>, world: &'w World, ) -> Result<(), NodeRunError> { let settings = world.resource::(); @@ -367,10 +401,6 @@ impl ViewNode for GradeNode { return Ok(()); }; let device = context.render_device(); - let mut uniform = UniformBuffer::from(GradeUniform { - control: grade.control, - }); - uniform.write_buffer(device, world.resource::()); let out = target.post_process_write(); let bind = device.create_bind_group( "post_grading", @@ -381,7 +411,7 @@ impl ViewNode for GradeNode { &day.texture_view, &night.texture_view, &settings.lut_sampler, - uniform.binding().unwrap(), + uniform.0.as_entire_binding(), )), ); let diagnostics = context.diagnostic_recorder(); diff --git a/crates/benilla-app/src/post/sun_shafts.rs b/crates/benilla-app/src/post/sun_shafts.rs index 04228740..b82420d1 100644 --- a/crates/benilla-app/src/post/sun_shafts.rs +++ b/crates/benilla-app/src/post/sun_shafts.rs @@ -165,6 +165,10 @@ const MASK_FORMAT: TextureFormat = TextureFormat::R8Unorm; #[derive(Component)] struct ShaftMask(CachedTexture); +/// MONKEY (polish): the view's uniform, created once and rewritten in prepare, like bloom's. +#[derive(Component)] +struct ShaftUniform(Buffer); + #[derive(Component)] struct ViewShaftPipeline(CachedRenderPipelineId); @@ -277,17 +281,31 @@ fn prepare_pipelines( } } -/// The half-size mask target, rounded up so an odd edge column still has a texel. +/// The half-size mask target, rounded up so an odd edge column still has a texel, and the +/// view's uniform, written in place. fn prepare_masks( mut commands: Commands, mut textures: ResMut, device: Res, - views: Query<(Entity, &ExtractedCamera), With>, + queue: Res, + views: Query<(Entity, &ExtractedCamera, &ShaftView, Option<&ShaftUniform>)>, ) { - for (entity, camera) in &views { + for (entity, camera, shaft, uniform) in &views { let Some(size) = camera.physical_viewport_size else { continue; }; + let rows = [shaft.sun.to_array(), shaft.color.to_array()]; + match uniform { + Some(uniform) => queue.write_buffer(&uniform.0, 0, bytemuck::cast_slice(&rows)), + None => { + let buffer = device.create_buffer_with_data(&BufferInitDescriptor { + label: Some("post_sun_shafts_uniform"), + contents: bytemuck::cast_slice(&rows), + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + }); + commands.entity(entity).insert(ShaftUniform(buffer)); + } + } let mask = textures.get( &device, TextureDescriptor { @@ -316,16 +334,18 @@ impl ViewNode for ShaftNode { type ViewQuery = ( &'static ViewTarget, &'static ViewDepthTexture, + // Gates the pass: the mask and uniform outlive a disabled frame. &'static ShaftView, &'static ViewShaftPipeline, &'static ShaftMask, + &'static ShaftUniform, ); fn run<'w>( &self, _graph: &mut RenderGraphContext, context: &mut RenderContext<'w>, - (target, depth, shaft, id, mask): QueryItem<'w, '_, Self::ViewQuery>, + (target, depth, _, id, mask, uniform): QueryItem<'w, '_, Self::ViewQuery>, world: &'w World, ) -> Result<(), NodeRunError> { let cache = world.resource::(); @@ -350,8 +370,6 @@ impl ViewNode for ShaftNode { &cache.get_bind_group_layout(&settings.mask_layouts[multisampled]), &BindGroupEntries::single(depth.view()), ); - let mut uniform = UniformBuffer::from(*shaft); - uniform.write_buffer(device, world.resource::()); let out = target.post_process_write(); let bind = device.create_bind_group( "post_sun_shafts", @@ -360,7 +378,7 @@ impl ViewNode for ShaftNode { out.source, &settings.sampler, &mask.0.default_view, - uniform.binding().unwrap(), + uniform.0.as_entire_binding(), )), ); let diagnostics = context.diagnostic_recorder(); From 7d2f55a0147c50d02b8d903bf7cd525a43726162 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:38:38 +0000 Subject: [PATCH 3/5] MONKEY (polish): crossfade colour grading across zone borders The grade snapped to the new zone's LUT pair on the frame `CurrentArea` changed. `GradeBlend` now holds the current and previous pairs and fades between them over GRADE_FADE_S = 2 s: the view binds a second day/night pair (bindings 6, 7; identity when there is none) and `control.z` is the current pair's weight, `control.w` the previous pair's strength. The shader runs the previous pair through the same day/night blend at its own strength and mixes, only while z < 1. A zone with no row fades to identity (strength 0) instead of cutting; stepping back across the border mid-fade reverses the fade from the image on screen. The first graded zone after login, and a disabled grade, snap. Missing LUTs still bind the identity cube. Verified: cargo test -p benilla-app --lib post:: (4 passed, including the new zone_change_crossfades_and_a_quick_return_reverses); grading.wgsl composes and validates under naga_oil 0.20 / naga 27 offline. No GPU or WoW data here; built on Linux only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6yUWgPosSakvL23gtgvpH --- crates/benilla-app/src/post/grading.rs | 142 ++++++++++++++++++++++- crates/benilla-app/src/post/grading.wgsl | 14 ++- 2 files changed, 151 insertions(+), 5 deletions(-) diff --git a/crates/benilla-app/src/post/grading.rs b/crates/benilla-app/src/post/grading.rs index b3fd694d..7d2b28d6 100644 --- a/crates/benilla-app/src/post/grading.rs +++ b/crates/benilla-app/src/post/grading.rs @@ -43,10 +43,66 @@ struct GradeCatalog { struct GradeView { day: Handle, night: Handle, - // x = authored strength, y = night blend. + /// MONKEY (polish): the pair being faded out (identity once the fade is done). + prev_day: Handle, + prev_night: Handle, + // x = authored strength, y = night blend, z = crossfade weight of the current pair (1 = done), + // w = the previous pair's strength. control: Vec4, } +/// MONKEY (polish): the zone crossfade's length; a border crossing no longer snaps the grade. +const GRADE_FADE_S: f32 = 2.0; + +/// One zone's LUT pair at its authored strength; a zone with no row grades at strength 0. +#[derive(Clone, PartialEq, Debug)] +struct GradePair { + day: Handle, + night: Handle, + strength: f32, +} + +/// MONKEY (polish): the crossfade from the previous zone's pair to the current one. +#[derive(Resource, Default)] +struct GradeBlend { + current: Option, + previous: Option, + /// The current pair's weight, 0..1. + fade: f32, + primed: bool, +} + +impl GradeBlend { + /// Steps toward `target`. The first graded zone after login (or re-enabling) and a disabled + /// grade snap; a return to the + /// pair still fading out reverses the fade instead of restarting it. + fn step(&mut self, target: Option, dt: f32, snap: bool) { + if snap || !self.primed { + *self = Self { + primed: !snap && target.is_some(), + current: target, + previous: None, + fade: 1.0, + }; + return; + } + if target != self.current { + if self.fade < 1.0 && target == self.previous { + std::mem::swap(&mut self.current, &mut self.previous); + self.fade = 1.0 - self.fade; + } else { + self.previous = self.current.take(); + self.current = target; + self.fade = 0.0; + } + } + self.fade = (self.fade + dt / GRADE_FADE_S).min(1.0); + if self.fade >= 1.0 { + self.previous = None; + } + } +} + #[derive(Clone, Copy, ShaderType)] struct GradeUniform { control: Vec4, @@ -55,6 +111,7 @@ struct GradeUniform { impl Plugin for GradingPlugin { fn build(&self, app: &mut App) { app.add_plugins(ExtractComponentPlugin::::default()) + .init_resource::() .add_systems(Last, update_views); if !app.is_plugin_added::() { return; @@ -219,6 +276,7 @@ fn volume_image(cube: Vec) -> Image { ) } +#[allow(clippy::too_many_arguments)] fn update_views( mut commands: Commands, video: Res, @@ -226,6 +284,8 @@ fn update_views( area: Res, areas: Option>, time: Res, + clock: Res