From 709158a827c82a39e04930233501725101373a74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 12:35:04 +0100 Subject: [PATCH 1/4] renderer: mobile defaults for TAA and the sun shadow; one shadow lookup per pixel - temporal_upscale (set_temporal_upscale): when off, TAA resolves at the render resolution and the upscale pass scales its result (Unity URP's order) instead of TAA upscaling to the surface. Off on Android, where the surface-size TAA was most of the frame (Pixel 4a: 68 -> 21 ms). - set_shadow_filter_4tap: 4 hardware-PCF taps (a 3x3-texel tent, Unity URP's mobile soft-shadow filter) instead of the 16-tap Poisson disc. Default on Android (main pass -6 ms at 0.5 scale on a Pixel 4a). - The cascade transition picks one cascade per pixel with a 4x4 ordered dither (TAA averages it) instead of sampling both cascades and blending. The second lookup put a second copy of the filter in the scene shader: 12 ms of a 43 ms main pass on a Pixel 4a. All platforms. --- native/shared/src/renderer/light_api.rs | 8 +- native/shared/src/renderer/mod.rs | 52 +++- native/shared/src/renderer/postfx_chain.rs | 238 +++++++++--------- native/shared/src/renderer/shaders/core.rs | 91 +++---- .../shared/src/renderer/shaders/core_tests.rs | 19 +- native/shared/src/shadows.rs | 4 + native/shared/src/virtual_shadow_tests.rs | 13 +- native/shared/src/virtual_shadows.rs | 14 +- 8 files changed, 254 insertions(+), 185 deletions(-) diff --git a/native/shared/src/renderer/light_api.rs b/native/shared/src/renderer/light_api.rs index 500884a8..74ba9d7e 100644 --- a/native/shared/src/renderer/light_api.rs +++ b/native/shared/src/renderer/light_api.rs @@ -193,7 +193,13 @@ impl Renderer { } else { 0.0 }; - self.lighting_uniforms.dir_light_count = [0.0, shadows_flag, ssr_share, 0.0]; + // dir_light_count.w selects the sun shadow's 4-tap filter. + let filter_4tap = if self.shadow_map.filter_4tap { + 1.0 + } else { + 0.0 + }; + self.lighting_uniforms.dir_light_count = [0.0, shadows_flag, ssr_share, filter_4tap]; self.lighting_uniforms.point_light_count = [0.0; 4]; self.shadow_map.virtual_map.clear_local_requests(); } diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index b55f891a..4c94ddff 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -802,6 +802,14 @@ pub struct Renderer { /// 1 = TAA on (default). When off the renderer behaves exactly /// as the pre-TAA pipeline did. pub taa_enabled: bool, + /// Whether TAA also upscales when the render scale is below 1 (a + /// temporal upscaler: render-resolution input, history and output at the + /// surface's size). Off, TAA resolves at the render resolution and the + /// upscale pass scales its result, the order mobile renderers use (Unity + /// URP): the temporal pass then costs render_scale² as much, which on a + /// phone's 2-3x density display is most of the frame. Default off on + /// Android, on elsewhere. See `set_temporal_upscale`. + temporal_upscale: bool, /// Perspective/orthographic clip planes for begin_mode_3d. Defaults keep /// the historical 0.01..1000; a scene viewed from ~15 units away needs a /// larger near plane, or surfaces a millimetre apart z-fight. @@ -1341,7 +1349,8 @@ pub struct Renderer { pub upscale_pipeline: LazyRenderPipeline, pub upscale_layout: wgpu::BindGroupLayout, pub upscale_uniform_buffer: wgpu::Buffer, - upscale_bind_group_cache: Option, + /// The upscale pass's bind groups by source: composed, TAA 0, TAA 1. + upscale_bind_group_cache: [Option; 3], /// 0 = bilinear (cheap), 1 = Catmull-Rom 5-tap (sharper edges). /// Default 1. pub upscale_mode: u32, @@ -8285,6 +8294,7 @@ impl Renderer { taa_reactive_bind_group_cache_keys: [None, None], taa_frame_index: 0, taa_enabled: true, + temporal_upscale: !cfg!(target_os = "android"), camera_near: 0.01, camera_far: 1000.0, taa_history_valid: false, @@ -8538,7 +8548,7 @@ impl Renderer { upscale_pipeline, upscale_layout, upscale_uniform_buffer, - upscale_bind_group_cache: None, + upscale_bind_group_cache: [None, None, None], upscale_mode: 1, // Catmull-Rom by default cas_rt_texture, cas_rt_view, @@ -8947,7 +8957,7 @@ impl Renderer { self.taa_bind_group_cache = [None, None]; self.taa_reactive_bind_group_cache = [None, None]; self.taa_reactive_bind_group_cache_keys = [None, None]; - self.upscale_bind_group_cache = None; + self.upscale_bind_group_cache = [None, None, None]; self.dof_bind_group_cache = std::array::from_fn(|_| None); self.motion_blur_bind_group_cache = std::array::from_fn(|_| None); self.sss_bind_group_cache = std::array::from_fn(|_| None); @@ -9001,13 +9011,19 @@ impl Renderer { self.hiz_views = hiz_v; #[cfg(feature = "models3d")] self.invalidate_registered_virtual_hiz(true); - // TAA history/output live at SURFACE size — the TAA pass is - // also the TSR upscaler (render res in, output res out). - let (taa_t, taa_v) = create_taa_textures(&self.device, width, height); + // TAA history/output live at SURFACE size when the TAA pass is + // also the TSR upscaler (render res in, output res out), else at + // the render resolution (see taa_upscales). + let (taa_w, taa_h) = if self.taa_upscales() { + (width, height) + } else { + (rw, rh) + }; + let (taa_t, taa_v) = create_taa_textures(&self.device, taa_w, taa_h); self.taa_textures = taa_t; self.taa_views = taa_v; let (taa_depth_t, taa_depth_v) = - create_taa_depth_history_textures(&self.device, width, height); + create_taa_depth_history_textures(&self.device, taa_w, taa_h); self.taa_depth_history_textures = taa_depth_t; self.taa_depth_history_views = taa_depth_v; self.taa_current_idx = 0; @@ -9316,6 +9332,21 @@ impl Renderer { } } + /// Whether TAA upscales to the surface's size (see `temporal_upscale`) or + /// resolves at the render resolution before the upscale pass. Triggers a + /// resize, since TAA's history changes size. + pub fn set_temporal_upscale(&mut self, enabled: bool) { + if enabled != self.temporal_upscale { + self.temporal_upscale = enabled; + self.taa_current_idx = 0; + self.taa_frame_index = 0; + self.taa_history_valid = false; + self.taa_history_written = false; + let (w, h) = (self.native_width, self.native_height); + self.resize(w, h, self.logical_width, self.logical_height); + } + } + /// Set the render-resolution multiplier explicitly. Clamped to /// [0.15, 1.0]. Triggers a resize so render-res intermediates /// pick up the new extent. @@ -11022,6 +11053,13 @@ impl Renderer { } } + /// The sun shadow's filter: `true` takes 4 hardware-PCF taps (a + /// 3x3-texel tent: the filter mobile renderers default to, a quarter of + /// the cost), `false` the 16-tap Poisson disc. Default 4-tap on Android. + pub fn set_shadow_filter_4tap(&mut self, on: bool) { + self.shadow_map.filter_4tap = on; + } + /// Disables the ticket-004 shadow-cache and re-renders cascades /// every frame. Useful for games that mutate lighting state from /// places the cache invalidation path doesn't cover (e.g. day/ diff --git a/native/shared/src/renderer/postfx_chain.rs b/native/shared/src/renderer/postfx_chain.rs index 6a7f1302..bd87c076 100644 --- a/native/shared/src/renderer/postfx_chain.rs +++ b/native/shared/src/renderer/postfx_chain.rs @@ -777,70 +777,11 @@ impl Renderer { encoder: &mut wgpu::CommandEncoder, profiler: &mut crate::profiler::Profiler, ) { - // ============================================================ - // Upscale pass: render-res composed_rt → full-surface upscale_rt. - // Engages only when render_scale < 1.0 AND TAA is off — when - // TAA runs it does its own Catmull-Rom upscale. Downstream - // post-FX (DoF/MB/SSS/composite) read upscale_rt instead of - // hdr_rt in this case so the chain operates at full surface - // resolution. - // ============================================================ + // Upscale pass, TAA off: render-res composed_rt → full-surface + // upscale_rt. Downstream post-FX (DoF/MB/SSS/composite) read + // upscale_rt so the chain operates at full surface resolution. if self.render_scale < 0.999 && !self.taa_enabled { - let up = UpscaleParams { - params: [self.upscale_mode as f32, 0.0, 0.0, 0.0], - }; - self.queue - .write_buffer(&self.upscale_uniform_buffer, 0, bytemuck::bytes_of(&up)); - if self.upscale_bind_group_cache.is_none() { - self.frame_resource_stats - .created_bind_group(frame_resource_stats::BindGroupCreationSite::Upscale); - self.upscale_bind_group_cache = - Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("upscale_bg"), - layout: &self.upscale_layout, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: self.upscale_uniform_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView( - &self.composed_rt_view, - ), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: wgpu::BindingResource::Sampler(&self.composite_sampler), - }, - ], - })); - } - let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("upscale_pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &self.upscale_rt_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }); - pass.set_pipeline(&self.upscale_pipeline); - pass.set_bind_group( - 0, - self.upscale_bind_group_cache - .as_ref() - .expect("upscale bind group was initialized"), - &[], - ); - pass.draw(0..3, 0..1); + self.record_upscale(encoder, PostFxSource::Composed); } // ============================================================ @@ -865,12 +806,14 @@ impl Renderer { // Effective temporal window scales with per-pixel sample // density (~render_scale²). At 0.5 → 0.0625 (~16-frame // window, close to the prior 0.05/20-frame); at 1.0 → - // 0.10 (~10-frame), matching native TAA. - let alpha = taa_current_weight( - self.taa_history_valid, - self.taa_frame_index, - self.render_scale, - ); + // 0.10 (~10-frame), matching native TAA. Resolving at the + // render resolution, TAA is native to its input: scale 1. + let taa_scale = if self.taa_upscales() { + self.render_scale + } else { + 1.0 + }; + let alpha = taa_current_weight(self.taa_history_valid, self.taa_frame_index, taa_scale); // Pack authored camera motion into the otherwise-unused sign of // the positive TAA weight. This lets history reconstruction keep // its exact settled lookup while selecting a sharper fractional @@ -893,9 +836,9 @@ impl Renderer { -0.5 * self.current_jitter_ndc[0], 0.5 * self.current_jitter_ndc[1], if self.current_proj_matrix[3][3].abs() < 0.5 { - self.render_scale + taa_scale } else { - -self.render_scale + -taa_scale }, ], // Match the already-qualified path-tracing inverse upload. @@ -1135,23 +1078,21 @@ impl Renderer { } self.taa_history_valid = true; self.taa_history_written = true; + if self.render_scale < 0.999 && !self.taa_upscales() { + let source = if taa_dst_idx == 0 { + PostFxSource::Taa0 + } else { + PostFxSource::Taa1 + }; + self.record_upscale(encoder, source); + } } // ============================================================ // DoF pass: variable-radius Poisson disc blur driven by CoC // Reads TAA output / upscale_rt / hdr_rt + depth → dof_rt // ============================================================ - let pre_dof_source = if self.taa_enabled { - if taa_dst_idx == 0 { - PostFxSource::Taa0 - } else { - PostFxSource::Taa1 - } - } else if self.render_scale < 0.999 { - PostFxSource::Upscale - } else { - PostFxSource::Hdr - }; + let pre_dof_source = self.resolved_postfx_source(taa_dst_idx, PostFxSource::Hdr); if self.dof_enabled && self.dof_aperture > 0.0 { let inv_proj = self.current_inv_proj_matrix; @@ -1233,16 +1174,8 @@ impl Renderer { // ============================================================ let pre_mblur_source = if self.dof_enabled && self.dof_aperture > 0.0 { PostFxSource::DepthOfField - } else if self.taa_enabled { - if taa_dst_idx == 0 { - PostFxSource::Taa0 - } else { - PostFxSource::Taa1 - } - } else if self.render_scale < 0.999 { - PostFxSource::Upscale } else { - PostFxSource::Hdr + self.resolved_postfx_source(taa_dst_idx, PostFxSource::Hdr) }; if self.motion_blur_enabled && self.motion_blur_strength > 0.0 { @@ -1332,16 +1265,8 @@ impl Renderer { PostFxSource::MotionBlur } else if self.dof_enabled && self.dof_aperture > 0.0 { PostFxSource::DepthOfField - } else if self.taa_enabled { - if taa_dst_idx == 0 { - PostFxSource::Taa0 - } else { - PostFxSource::Taa1 - } - } else if self.render_scale < 0.999 { - PostFxSource::Upscale } else { - PostFxSource::Hdr + self.resolved_postfx_source(taa_dst_idx, PostFxSource::Hdr) }; if self.sss_enabled && self.sss_strength > 0.0 { @@ -1424,18 +1349,8 @@ impl Renderer { PostFxSource::MotionBlur } else if self.dof_enabled && self.dof_aperture > 0.0 { PostFxSource::DepthOfField - } else if self.taa_enabled { - if taa_dst_idx == 0 { - PostFxSource::Taa0 - } else { - PostFxSource::Taa1 - } - } else if self.render_scale < 0.999 { - PostFxSource::Upscale } else { - // TAA off, native res: composed_rt is already full-surface - // and carries SSR / SSGI / bloom / fog / shafts. - PostFxSource::Composed + self.resolved_postfx_source(taa_dst_idx, PostFxSource::Composed) }; if self.cas_strength > 0.0 { @@ -1511,17 +1426,106 @@ impl Renderer { CompositeSource::MotionBlur } else if self.dof_enabled && self.dof_aperture > 0.0 { CompositeSource::DepthOfField + } else { + match self.resolved_postfx_source(self.taa_current_idx, PostFxSource::Composed) { + PostFxSource::Taa0 => CompositeSource::Taa0, + PostFxSource::Taa1 => CompositeSource::Taa1, + PostFxSource::Upscale => CompositeSource::Upscale, + _ => CompositeSource::Composed, + } + } + } + + /// Whether the TAA pass also upscales (`temporal_upscale` with a render + /// scale below 1): its history and output then live at the surface's + /// size. Otherwise it resolves at the render resolution and the upscale + /// pass scales its result. + pub(super) fn taa_upscales(&self) -> bool { + self.temporal_upscale && self.render_scale < 0.999 + } + + /// The output of the TAA/upscale stage, which the later post-FX stages + /// read: TAA's when it ran at the surface's size, the upscale pass's when + /// the render resolution is lower, else `native` (the render-resolution + /// scene, which is then the surface's size). + fn resolved_postfx_source(&self, taa_idx: usize, native: PostFxSource) -> PostFxSource { + if self.render_scale < 0.999 && !(self.taa_enabled && self.taa_upscales()) { + PostFxSource::Upscale } else if self.taa_enabled { - if self.taa_current_idx == 0 { - CompositeSource::Taa0 + if taa_idx == 0 { + PostFxSource::Taa0 } else { - CompositeSource::Taa1 + PostFxSource::Taa1 } - } else if self.render_scale < 0.999 { - CompositeSource::Upscale } else { - CompositeSource::Composed + native + } + } + + /// Upscale pass: a render-resolution `source` (the composed scene, or the + /// TAA output when TAA resolves at the render resolution) → the + /// full-surface upscale_rt, with the `upscale_mode` filter. + fn record_upscale(&mut self, encoder: &mut wgpu::CommandEncoder, source: PostFxSource) { + let up = UpscaleParams { + params: [self.upscale_mode as f32, 0.0, 0.0, 0.0], + }; + self.queue + .write_buffer(&self.upscale_uniform_buffer, 0, bytemuck::bytes_of(&up)); + let slot = match source { + PostFxSource::Taa0 => 1, + PostFxSource::Taa1 => 2, + _ => 0, + }; + if self.upscale_bind_group_cache[slot].is_none() { + self.frame_resource_stats + .created_bind_group(frame_resource_stats::BindGroupCreationSite::Upscale); + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("upscale_bg"), + layout: &self.upscale_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: self.upscale_uniform_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView( + self.postfx_source_view_for(source), + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.composite_sampler), + }, + ], + }); + self.upscale_bind_group_cache[slot] = Some(bind_group); } + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("upscale_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &self.upscale_rt_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.upscale_pipeline); + pass.set_bind_group( + 0, + self.upscale_bind_group_cache[slot] + .as_ref() + .expect("upscale bind group was initialized"), + &[], + ); + pass.draw(0..3, 0..1); } pub(super) fn composite_source_view_for(&self, source: CompositeSource) -> &wgpu::TextureView { diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index 7ef75b84..464febb0 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -466,8 +466,21 @@ fn pcf_poisson16(t: texture_depth_2d, uv: vec2, tap_step: vec2, depth_ return sum; } -// Sample a single cascade's shadow texture with 16-tap Poisson PCF. The cascade -// is selected once, not per tap. +// 4 hardware 2x2 comparisons half a texel off the centre: together a 3x3-texel +// tent. The mobile filter (Unity URP's Low soft shadows), a quarter of the +// Poisson disc's taps. +fn pcf_tent4(t: texture_depth_2d, uv: vec2, texel: vec2, depth_ref: f32) -> f32 { + let h = 0.5 * texel; + var sum = textureSampleCompareLevel(t, shadow_samp, uv + vec2(-h.x, -h.y), depth_ref); + sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(h.x, -h.y), depth_ref); + sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(-h.x, h.y), depth_ref); + sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(h.x, h.y), depth_ref); + return sum; +} + +// Sample a single cascade's shadow texture with 16-tap Poisson PCF, or the +// 4-tap tent when dir_light_count.w selects it (set_shadow_filter_4tap). The +// cascade is selected once, not per tap. fn sample_cascade(cascade: i32, shadow_uv: vec2, depth_ref: f32) -> f32 { var dims: vec2; if (cascade == 0) { @@ -484,6 +497,17 @@ fn sample_cascade(cascade: i32, shadow_uv: vec2, depth_ref: f32) -> f32 { // to 2-3m of fuzz, which reads as 'painted' rather than 'cast'. // The sun's real angular size gives a ~1m penumbra at typical // scene distances; r=1.0 roughly matches that. + if (lighting.dir_light_count.w > 0.5) { + var tent: f32; + if (cascade == 0) { + tent = pcf_tent4(shadow_tex_0, shadow_uv, texel, depth_ref); + } else if (cascade == 1) { + tent = pcf_tent4(shadow_tex_1, shadow_uv, texel, depth_ref); + } else { + tent = pcf_tent4(shadow_tex_2, shadow_uv, texel, depth_ref); + } + return tent * 0.25; + } let radius = 1.0; let tap_step = texel * radius; var sum: f32; @@ -499,9 +523,13 @@ fn sample_cascade(cascade: i32, shadow_uv: vec2, depth_ref: f32) -> f32 { // Cascaded shadow map sampling. Determines which cascade the fragment // belongs to based on its view-space depth, projects through that -// cascade's VP, and performs PCF. Blends between cascades at boundaries -// for smooth transitions. -fn sample_shadow(world_pos: vec3, geo_n: vec3) -> f32 { +// cascade's VP, and performs PCF. In the band before a cascade's far split, +// each pixel takes the next cascade with a probability rising toward the +// split (a 4x4 ordered dither at `frag_xy`, which TAA averages): a smooth +// transition with one lookup per pixel. Sampling both cascades and blending +// put a second copy of the filter in the scene shader, whose register cost +// was 12 ms of a 43 ms main pass on a mobile GPU (Pixel 4a). +fn sample_shadow(world_pos: vec3, geo_n: vec3, frag_xy: vec2) -> f32 { // shadows disabled → fully lit. dir_light_count.y carries the enabled // flag (splits.w is the TSR mip-LOD bias — do NOT gate on it); without // this gate the projection below runs through identity/stale cascade @@ -568,6 +596,20 @@ fn sample_shadow(world_pos: vec3, geo_n: vec3) -> f32 { } var recv_pos = world_pos + geo_n * (2.0 * fit_r / map_dim) * 1.5; + // The transition band: `recv_pos` was sized for it above, so both + // cascades see the same receiver. Keep this cascade where the next one + // doesn't cover the receiver. + if (cascade < 2 && offset_dist_to_edge < offset_blend_zone) { + let keep = offset_dist_to_edge / offset_blend_zone; + if (bayer4(u32(frag_xy.x), u32(frag_xy.y)) > keep) { + let next_clip = lighting.shadow_cascade_vps[cascade + 1] * vec4(recv_pos, 1.0); + let next_ndc = next_clip.xyz / next_clip.w; + if (all(abs(next_ndc.xy) <= vec2(1.0)) && next_ndc.z >= 0.0 && next_ndc.z <= 1.0) { + cascade = cascade + 1; + } + } + } + // Project through the selected cascade's VP. A retained translation-slack // fit can put a receiver (especially after its normal offset) just outside // the selected cascade even though the next, wider cascade covers it. The @@ -604,42 +646,7 @@ fn sample_shadow(world_pos: vec3, geo_n: vec3) -> f32 { let shadow_uv = vec2(light_ndc.x * 0.5 + 0.5, 1.0 - (light_ndc.y * 0.5 + 0.5)); let bias = 0.001; let depth_ref = light_ndc.z - bias; - let shadow_val = sample_cascade(cascade, shadow_uv, depth_ref); - - // Blend between cascades at boundary regions for smooth transitions. - // The blend zone is 10% of each cascade's range. - var split_near = 0.0; - var split_far = lighting.shadow_cascade_splits.x; - if (cascade == 1) { - split_near = lighting.shadow_cascade_splits.x; - split_far = lighting.shadow_cascade_splits.y; - } else if (cascade == 2) { - split_near = lighting.shadow_cascade_splits.y; - split_far = lighting.shadow_cascade_splits.z; - } - let blend_zone = (split_far - split_near) * 0.1; - let dist_to_edge = split_far - view_depth; - - if (dist_to_edge < blend_zone && cascade < 2) { - // In the blend zone: sample the next cascade too and lerp. - // `recv_pos` was smoothly sized for this transition above. Sampling - // both maps at that same world-space point prevents a double edge. - let next_cascade = cascade + 1; - let next_clip = lighting.shadow_cascade_vps[next_cascade] * vec4(recv_pos, 1.0); - let next_ndc = next_clip.xyz / next_clip.w; - // The next fitted slice may not cover the inner blend zone. Never - // turn its clamped edge texel into a moving, falsely-lit shadow gap. - if (any(abs(next_ndc.xy) > vec2(1.0)) || next_ndc.z < 0.0 || next_ndc.z > 1.0) { - return shadow_val; - } - let next_uv = vec2(next_ndc.x * 0.5 + 0.5, 1.0 - (next_ndc.y * 0.5 + 0.5)); - let next_depth_ref = next_ndc.z - bias; - let next_val = sample_cascade(next_cascade, next_uv, next_depth_ref); - let t = dist_to_edge / blend_zone; - return mix(next_val, shadow_val, t); - } - - return shadow_val; + return sample_cascade(cascade, shadow_uv, depth_ref); } // Evaluate a single directional light's PBR contribution. Returns @@ -1095,7 +1102,7 @@ fn shade_main_scene(in: VertexOutputScene, front_facing: bool) -> SceneOut { // offset — the mapped normal can point anywhere per-texel and would // dither the offset; the flipped foliage normal would push the sample // through the card. - let shadow_factor = sample_shadow(in.world_pos, normalize(in.normal)); + let shadow_factor = sample_shadow(in.world_pos, normalize(in.normal), in.clip_position.xy); // Never fully zero direct light — a 10% floor simulates // ambient bounce from surrounding surfaces and keeps shadows // from going pitch-black regardless of IBL intensity. diff --git a/native/shared/src/renderer/shaders/core_tests.rs b/native/shared/src/renderer/shaders/core_tests.rs index 93bd593f..fb1d0249 100644 --- a/native/shared/src/renderer/shaders/core_tests.rs +++ b/native/shared/src/renderer/shaders/core_tests.rs @@ -51,16 +51,25 @@ fn shadow_cascade_selection_matches_the_fitted_view_frustum_depth() { } #[test] -fn shadow_cascade_blend_never_samples_outside_the_next_fit() { - assert!(SCENE_SHADER.contains("if (any(abs(next_ndc.xy) > vec2(1.0)) || next_ndc.z < 0.0")); - assert!(SCENE_SHADER.contains("return shadow_val;")); +fn shadow_cascade_transition_takes_one_lookup_per_pixel() { + // An ordered dither picks one cascade in the band; no second lookup. + assert!(SCENE_SHADER.contains("bayer4(u32(frag_xy.x), u32(frag_xy.y)) > keep")); + assert!(!SCENE_SHADER.contains("let next_val = sample_cascade(")); + assert!(SCENE_SHADER.contains("return sample_cascade(cascade, shadow_uv, depth_ref);")); } #[test] -fn shadow_cascade_blend_uses_one_continuous_receiver_position() { +fn shadow_cascade_transition_never_samples_outside_the_next_fit() { + assert!(SCENE_SHADER.contains( + "if (all(abs(next_ndc.xy) <= vec2(1.0)) && next_ndc.z >= 0.0 && next_ndc.z <= 1.0) {" + )); +} + +#[test] +fn shadow_cascade_transition_uses_one_continuous_receiver_position() { assert!(SCENE_SHADER.contains("fit_r = mix(fit_r, next_fit_r, toward_next);")); assert!(SCENE_SHADER - .contains("lighting.shadow_cascade_vps[next_cascade] * vec4(recv_pos, 1.0)")); + .contains("lighting.shadow_cascade_vps[cascade + 1] * vec4(recv_pos, 1.0)")); assert!(!SCENE_SHADER.contains("let next_pos = world_pos + geo_n")); } diff --git a/native/shared/src/shadows.rs b/native/shared/src/shadows.rs index e4f61ec7..399169f1 100644 --- a/native/shared/src/shadows.rs +++ b/native/shared/src/shadows.rs @@ -333,6 +333,9 @@ pub struct ShadowMap { /// where the cache hit rate would be ~zero anyway. When true, every /// frame renders shadows; the cache is bypassed. pub always_fresh: bool, + /// The sun shadow's filter: 4 hardware-PCF taps (a 3x3-texel tent, the + /// mobile filter) instead of the 16-tap Poisson disc. Default on Android. + pub filter_4tap: bool, /// Cascade VPs that correspond to the contents currently stored in /// the depth textures. `None` before the first render. When the /// freshly-computed VPs match this byte-for-byte AND nothing else @@ -802,6 +805,7 @@ impl ShadowMap { enabled: false, dirty: true, always_fresh: false, + filter_4tap: cfg!(target_os = "android"), rendered_light_vps: None, rendered_light_dir: None, rendered_scene_version: 0, diff --git a/native/shared/src/virtual_shadow_tests.rs b/native/shared/src/virtual_shadow_tests.rs index 51d59b55..a5ef71b5 100644 --- a/native/shared/src/virtual_shadow_tests.rs +++ b/native/shared/src/virtual_shadow_tests.rs @@ -440,20 +440,19 @@ fn page_crop_preserves_depth() { } #[test] -fn scene_shader_variant_injects_bindings_and_both_cascade_samples() { +fn scene_shader_variant_injects_bindings_and_the_cascade_sample() { let source = r#" -let shadow_val = sample_cascade(cascade, shadow_uv, depth_ref); -fn sample_shadow(world_pos: vec3, geo_n: vec3) -> f32 { -let next_val = sample_cascade(next_cascade, next_uv, next_depth_ref); +fn sample_shadow(world_pos: vec3, geo_n: vec3, frag_xy: vec2) -> f32 { +return sample_cascade(cascade, shadow_uv, depth_ref); } "#; let variant = directional_scene_shader(source); assert!(variant.contains("@binding(13) var vsm_page_table")); assert!(variant.contains("@binding(14) var vsm_physical_pages")); assert!(variant.contains("@binding(15) var vsm_params")); - assert_eq!(variant.matches("sample_virtual_shadow(").count(), 3); - assert!(variant.contains("sample_virtual_shadow(cascade, recv_pos,")); - assert!(variant.contains("sample_virtual_shadow(next_cascade, recv_pos,")); + // the helper's definition and the one lookup + assert_eq!(variant.matches("sample_virtual_shadow(").count(), 2); + assert!(variant.contains("return sample_virtual_shadow(cascade, recv_pos,")); assert!(variant.contains("(vsm_params.words.x & 2u) == 0u")); assert!(variant.contains("level_vps: array, 3>")); assert!(!source.contains("vsm_page_table")); diff --git a/native/shared/src/virtual_shadows.rs b/native/shared/src/virtual_shadows.rs index f6f88e47..dbd2c37a 100644 --- a/native/shared/src/virtual_shadows.rs +++ b/native/shared/src/virtual_shadows.rs @@ -1424,7 +1424,8 @@ const DIRECTIONAL_VSM_SCENE_HELPER: &str = /// byte-for-byte unchanged when VSM is disabled, avoiding an extra branch or /// binding in the default renderer. pub(crate) fn directional_scene_shader(source: &str) -> String { - let helper_marker = "fn sample_shadow(world_pos: vec3, geo_n: vec3) -> f32 {"; + let helper_marker = + "fn sample_shadow(world_pos: vec3, geo_n: vec3, frag_xy: vec2) -> f32 {"; let helper_offset = source .find(helper_marker) .expect("scene shader missing sample_shadow marker"); @@ -1435,13 +1436,14 @@ pub(crate) fn directional_scene_shader(source: &str) -> String { output.push_str(&source[..helper_offset]); output.push_str(DIRECTIONAL_VSM_SCENE_HELPER); output.push_str(&source[helper_offset..]); - output = output.replace( - "let shadow_val = sample_cascade(cascade, shadow_uv, depth_ref);", - "let shadow_val = sample_virtual_shadow(cascade, recv_pos, shadow_uv, depth_ref);", + let cascade_lookup = "return sample_cascade(cascade, shadow_uv, depth_ref);"; + debug_assert!( + output.contains(cascade_lookup), + "VSM scene variant must replace the cascade lookup" ); output = output.replace( - "let next_val = sample_cascade(next_cascade, next_uv, next_depth_ref);", - "let next_val = sample_virtual_shadow(next_cascade, recv_pos, next_uv, next_depth_ref);", + cascade_lookup, + "return sample_virtual_shadow(cascade, recv_pos, shadow_uv, depth_ref);", ); let light_index = if source.contains("let light_index = cluster_indices[") { "light_index" From f74842d393d8318f4a60c5da01d0e541afaa2385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 13:07:36 +0100 Subject: [PATCH 2/4] shadows: the live cascades are the layers of one depth texture The scene shader samples them through one array view, the cascade being the layer: a pixel's cascade is a coordinate, not a branch between three textures. With the dithered transition, pixels of one quad pick different cascades, and sampling different textures in divergent branches lost those pixels' writes on Adreno (Pixel 4a: a dithered band of background colour across the transition). One code path is also one inlined filter instead of three. The layers keep their own 2D views (depth_views) for rendering, the static-cache copies and the consumers that bind cascades one by one. --- native/shared/src/renderer/lighting.rs | 35 ++---- native/shared/src/renderer/quality_capture.rs | 28 ++++- native/shared/src/renderer/queries.rs | 8 +- native/shared/src/renderer/shaders/core.rs | 92 ++++++---------- native/shared/src/renderer/shadow_pass.rs | 11 +- native/shared/src/shadows.rs | 103 +++++++++--------- 6 files changed, 134 insertions(+), 143 deletions(-) diff --git a/native/shared/src/renderer/lighting.rs b/native/shared/src/renderer/lighting.rs index 54daf4c8..98376525 100644 --- a/native/shared/src/renderer/lighting.rs +++ b/native/shared/src/renderer/lighting.rs @@ -25,11 +25,6 @@ pub(super) fn create_lighting_layout( view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }; - let tex_depth = wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Depth, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }; let frag = wgpu::ShaderStages::FRAGMENT; let mut entries = vec![ // 0: Lighting UBO. VERTEX_FRAGMENT so the scene vertex shader can read @@ -70,23 +65,15 @@ pub(super) fn create_lighting_layout( ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, - // 5-7: shadow cascades, 8: comparison sampler + // 5: the shadow cascades (one layer each), 8: comparison sampler wgpu::BindGroupLayoutEntry { binding: 5, visibility: frag, - ty: tex_depth, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 6, - visibility: frag, - ty: tex_depth, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 7, - visibility: frag, - ty: tex_depth, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Depth, + view_dimension: wgpu::TextureViewDimension::D2Array, + multisampled: false, + }, count: None, }, wgpu::BindGroupLayoutEntry { @@ -193,15 +180,7 @@ pub(super) fn create_lighting_bind_group( }, wgpu::BindGroupEntry { binding: 5, - resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[0]), - }, - wgpu::BindGroupEntry { - binding: 6, - resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[1]), - }, - wgpu::BindGroupEntry { - binding: 7, - resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_views[2]), + resource: wgpu::BindingResource::TextureView(&src.shadow_map.depth_array_view), }, wgpu::BindGroupEntry { binding: 8, diff --git a/native/shared/src/renderer/quality_capture.rs b/native/shared/src/renderer/quality_capture.rs index 93816e13..5f669766 100644 --- a/native/shared/src/renderer/quality_capture.rs +++ b/native/shared/src/renderer/quality_capture.rs @@ -292,6 +292,21 @@ impl Renderer { kind: ReadbackKind, aspect: wgpu::TextureAspect, bytes_per_pixel: u32, + ) -> QualityReadback { + self.record_quality_texture_layer(encoder, texture, 0, name, kind, aspect, bytes_per_pixel) + } + + /// `record_quality_texture` for one layer of an array texture. + #[allow(clippy::too_many_arguments)] + fn record_quality_texture_layer( + &self, + encoder: &mut wgpu::CommandEncoder, + texture: &wgpu::Texture, + layer: u32, + name: &'static str, + kind: ReadbackKind, + aspect: wgpu::TextureAspect, + bytes_per_pixel: u32, ) -> QualityReadback { let size = texture.size(); let unpadded_bytes_per_row = size.width * bytes_per_pixel; @@ -306,7 +321,11 @@ impl Renderer { wgpu::TexelCopyTextureInfo { texture, mip_level: 0, - origin: wgpu::Origin3d::ZERO, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: layer, + }, aspect, }, wgpu::TexelCopyBufferInfo { @@ -383,11 +402,12 @@ impl Renderer { .copied() .and_then(|value| value.checked_sub(b'0')) .map(usize::from) - .filter(|&index| index < self.shadow_map.depth_textures.len()) + .filter(|&index| index < crate::shadows::NUM_CASCADES) .ok_or_else(|| format!("invalid shadow capture resource '{name}'"))?; - self.record_quality_texture( + self.record_quality_texture_layer( encoder, - &self.shadow_map.depth_textures[cascade], + &self.shadow_map.depth_texture, + cascade as u32, name, ReadbackKind::Depth, wgpu::TextureAspect::DepthOnly, diff --git a/native/shared/src/renderer/queries.rs b/native/shared/src/renderer/queries.rs index daf359f2..d6400c9c 100644 --- a/native/shared/src/renderer/queries.rs +++ b/native/shared/src/renderer/queries.rs @@ -164,9 +164,13 @@ impl Renderer { encoder.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { - texture: &self.shadow_map.depth_textures[cascade], + texture: &self.shadow_map.depth_texture, mip_level: 0, - origin: wgpu::Origin3d::ZERO, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: cascade as u32, + }, aspect: wgpu::TextureAspect::DepthOnly, }, wgpu::TexelCopyBufferInfo { diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index 464febb0..f9c583ec 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -107,9 +107,8 @@ struct VertexOutputScene { @group(1) @binding(2) var env_samp: sampler; @group(1) @binding(3) var brdf_lut_tex: texture_2d; @group(1) @binding(4) var brdf_lut_samp: sampler; -@group(1) @binding(5) var shadow_tex_0: texture_depth_2d; -@group(1) @binding(6) var shadow_tex_1: texture_depth_2d; -@group(1) @binding(7) var shadow_tex_2: texture_depth_2d; +// The shadow cascades, one layer each. +@group(1) @binding(5) var shadow_tex: texture_depth_2d_array; @group(1) @binding(8) var shadow_samp: sampler_comparison; @group(1) @binding(9) var env_diffuse_tex: texture_2d; @group(2) @binding(0) var base_color_tex: texture_2d; @@ -440,85 +439,60 @@ fn f_schlick(v_dot_h: f32, f0: vec3) -> vec3 { return f0 + (vec3(1.0) - f0) * fc; } -// 16-tap Poisson PCF in one cascade; each tap is a hardware 2x2 comparison. +// 16-tap Poisson PCF in one cascade (`layer`); each tap is a hardware 2x2 +// comparison. // The offsets are literal operands rather than a local array indexed by a loop // counter: SPIR-V can't index a constant array with a runtime value, so such an // array becomes function memory, which mobile GPUs (Adreno) keep in scratch // memory (measured ~12 ms of a 73 ms main pass on a Pixel 4a). -fn pcf_poisson16(t: texture_depth_2d, uv: vec2, tap_step: vec2, depth_ref: f32) -> f32 { +fn pcf_poisson16(layer: i32, uv: vec2, tap_step: vec2, depth_ref: f32) -> f32 { var sum = 0.0; - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(-0.94201624, -0.39906216) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(0.94558609, -0.76890725) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(-0.09418410, -0.92938870) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(0.34495938, 0.29387760) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(-0.91588581, 0.45771432) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(-0.81544232, -0.87912464) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(-0.38277543, 0.27676845) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(0.97484398, 0.75648379) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(0.44323325, -0.97511554) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(0.53742981, -0.47373420) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(-0.26496911, -0.41893023) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(0.79197514, 0.19090188) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(-0.24188840, 0.99706507) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(-0.81409955, 0.91437590) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(0.19984126, 0.78641367) * tap_step, depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(0.14383161, -0.14100790) * tap_step, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(-0.94201624, -0.39906216) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(0.94558609, -0.76890725) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(-0.09418410, -0.92938870) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(0.34495938, 0.29387760) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(-0.91588581, 0.45771432) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(-0.81544232, -0.87912464) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(-0.38277543, 0.27676845) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(0.97484398, 0.75648379) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(0.44323325, -0.97511554) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(0.53742981, -0.47373420) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(-0.26496911, -0.41893023) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(0.79197514, 0.19090188) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(-0.24188840, 0.99706507) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(-0.81409955, 0.91437590) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(0.19984126, 0.78641367) * tap_step, layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(0.14383161, -0.14100790) * tap_step, layer, depth_ref); return sum; } // 4 hardware 2x2 comparisons half a texel off the centre: together a 3x3-texel // tent. The mobile filter (Unity URP's Low soft shadows), a quarter of the // Poisson disc's taps. -fn pcf_tent4(t: texture_depth_2d, uv: vec2, texel: vec2, depth_ref: f32) -> f32 { +fn pcf_tent4(layer: i32, uv: vec2, texel: vec2, depth_ref: f32) -> f32 { let h = 0.5 * texel; - var sum = textureSampleCompareLevel(t, shadow_samp, uv + vec2(-h.x, -h.y), depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(h.x, -h.y), depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(-h.x, h.y), depth_ref); - sum += textureSampleCompareLevel(t, shadow_samp, uv + vec2(h.x, h.y), depth_ref); + var sum = textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(-h.x, -h.y), layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(h.x, -h.y), layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(-h.x, h.y), layer, depth_ref); + sum += textureSampleCompareLevel(shadow_tex, shadow_samp, uv + vec2(h.x, h.y), layer, depth_ref); return sum; } -// Sample a single cascade's shadow texture with 16-tap Poisson PCF, or the -// 4-tap tent when dir_light_count.w selects it (set_shadow_filter_4tap). The -// cascade is selected once, not per tap. +// Sample one cascade (the shadow texture's layer) with 16-tap Poisson PCF, or +// the 4-tap tent when dir_light_count.w selects it (set_shadow_filter_4tap). fn sample_cascade(cascade: i32, shadow_uv: vec2, depth_ref: f32) -> f32 { - var dims: vec2; - if (cascade == 0) { - dims = textureDimensions(shadow_tex_0); - } else if (cascade == 1) { - dims = textureDimensions(shadow_tex_1); - } else { - dims = textureDimensions(shadow_tex_2); + let texel = 1.0 / vec2(textureDimensions(shadow_tex)); + if (lighting.dir_light_count.w > 0.5) { + return pcf_tent4(cascade, shadow_uv, texel, depth_ref) * 0.25; } - let texel = vec2(1.0 / f32(dims.x), 1.0 / f32(dims.y)); // Tighter PCF radius (1.0 vs. prior 2.0). Softer was safer against // shadow acne / swim but produced a ~4-texel penumbra on every // shadow — for outdoor sun at this map resolution that translates // to 2-3m of fuzz, which reads as 'painted' rather than 'cast'. // The sun's real angular size gives a ~1m penumbra at typical // scene distances; r=1.0 roughly matches that. - if (lighting.dir_light_count.w > 0.5) { - var tent: f32; - if (cascade == 0) { - tent = pcf_tent4(shadow_tex_0, shadow_uv, texel, depth_ref); - } else if (cascade == 1) { - tent = pcf_tent4(shadow_tex_1, shadow_uv, texel, depth_ref); - } else { - tent = pcf_tent4(shadow_tex_2, shadow_uv, texel, depth_ref); - } - return tent * 0.25; - } let radius = 1.0; - let tap_step = texel * radius; - var sum: f32; - if (cascade == 0) { - sum = pcf_poisson16(shadow_tex_0, shadow_uv, tap_step, depth_ref); - } else if (cascade == 1) { - sum = pcf_poisson16(shadow_tex_1, shadow_uv, tap_step, depth_ref); - } else { - sum = pcf_poisson16(shadow_tex_2, shadow_uv, tap_step, depth_ref); - } - return sum / 16.0; + return pcf_poisson16(cascade, shadow_uv, texel * radius, depth_ref) / 16.0; } // Cascaded shadow map sampling. Determines which cascade the fragment @@ -559,7 +533,7 @@ fn sample_shadow(world_pos: vec3, geo_n: vec3, frag_xy: vec2) -> // far below visible peter-panning at each cascade's viewing distance. // The cascade fit radius ≈ its split distance (compute_cascade_vps // fits a camera-centred sphere), so texel ≈ 2·split / map_dim. - let map_dim = f32(textureDimensions(shadow_tex_0).x); + let map_dim = f32(textureDimensions(shadow_tex).x); var fit_r = lighting.shadow_cascade_splits.z; if (cascade == 0) { fit_r = lighting.shadow_cascade_splits.x; diff --git a/native/shared/src/renderer/shadow_pass.rs b/native/shared/src/renderer/shadow_pass.rs index 8982d172..b4e6469d 100644 --- a/native/shared/src/renderer/shadow_pass.rs +++ b/native/shared/src/renderer/shadow_pass.rs @@ -963,7 +963,16 @@ impl Renderer { // dynamic casters on top. encoder.copy_texture_to_texture( self.shadow_map.static_depth_textures[cascade].as_image_copy(), - self.shadow_map.depth_textures[cascade].as_image_copy(), + wgpu::TexelCopyTextureInfo { + texture: &self.shadow_map.depth_texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: cascade as u32, + }, + aspect: wgpu::TextureAspect::All, + }, wgpu::Extent3d { width: crate::shadows::CASCADE_MAP_SIZE, height: crate::shadows::CASCADE_MAP_SIZE, diff --git a/native/shared/src/shadows.rs b/native/shared/src/shadows.rs index 399169f1..444313b3 100644 --- a/native/shared/src/shadows.rs +++ b/native/shared/src/shadows.rs @@ -291,8 +291,12 @@ pub struct ShadowUniforms { /// Shadow map resources for cascaded shadow mapping. pub struct ShadowMap { - pub depth_textures: [wgpu::Texture; NUM_CASCADES], + /// The live cascades, one layer each (see `ShadowMap::new`). + pub depth_texture: wgpu::Texture, + /// Layer `i` of `depth_texture` as a 2D view. pub depth_views: [wgpu::TextureView; NUM_CASCADES], + /// All the cascades as a 2D array view, the layer being the cascade. + pub depth_array_view: wgpu::TextureView, /// Cached static-caster depth per cascade ("cached whole-scene /// shadows"): scene nodes / cached models / material draws render in /// here only when the cascade's VP or static content changes. Every @@ -424,35 +428,49 @@ impl ShadowMap { vertex_layout: wgpu::VertexBufferLayout<'static>, joint_layout: &wgpu::BindGroupLayout, ) -> Self { - // Create NUM_CASCADES depth textures (live + static cache). - let mut depth_textures_vec: Vec = Vec::new(); - let mut depth_views_vec: Vec = Vec::new(); - let mut static_textures_vec: Vec = Vec::new(); - let mut static_views_vec: Vec = Vec::new(); - for i in 0..NUM_CASCADES { - let tex = device.create_texture(&wgpu::TextureDescriptor { + // The live cascades are the layers of one depth texture: the scene + // shader samples them through one array view with the cascade as the + // layer, so a pixel's cascade choice is a coordinate, not a branch + // between textures. (Pixels of one quad sampling different textures in + // divergent branches lose their writes on Adreno; and each branch was + // another inlined copy of the filter.) `depth_views` are the layers' + // own views, for rendering and for the consumers that bind cascades + // one by one. The static caches are separate copy sources. + let depth_texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("shadow_depth_cascades"), + size: wgpu::Extent3d { + width: CASCADE_MAP_SIZE, + height: CASCADE_MAP_SIZE, + depth_or_array_layers: NUM_CASCADES as u32, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Depth32Float, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC + // Refreshed from the static cache before dynamic + // casters draw on top. + | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let depth_views: [wgpu::TextureView; NUM_CASCADES] = std::array::from_fn(|i| { + depth_texture.create_view(&wgpu::TextureViewDescriptor { label: Some(&format!("shadow_depth_cascade_{}", i)), - size: wgpu::Extent3d { - width: CASCADE_MAP_SIZE, - height: CASCADE_MAP_SIZE, - depth_or_array_layers: 1, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Depth32Float, - usage: wgpu::TextureUsages::RENDER_ATTACHMENT - | wgpu::TextureUsages::TEXTURE_BINDING - | wgpu::TextureUsages::COPY_SRC - // Refreshed from the static cache before dynamic - // casters draw on top. - | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); - depth_textures_vec.push(tex); - depth_views_vec.push(view); - let stex = device.create_texture(&wgpu::TextureDescriptor { + dimension: Some(wgpu::TextureViewDimension::D2), + base_array_layer: i as u32, + array_layer_count: Some(1), + ..Default::default() + }) + }); + let depth_array_view = depth_texture.create_view(&wgpu::TextureViewDescriptor { + label: Some("shadow_depth_cascades"), + dimension: Some(wgpu::TextureViewDimension::D2Array), + ..Default::default() + }); + let static_depth_textures: [wgpu::Texture; NUM_CASCADES] = std::array::from_fn(|i| { + device.create_texture(&wgpu::TextureDescriptor { label: Some(&format!("shadow_static_depth_cascade_{}", i)), size: wgpu::Extent3d { width: CASCADE_MAP_SIZE, @@ -465,25 +483,11 @@ impl ShadowMap { format: wgpu::TextureFormat::Depth32Float, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], - }); - let sview = stex.create_view(&wgpu::TextureViewDescriptor::default()); - static_textures_vec.push(stex); - static_views_vec.push(sview); - } - - // Convert Vecs to fixed-size arrays - let depth_textures: [wgpu::Texture; NUM_CASCADES] = depth_textures_vec - .try_into() - .unwrap_or_else(|_| panic!("cascade texture count mismatch")); - let depth_views: [wgpu::TextureView; NUM_CASCADES] = depth_views_vec - .try_into() - .unwrap_or_else(|_| panic!("cascade view count mismatch")); - let static_depth_textures: [wgpu::Texture; NUM_CASCADES] = static_textures_vec - .try_into() - .unwrap_or_else(|_| panic!("static cascade texture count mismatch")); - let static_depth_views: [wgpu::TextureView; NUM_CASCADES] = static_views_vec - .try_into() - .unwrap_or_else(|_| panic!("static cascade view count mismatch")); + }) + }); + let static_depth_views: [wgpu::TextureView; NUM_CASCADES] = std::array::from_fn(|i| { + static_depth_textures[i].create_view(&wgpu::TextureViewDescriptor::default()) + }); // Comparison sampler for PCF let sampler = device.create_sampler(&wgpu::SamplerDescriptor { @@ -786,8 +790,9 @@ impl ShadowMap { crate::virtual_shadows::DirectionalVirtualShadowMap::new(device, &uniform_layout); Self { - depth_textures, + depth_texture, depth_views, + depth_array_view, static_depth_textures, static_depth_views, sampler, From 6c1f295b1fd8de786ff0c54ae1f025a54ab63bd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 13:18:27 +0100 Subject: [PATCH 3/4] postfx: GPU timestamp for the upscale pass --- native/shared/src/renderer/postfx_chain.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/native/shared/src/renderer/postfx_chain.rs b/native/shared/src/renderer/postfx_chain.rs index bd87c076..3f48b1f0 100644 --- a/native/shared/src/renderer/postfx_chain.rs +++ b/native/shared/src/renderer/postfx_chain.rs @@ -781,7 +781,7 @@ impl Renderer { // upscale_rt. Downstream post-FX (DoF/MB/SSS/composite) read // upscale_rt so the chain operates at full surface resolution. if self.render_scale < 0.999 && !self.taa_enabled { - self.record_upscale(encoder, PostFxSource::Composed); + self.record_upscale(encoder, profiler, PostFxSource::Composed); } // ============================================================ @@ -1084,7 +1084,7 @@ impl Renderer { } else { PostFxSource::Taa1 }; - self.record_upscale(encoder, source); + self.record_upscale(encoder, profiler, source); } } @@ -1465,7 +1465,12 @@ impl Renderer { /// Upscale pass: a render-resolution `source` (the composed scene, or the /// TAA output when TAA resolves at the render resolution) → the /// full-surface upscale_rt, with the `upscale_mode` filter. - fn record_upscale(&mut self, encoder: &mut wgpu::CommandEncoder, source: PostFxSource) { + fn record_upscale( + &mut self, + encoder: &mut wgpu::CommandEncoder, + profiler: &mut crate::profiler::Profiler, + source: PostFxSource, + ) { let up = UpscaleParams { params: [self.upscale_mode as f32, 0.0, 0.0, 0.0], }; @@ -1501,6 +1506,7 @@ impl Renderer { }); self.upscale_bind_group_cache[slot] = Some(bind_group); } + let upscale_ts = profiler.pass_timestamp_writes("upscale_pass"); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("upscale_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -1513,7 +1519,7 @@ impl Renderer { }, })], depth_stencil_attachment: None, - timestamp_writes: None, + timestamp_writes: upscale_ts, occlusion_query_set: None, multiview_mask: None, }); From 09a2af5f712938e602f60839da6aff77a8428afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 9 Nov 2026 13:45:59 +0100 Subject: [PATCH 4/4] review: TAA telemetry and diagnostics at TAA's own extent; filter setter applies now; file sizes - quality telemetry reports TAA's output extent and its own scale (1 when it resolves at the render resolution), and picks the filter metadata from it; the TAA diagnostics render at TAA's extent. - set_shadow_filter_4tap updates the current frame's lighting flag too. - set_temporal_upscale / set_shadow_filter_4tap live in quality_preset.rs, TAA's target re-creation in postfx_chain.rs (recreate_taa_targets), and the capture readback in quality_readback.rs: mod.rs back under its ratchet, quality_capture.rs under 2000 lines. --- native/shared/src/renderer/mod.rs | 52 +-------- native/shared/src/renderer/postfx_chain.rs | 39 ++++++- native/shared/src/renderer/quality_capture.rs | 105 ++---------------- native/shared/src/renderer/quality_preset.rs | 28 +++++ .../shared/src/renderer/quality_readback.rs | 89 +++++++++++++++ .../src/renderer/temporal_diagnostics.rs | 5 +- 6 files changed, 169 insertions(+), 149 deletions(-) create mode 100644 native/shared/src/renderer/quality_readback.rs diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 4c94ddff..45337402 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -98,6 +98,7 @@ mod pt_temporal_diagnostics; #[cfg(not(target_arch = "wasm32"))] mod quality_capture; mod quality_preset; +mod quality_readback; mod queries; mod refractive_reflections; mod scene_pass; @@ -802,13 +803,7 @@ pub struct Renderer { /// 1 = TAA on (default). When off the renderer behaves exactly /// as the pre-TAA pipeline did. pub taa_enabled: bool, - /// Whether TAA also upscales when the render scale is below 1 (a - /// temporal upscaler: render-resolution input, history and output at the - /// surface's size). Off, TAA resolves at the render resolution and the - /// upscale pass scales its result, the order mobile renderers use (Unity - /// URP): the temporal pass then costs render_scale² as much, which on a - /// phone's 2-3x density display is most of the frame. Default off on - /// Android, on elsewhere. See `set_temporal_upscale`. + /// TAA also upscales below render scale 1; see `set_temporal_upscale`. temporal_upscale: bool, /// Perspective/orthographic clip planes for begin_mode_3d. Defaults keep /// the historical 0.01..1000; a scene viewed from ~15 units away needs a @@ -1349,7 +1344,6 @@ pub struct Renderer { pub upscale_pipeline: LazyRenderPipeline, pub upscale_layout: wgpu::BindGroupLayout, pub upscale_uniform_buffer: wgpu::Buffer, - /// The upscale pass's bind groups by source: composed, TAA 0, TAA 1. upscale_bind_group_cache: [Option; 3], /// 0 = bilinear (cheap), 1 = Catmull-Rom 5-tap (sharper edges). /// Default 1. @@ -9011,25 +9005,7 @@ impl Renderer { self.hiz_views = hiz_v; #[cfg(feature = "models3d")] self.invalidate_registered_virtual_hiz(true); - // TAA history/output live at SURFACE size when the TAA pass is - // also the TSR upscaler (render res in, output res out), else at - // the render resolution (see taa_upscales). - let (taa_w, taa_h) = if self.taa_upscales() { - (width, height) - } else { - (rw, rh) - }; - let (taa_t, taa_v) = create_taa_textures(&self.device, taa_w, taa_h); - self.taa_textures = taa_t; - self.taa_views = taa_v; - let (taa_depth_t, taa_depth_v) = - create_taa_depth_history_textures(&self.device, taa_w, taa_h); - self.taa_depth_history_textures = taa_depth_t; - self.taa_depth_history_views = taa_depth_v; - self.taa_current_idx = 0; - self.taa_frame_index = 0; // reset jitter sequence on resize - self.taa_history_valid = false; - self.taa_history_written = false; + self.recreate_taa_targets(width, height, rw, rh); let (sr_t, sr_v) = create_ssr_rt(&self.device, rw, rh); self.ssr_rt_texture = sr_t; self.ssr_rt_view = sr_v; @@ -9332,21 +9308,6 @@ impl Renderer { } } - /// Whether TAA upscales to the surface's size (see `temporal_upscale`) or - /// resolves at the render resolution before the upscale pass. Triggers a - /// resize, since TAA's history changes size. - pub fn set_temporal_upscale(&mut self, enabled: bool) { - if enabled != self.temporal_upscale { - self.temporal_upscale = enabled; - self.taa_current_idx = 0; - self.taa_frame_index = 0; - self.taa_history_valid = false; - self.taa_history_written = false; - let (w, h) = (self.native_width, self.native_height); - self.resize(w, h, self.logical_width, self.logical_height); - } - } - /// Set the render-resolution multiplier explicitly. Clamped to /// [0.15, 1.0]. Triggers a resize so render-res intermediates /// pick up the new extent. @@ -11053,13 +11014,6 @@ impl Renderer { } } - /// The sun shadow's filter: `true` takes 4 hardware-PCF taps (a - /// 3x3-texel tent: the filter mobile renderers default to, a quarter of - /// the cost), `false` the 16-tap Poisson disc. Default 4-tap on Android. - pub fn set_shadow_filter_4tap(&mut self, on: bool) { - self.shadow_map.filter_4tap = on; - } - /// Disables the ticket-004 shadow-cache and re-renders cascades /// every frame. Useful for games that mutate lighting state from /// places the cache invalidation path doesn't cover (e.g. day/ diff --git a/native/shared/src/renderer/postfx_chain.rs b/native/shared/src/renderer/postfx_chain.rs index 3f48b1f0..817fc947 100644 --- a/native/shared/src/renderer/postfx_chain.rs +++ b/native/shared/src/renderer/postfx_chain.rs @@ -808,11 +808,7 @@ impl Renderer { // window, close to the prior 0.05/20-frame); at 1.0 → // 0.10 (~10-frame), matching native TAA. Resolving at the // render resolution, TAA is native to its input: scale 1. - let taa_scale = if self.taa_upscales() { - self.render_scale - } else { - 1.0 - }; + let taa_scale = self.taa_scale(); let alpha = taa_current_weight(self.taa_history_valid, self.taa_frame_index, taa_scale); // Pack authored camera motion into the otherwise-unused sign of // the positive TAA weight. This lets history reconstruction keep @@ -1436,6 +1432,39 @@ impl Renderer { } } + /// (Re)create TAA's output/history targets for a surface of `width` x + /// `height` and a render extent of `rw` x `rh`: surface size when TAA also + /// upscales (TSR), else the render resolution (see taa_upscales). Resets + /// the history and the jitter sequence. + pub(super) fn recreate_taa_targets(&mut self, width: u32, height: u32, rw: u32, rh: u32) { + let (taa_w, taa_h) = if self.taa_upscales() { + (width, height) + } else { + (rw, rh) + }; + let (taa_t, taa_v) = super::create_taa_textures(&self.device, taa_w, taa_h); + self.taa_textures = taa_t; + self.taa_views = taa_v; + let (taa_depth_t, taa_depth_v) = + super::create_taa_depth_history_textures(&self.device, taa_w, taa_h); + self.taa_depth_history_textures = taa_depth_t; + self.taa_depth_history_views = taa_depth_v; + self.taa_current_idx = 0; + self.taa_frame_index = 0; + self.taa_history_valid = false; + self.taa_history_written = false; + } + + /// TAA's own scale: the render scale when it also upscales, else 1 (it + /// resolves at its input's resolution). + pub(super) fn taa_scale(&self) -> f32 { + if self.taa_upscales() { + self.render_scale + } else { + 1.0 + } + } + /// Whether the TAA pass also upscales (`temporal_upscale` with a render /// scale below 1): its history and output then live at the surface's /// size. Otherwise it resolves at the render resolution and the upscale diff --git a/native/shared/src/renderer/quality_capture.rs b/native/shared/src/renderer/quality_capture.rs index 5f669766..f5505349 100644 --- a/native/shared/src/renderer/quality_capture.rs +++ b/native/shared/src/renderer/quality_capture.rs @@ -6,26 +6,11 @@ use std::sync::mpsc; +use super::quality_readback::{QualityReadback, ReadbackKind}; use super::util::encode_png_simple; use super::weighted_transparency::WEIGHTED_TRANSPARENCY_AUTO_DRAW_THRESHOLD; use super::Renderer; -#[derive(Clone, Copy)] -enum ReadbackKind { - Hdr, - Depth, - Rgba8, -} - -pub(super) struct QualityReadback { - name: &'static str, - kind: ReadbackKind, - width: u32, - height: u32, - padded_bytes_per_row: u32, - buffer: wgpu::Buffer, -} - const PACKED_MRT_PIXEL_BYTES: u64 = 20; pub(super) struct MrtReadback { @@ -284,74 +269,6 @@ fn rgba8_rgb(data: &[u8], width: u32, height: u32, padded_bytes_per_row: u32) -> } impl Renderer { - fn record_quality_texture( - &self, - encoder: &mut wgpu::CommandEncoder, - texture: &wgpu::Texture, - name: &'static str, - kind: ReadbackKind, - aspect: wgpu::TextureAspect, - bytes_per_pixel: u32, - ) -> QualityReadback { - self.record_quality_texture_layer(encoder, texture, 0, name, kind, aspect, bytes_per_pixel) - } - - /// `record_quality_texture` for one layer of an array texture. - #[allow(clippy::too_many_arguments)] - fn record_quality_texture_layer( - &self, - encoder: &mut wgpu::CommandEncoder, - texture: &wgpu::Texture, - layer: u32, - name: &'static str, - kind: ReadbackKind, - aspect: wgpu::TextureAspect, - bytes_per_pixel: u32, - ) -> QualityReadback { - let size = texture.size(); - let unpadded_bytes_per_row = size.width * bytes_per_pixel; - let padded_bytes_per_row = (unpadded_bytes_per_row + 255) & !255; - let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("bloom_quality_intermediate_readback"), - size: (padded_bytes_per_row * size.height) as u64, - usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, - mapped_at_creation: false, - }); - encoder.copy_texture_to_buffer( - wgpu::TexelCopyTextureInfo { - texture, - mip_level: 0, - origin: wgpu::Origin3d { - x: 0, - y: 0, - z: layer, - }, - aspect, - }, - wgpu::TexelCopyBufferInfo { - buffer: &buffer, - layout: wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(padded_bytes_per_row), - rows_per_image: Some(size.height), - }, - }, - wgpu::Extent3d { - width: size.width, - height: size.height, - depth_or_array_layers: 1, - }, - ); - QualityReadback { - name, - kind, - width: size.width, - height: size.height, - padded_bytes_per_row, - buffer, - } - } - /// Resolve logical graph resource names to the renderer-owned imports and /// encode their copies. Unknown names fail loudly instead of silently /// capturing the wrong attachment after a topology refactor. @@ -1176,13 +1093,13 @@ impl Renderer { self.append_pt_temporal_diagnostic_telemetry(&mut out); out.push('}'); let (taa_input_width, taa_input_height) = self.render_extent(); - let taa_output_width = self.surface_config.width.max(1); - let taa_output_height = self.surface_config.height.max(1); - let taa_footprint_input_pixels = if self.render_scale >= 0.75 { - self.render_scale - } else { - 0.80 - }; + // TAA's own output and scale: the surface and the render scale when + // it also upscales, else the render resolution and 1. + let taa_size = self.taa_textures[0].size(); + let taa_output_width = taa_size.width.max(1); + let taa_output_height = taa_size.height.max(1); + let taa_scale = self.taa_scale(); + let taa_footprint_input_pixels = if taa_scale >= 0.75 { taa_scale } else { 0.80 }; let taa_footprint_output_x = taa_footprint_input_pixels * taa_output_width as f32 / taa_input_width.max(1) as f32; let taa_footprint_output_y = @@ -1200,7 +1117,7 @@ impl Renderer { &self.prev_proj_matrix_unjittered, ); let taa_camera_motion_phase_active = - taa_camera_moving && self.render_scale >= 0.75 && self.render_scale < 0.95; + taa_camera_moving && taa_scale >= 0.75 && taa_scale < 0.95; let output_detail_uses_depth = !taa_camera_moving && !self.pt_active(); let ( taa_source_filter, @@ -1208,7 +1125,7 @@ impl Renderer { taa_statistics_filter, taa_statistics_additional_samples, taa_composed_source_samples, - ) = if self.render_scale >= 0.75 && self.render_scale < 0.95 { + ) = if taa_scale >= 0.75 && taa_scale < 0.95 { ( "approximate-separable-lanczos2", 9, @@ -1216,7 +1133,7 @@ impl Renderer { 0, 9, ) - } else if self.render_scale >= 0.5 && self.render_scale < 0.75 { + } else if taa_scale >= 0.5 && taa_scale < 0.75 { ( "approximate-radial-lanczos2", 5, diff --git a/native/shared/src/renderer/quality_preset.rs b/native/shared/src/renderer/quality_preset.rs index 77dc2e60..8b7f53e4 100644 --- a/native/shared/src/renderer/quality_preset.rs +++ b/native/shared/src/renderer/quality_preset.rs @@ -109,6 +109,34 @@ fn quality_preset_config(preset: u32) -> QualityPresetConfig { } impl Renderer { + /// Whether TAA also upscales when the render scale is below 1 (a temporal + /// upscaler: render-resolution input, history and output at the surface's + /// size). Off, TAA resolves at the render resolution and the upscale pass + /// scales its result, the order mobile renderers use (Unity URP): the + /// temporal pass then costs render_scale² as much, which on a phone's 2-3x + /// density display is most of the frame. Default off on Android, on + /// elsewhere. Triggers a resize, since TAA's history changes size. + pub fn set_temporal_upscale(&mut self, enabled: bool) { + if enabled != self.temporal_upscale { + self.temporal_upscale = enabled; + self.taa_current_idx = 0; + self.taa_frame_index = 0; + self.taa_history_valid = false; + self.taa_history_written = false; + let (w, h) = (self.native_width, self.native_height); + self.resize(w, h, self.logical_width, self.logical_height); + } + } + + /// The sun shadow's filter: `true` takes 4 hardware-PCF taps (a + /// 3x3-texel tent: the filter mobile renderers default to, a quarter of + /// the cost), `false` the 16-tap Poisson disc. Default 4-tap on Android. + /// Applies from the current frame (dir_light_count.w). + pub fn set_shadow_filter_4tap(&mut self, on: bool) { + self.shadow_map.filter_4tap = on; + self.lighting_uniforms.dir_light_count[3] = if on { 1.0 } else { 0.0 }; + } + /// Apply one coherent quality tier. Resolution, temporal reconstruction, /// upscale filtering, sharpening, and effects change together. Individual /// setters remain overrides and should be called after this method. diff --git a/native/shared/src/renderer/quality_readback.rs b/native/shared/src/renderer/quality_readback.rs new file mode 100644 index 00000000..57220703 --- /dev/null +++ b/native/shared/src/renderer/quality_readback.rs @@ -0,0 +1,89 @@ +//! Texture readback for the qualification captures (quality_capture.rs). + +use super::Renderer; + +#[derive(Clone, Copy)] +pub(super) enum ReadbackKind { + Hdr, + Depth, + Rgba8, +} + +pub(super) struct QualityReadback { + pub(super) name: &'static str, + pub(super) kind: ReadbackKind, + pub(super) width: u32, + pub(super) height: u32, + pub(super) padded_bytes_per_row: u32, + pub(super) buffer: wgpu::Buffer, +} + +impl Renderer { + pub(super) fn record_quality_texture( + &self, + encoder: &mut wgpu::CommandEncoder, + texture: &wgpu::Texture, + name: &'static str, + kind: ReadbackKind, + aspect: wgpu::TextureAspect, + bytes_per_pixel: u32, + ) -> QualityReadback { + self.record_quality_texture_layer(encoder, texture, 0, name, kind, aspect, bytes_per_pixel) + } + + /// `record_quality_texture` for one layer of an array texture. + #[allow(clippy::too_many_arguments)] + pub(super) fn record_quality_texture_layer( + &self, + encoder: &mut wgpu::CommandEncoder, + texture: &wgpu::Texture, + layer: u32, + name: &'static str, + kind: ReadbackKind, + aspect: wgpu::TextureAspect, + bytes_per_pixel: u32, + ) -> QualityReadback { + let size = texture.size(); + let unpadded_bytes_per_row = size.width * bytes_per_pixel; + let padded_bytes_per_row = (unpadded_bytes_per_row + 255) & !255; + let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("bloom_quality_intermediate_readback"), + size: (padded_bytes_per_row * size.height) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: layer, + }, + aspect, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(size.height), + }, + }, + wgpu::Extent3d { + width: size.width, + height: size.height, + depth_or_array_layers: 1, + }, + ); + QualityReadback { + name, + kind, + width: size.width, + height: size.height, + padded_bytes_per_row, + buffer, + } + } +} diff --git a/native/shared/src/renderer/temporal_diagnostics.rs b/native/shared/src/renderer/temporal_diagnostics.rs index bd2d605b..64301091 100644 --- a/native/shared/src/renderer/temporal_diagnostics.rs +++ b/native/shared/src/renderer/temporal_diagnostics.rs @@ -272,7 +272,10 @@ impl Renderer { bind_group: &wgpu::BindGroup, reactive: bool, ) { - let (width, height) = (self.surface_config.width, self.surface_config.height); + // TAA's own extent: the surface's, or the render resolution's when + // it resolves there (see taa_upscales). + let taa_size = self.taa_textures[0].size(); + let (width, height) = (taa_size.width, taa_size.height); let resize = self .temporal_diagnostics .as_ref()