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/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/mod.rs b/native/shared/src/renderer/mod.rs index b55f891a..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,6 +803,8 @@ pub struct Renderer { /// 1 = TAA on (default). When off the renderer behaves exactly /// as the pre-TAA pipeline did. pub taa_enabled: bool, + /// 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 /// larger near plane, or surfaces a millimetre apart z-fight. @@ -1341,7 +1344,7 @@ pub struct Renderer { pub upscale_pipeline: LazyRenderPipeline, pub upscale_layout: wgpu::BindGroupLayout, pub upscale_uniform_buffer: wgpu::Buffer, - upscale_bind_group_cache: Option, + upscale_bind_group_cache: [Option; 3], /// 0 = bilinear (cheap), 1 = Catmull-Rom 5-tap (sharper edges). /// Default 1. pub upscale_mode: u32, @@ -8285,6 +8288,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 +8542,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 +8951,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,19 +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 — 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); - 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); - 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; diff --git a/native/shared/src/renderer/postfx_chain.rs b/native/shared/src/renderer/postfx_chain.rs index 6a7f1302..817fc947 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, profiler, PostFxSource::Composed); } // ============================================================ @@ -865,12 +806,10 @@ 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 = 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 // its exact settled lookup while selecting a sharper fractional @@ -893,9 +832,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 +1074,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, profiler, 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 +1170,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 +1261,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 +1345,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 +1422,145 @@ 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, + } + } + } + + /// (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 + /// 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, + profiler: &mut crate::profiler::Profiler, + 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 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 { + 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: upscale_ts, + 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/quality_capture.rs b/native/shared/src/renderer/quality_capture.rs index 93816e13..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,55 +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 { - 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::ZERO, - 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. @@ -383,11 +319,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, @@ -1156,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 = @@ -1180,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, @@ -1188,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, @@ -1196,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/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 7ef75b84..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,44 +439,52 @@ 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; } -// 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(layer: i32, uv: vec2, texel: vec2, depth_ref: f32) -> f32 { + let h = 0.5 * texel; + 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 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 @@ -485,23 +492,18 @@ fn sample_cascade(cascade: i32, shadow_uv: vec2, depth_ref: f32) -> f32 { // The sun's real angular size gives a ~1m penumbra at typical // scene distances; r=1.0 roughly matches that. 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 // 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 @@ -531,7 +533,7 @@ fn sample_shadow(world_pos: vec3, geo_n: vec3) -> f32 { // 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; @@ -568,6 +570,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 +620,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 +1076,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/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/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() diff --git a/native/shared/src/shadows.rs b/native/shared/src/shadows.rs index e4f61ec7..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 @@ -333,6 +337,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 @@ -421,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, @@ -462,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 { @@ -783,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, @@ -802,6 +810,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"