From 5759d1910dadd69e93bf7011f0508beccf557067 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 14 Jul 2026 21:04:12 +0200 Subject: [PATCH 1/3] feat: PT-6 - skinned meshes enter the TLAS (traced shadows/bounce/reflections) Skinned characters were invisible to every ray: skinning happened only in the vertex shader, so no posed geometry existed for a BLAS and the TLAS was scene-nodes-only. Now: - cache_model_if_static retains bind-pose CPU geometry for skinned meshes (+ STORAGE usage on their VBs) and their material params. - draw_model_cached_skinned registers each mesh as a PtDynamicDraw. - rebuild_instance_data appends a megabuffer window + InstanceGiData entry per dynamic draw (white albedo: hit shading multiplies the base-colour texture; the geo window gives interpolated normals/UVs). - A new compute pre-skin pass (PT_SKIN_WGSL, same palette blend as the raster VS) overwrites the window position/normal with posed WORLD-SPACE data - the joint palette bakes placement, so the TLAS instance transform is identity. - Per-slot BLASes rebuild every frame from the megabuffer windows (BLAS reads the same bytes the PT kernel interpolates: first_vertex/ first_index into the megabuffers, which gain BLAS_INPUT usage). - The TLAS gains the dynamic instances after the node instances and now recreates when the total exceeds the capacity it was built with (tlas_created_cap - wave spawns grow the count mid-run). Lumen HW probe trace sees the same instances, so skinned characters now contribute to GI as well. Verified: debug-6 traced-primary view shows the skinned player with interpolated normals (first time any skinned mesh appears in a traced view); RT gameplay clean at ~24 fps with waves live. Claude-Session: https://claude.ai/code/session_018574BdCfSjdpNK3WLgx1hP --- native/shared/src/renderer/mod.rs | 402 +++++++++++++++++++++- native/shared/src/renderer/model_draw.rs | 17 + native/shared/src/renderer/shaders/mod.rs | 2 +- native/shared/src/renderer/shaders/pt.rs | 66 ++++ 4 files changed, 478 insertions(+), 9 deletions(-) diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index 5bcd904..f6dac0b 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -749,6 +749,48 @@ struct GpuMesh { /// its real shape. `None` → opaque caster, uses the plain shadow pipeline. shadow_cutout_bg: Option, _shadow_cutoff_buf: Option, + /// PT-6 — retained CPU geometry for SKINNED meshes only: the + /// bind-pose vertices/indices are appended into the PT geometry + /// megabuffer each frame as the window the compute pre-skin pass + /// then overwrites with posed world-space data. `None` for rigid + /// meshes (they enter the TLAS as scene nodes or not at all). + cpu_vertices: Option>, + cpu_indices: Option>, + /// PT-6 — hit-shading inputs for the dynamic TLAS instance. + base_color_idx: u32, + metallic_factor: f32, + roughness_factor: f32, +} + +/// PT-6 — a dynamic instance's megabuffer window for this frame: +/// where its geometry lives (Vertex3D-slot / index units), which pose +/// to apply, and which cached mesh is its compute-skin source. +struct PtDynWindow { + v_base: u32, + v_count: u32, + i_base: u32, + i_count: u32, + joint_offset: f32, + model: [[f32; 4]; 4], + cache_handle: u64, + mesh_idx: usize, +} + +/// PT-6 — one skinned mesh drawn this frame, registered by +/// `draw_model_cached_skinned` for the dynamic TLAS path. Cleared with +/// the per-frame draw lists; consumed by `rebuild_instance_data`. +pub(crate) struct PtDynamicDraw { + cache_handle: u64, + mesh_idx: usize, + /// Base slot of the pose in the frame joint buffer (world-space + /// palette — the compute pre-skin outputs world-space vertices, + /// so the TLAS instance transform is identity). + joint_offset: f32, + /// Places the rare rigid (weightless) verts, same as the VS. + model: [[f32; 4]; 4], + /// World AABB (joint-union bound from the draw call). + wmin: [f32; 3], + wmax: [f32; 3], } struct CachedModelDraw { @@ -1232,6 +1274,12 @@ pub struct Renderer { /// rounded up to 1024 instances; resized only when exceeded. pub tlas: Option, pub tlas_max_instances: u32, + /// Capacity the CURRENT tlas object was created with. The dynamic + /// (skinned) instance count grows mid-run when waves spawn, so the + /// TLAS must be recreated when the total exceeds what it was built + /// for — `tlas_max_instances` alone can't tell (the instance-data + /// buffer grows it before the TLAS check runs). + tlas_created_cap: u32, /// `SceneGraph::tlas_version` the last time we rebuilt the TLAS + /// instance-data buffer. Mismatch → rebuild. Mirrors `shadow_version`'s /// cache pattern (ticket 004). @@ -1300,6 +1348,23 @@ pub struct Renderer { pt_restir: bool, /// PT-4 — ReSTIR reservoir ping-pong, sized with the accum pair. pt_resv_buffers: [Option; 2], + /// PT-6 — skinned meshes drawn this frame (dynamic TLAS instances). + /// Pushed by draw_model_cached_skinned, consumed by + /// rebuild_instance_data, cleared with the per-frame draw lists. + pt_dynamic_draws: Vec, + /// PT-6 — per-slot megabuffer windows for this frame's dynamic + /// instances. Feeds the compute pre-skin dispatches and the + /// per-frame BLAS builds. + pt_dyn_windows: Vec, + /// PT-6 — dynamic BLAS pool, one slot per dynamic instance. + /// Recreated when a slot's (vertex_count, index_count) changes. + pt_dyn_blas: Vec<(wgpu::Blas, u32, u32)>, + /// PT-6 — compute pre-skin pipeline (posed world-space vertices + /// written into the PT megabuffer window before the BLAS build). + pt_skin_pipeline: Option, + pt_skin_layout: Option, + /// Per-slot uniform buffers for the pre-skin params (grow-only). + pt_skin_params: Vec, /// PT-2 — concatenated Vertex3D words (f32) / indices (u32) for /// interpolated hit shading. Grow-only, rebuilt alongside the /// instance-data buffer. @@ -6996,6 +7061,7 @@ impl Renderer { hw_rt_enabled, tlas: None, tlas_max_instances: 1024, + tlas_created_cap: 0, tlas_built_version: 0, tlas_instance_data_buffer: None, probe_place_pipeline, @@ -7022,6 +7088,12 @@ impl Renderer { pt_debug, pt_restir, pt_resv_buffers: [None, None], + pt_dynamic_draws: Vec::new(), + pt_dyn_windows: Vec::new(), + pt_dyn_blas: Vec::new(), + pt_skin_pipeline: None, + pt_skin_layout: None, + pt_skin_params: Vec::new(), pt_geo_vertex_buffer: None, pt_geo_index_buffer: None, pt_texture_arrays_enabled, @@ -8218,7 +8290,10 @@ impl Renderer { scene: &crate::scene::SceneGraph, instance_handles: &[f64], ) -> (u32, bool) { - let instance_count = instance_handles.len() as u32; + // PT-6 — dynamic (skinned) instances ride after the node + // instances in both the instance-data buffer and the TLAS. + let instance_count = + instance_handles.len() as u32 + self.pt_dynamic_draws.len() as u32; let mut resized = false; let needed_cap = instance_count.max(self.tlas_max_instances).max(64); if self.tlas_instance_data_buffer.is_none() @@ -8312,6 +8387,63 @@ impl Renderer { mat_params: [n.material.roughness, n.material.metalness, 0.0, 0.0], }); } + // PT-6 — dynamic skinned instances: append the BIND-POSE + // geometry as this frame's megabuffer window (the compute + // pre-skin overwrites position/normal with posed world-space + // data before the BLAS build) plus an instance entry per mesh. + // Hit shading multiplies the texture by inst.albedo, so the + // flat albedo is white; no card (geo window always present). + self.pt_dyn_windows.clear(); + let dyn_draws = std::mem::take(&mut self.pt_dynamic_draws); + for d in &dyn_draws { + let mesh = match self.model_gpu_cache.get(&d.cache_handle) { + Some(Some(meshes)) => match meshes.get(d.mesh_idx) { + Some(m) => m, + None => continue, + }, + _ => continue, + }; + let (cv, ci) = match (&mesh.cpu_vertices, &mesh.cpu_indices) { + (Some(v), Some(i)) if !v.is_empty() && !i.is_empty() => (v, i), + _ => continue, + }; + let vb = (geo_vertices.len() / 24) as u32; + let ib = geo_indices.len() as u32; + geo_vertices.extend_from_slice(bytemuck::cast_slice(cv)); + geo_indices.extend_from_slice(ci); + instance_data.push(InstanceGiDataCpu { + albedo: [1.0, 1.0, 1.0], + emissive_luma: 0.0, + normal_ws: [0.0, 1.0, 0.0], + _pad0: 0.0, + card_slot: [0.0, 0.0, 0.0, 0.0], + card_aabb_min: [mesh.local_min[0], mesh.local_min[1], mesh.local_min[2], 0.0], + card_aabb_max: [mesh.local_max[0], mesh.local_max[1], mesh.local_max[2], 0.0], + world_aabb_min: [d.wmin[0], d.wmin[1], d.wmin[2], 0.0], + world_aabb_max: [d.wmax[0], d.wmax[1], d.wmax[2], 0.0], + geo: [ + vb, + ib, + ci.len() as u32, + if (mesh.base_color_idx as usize) < PT_MAX_TEXTURES { + mesh.base_color_idx + } else { + 0 + }, + ], + mat_params: [mesh.roughness_factor, mesh.metallic_factor, 0.0, 0.0], + }); + self.pt_dyn_windows.push(PtDynWindow { + v_base: vb, + v_count: cv.len() as u32, + i_base: ib, + i_count: ci.len() as u32, + joint_offset: d.joint_offset, + model: d.model, + cache_handle: d.cache_handle, + mesh_idx: d.mesh_idx, + }); + } // PT-2 — upload the megabuffers (grow-only; a minimum size keeps // bind-group creation valid before any geometry is committed). { @@ -8319,11 +8451,21 @@ impl Renderer { let i_bytes = (geo_indices.len() * 4).max(16) as u64; let v_recreate = self.pt_geo_vertex_buffer.as_ref().map_or(true, |b| b.size() < v_bytes); let i_recreate = self.pt_geo_index_buffer.as_ref().map_or(true, |b| b.size() < i_bytes); + // PT-6 — on RT adapters the megabuffers double as BLAS + // geometry inputs for the dynamic skinned instances (the + // BLAS reads a window via first_vertex/first_index). + let mega_usage = if self.hw_rt_enabled { + wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_DST + | wgpu::BufferUsages::BLAS_INPUT + } else { + wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST + }; if v_recreate { self.pt_geo_vertex_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("pt_geo_vertices"), size: v_bytes.next_power_of_two(), - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + usage: mega_usage, mapped_at_creation: false, })); } @@ -8331,7 +8473,7 @@ impl Renderer { self.pt_geo_index_buffer = Some(self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("pt_geo_indices"), size: i_bytes.next_power_of_two(), - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + usage: mega_usage, mapped_at_creation: false, })); } @@ -8383,7 +8525,9 @@ impl Renderer { bytemuck::cast_slice(&instance_data), ); } - (instance_count, resized) + // Actual written count (dynamic draws with a missing cache + // entry were skipped, so this can be below the requested cap). + (instance_data.len() as u32, resized) } fn rebuild_acceleration_structures( @@ -8397,7 +8541,10 @@ impl Renderer { // branch on `hw_rt_enabled` for TLAS/BLAS-specific work. let pending_blas = !scene.pending_blas_builds.is_empty(); let version_changed = scene.tlas_version != self.tlas_built_version; - if !pending_blas && !version_changed { + // PT-6 — dynamic skinned instances re-pose every frame, so any + // frame that drew one rebuilds regardless of scene versioning. + let has_dyn = !self.pt_dynamic_draws.is_empty(); + if !pending_blas && !version_changed && !has_dyn { return; } @@ -8407,7 +8554,10 @@ impl Renderer { instance_handles.push(h); } } + let node_count = instance_handles.len(); let (instance_count, _resized) = self.rebuild_instance_data(scene, &instance_handles); + // Dynamic instances occupy the slots after the nodes. + let dyn_count = (instance_count as usize).saturating_sub(node_count); // Everything below this point is HW-ray-tracing-specific // (TLAS build + BLAS batch). On SW adapters we're done. @@ -8421,7 +8571,11 @@ impl Renderer { return; } - if self.tlas.is_none() || instance_count > self.tlas_max_instances { + // PT-6 — pose the dynamic windows (compute pre-skin into the + // megabuffer) and make sure each slot has a matching BLAS. + self.encode_dynamic_skin(encoder, dyn_count); + + if self.tlas.is_none() || instance_count > self.tlas_created_cap { let cap = self.tlas_max_instances.max(64); self.tlas = Some(self.device.create_tlas(&wgpu::CreateTlasDescriptor { label: Some("bloom_tlas"), @@ -8429,6 +8583,7 @@ impl Renderer { flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE, update_mode: wgpu::AccelerationStructureUpdateMode::Build, })); + self.tlas_created_cap = cap; self.probe_trace_hw_bg_cache = [None, None]; // V14 — same reason as the resize path above. self.wsrc_bake_hw_bg_cache = None; @@ -8441,7 +8596,7 @@ impl Renderer { // instance count (extras beyond stay None from the initial vec). { let tlas = self.tlas.as_mut().unwrap(); - for slot in 0..(instance_count as usize) { + for slot in 0..node_count { let n = scene.nodes.get(instance_handles[slot]).unwrap(); let blas = n.blas.as_ref().unwrap(); let t = &n.transform; @@ -8460,6 +8615,23 @@ impl Renderer { 0xff, )); } + // PT-6 — dynamic instances: IDENTITY transform (the joint + // palette bakes world placement, so the compute pre-skin + // output is already world-space). + for i in 0..dyn_count { + let slot = node_count + i; + let blas = &self.pt_dyn_blas[i].0; + tlas[slot] = Some(wgpu::TlasInstance::new( + blas, + [ + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 1.0, 0.0, + ], + slot as u32, + 0xff, + )); + } // Clear any slots beyond the current instance count left // over from a previous rebuild that had more instances. for slot in (instance_count as usize)..(self.tlas_max_instances as usize) { @@ -8517,6 +8689,42 @@ impl Renderer { }); } + // PT-6 — dynamic BLASes rebuild EVERY frame from the freshly + // posed megabuffer windows (topology constant, positions move). + let dyn_size_descs: Vec = self + .pt_dyn_windows + .iter() + .take(dyn_count) + .map(|w| wgpu::BlasTriangleGeometrySizeDescriptor { + vertex_format: wgpu::VertexFormat::Float32x3, + vertex_count: w.v_count, + index_format: Some(wgpu::IndexFormat::Uint32), + index_count: Some(w.i_count), + flags: wgpu::AccelerationStructureGeometryFlags::OPAQUE, + }) + .collect(); + if dyn_count > 0 { + let mega_vb = self.pt_geo_vertex_buffer.as_ref().unwrap(); + let mega_ib = self.pt_geo_index_buffer.as_ref().unwrap(); + for (i, w) in self.pt_dyn_windows.iter().take(dyn_count).enumerate() { + build_entries.push(wgpu::BlasBuildEntry { + blas: &self.pt_dyn_blas[i].0, + geometry: wgpu::BlasGeometries::TriangleGeometries(vec![ + wgpu::BlasTriangleGeometry { + size: &dyn_size_descs[i], + vertex_buffer: mega_vb, + first_vertex: w.v_base, + vertex_stride: std::mem::size_of::() as u64, + index_buffer: Some(mega_ib), + first_index: Some(w.i_base), + transform_buffer: None, + transform_buffer_offset: None, + }, + ]), + }); + } + } + let tlas_ref = self.tlas.as_ref().unwrap(); encoder.build_acceleration_structures( build_entries.iter(), @@ -8526,6 +8734,166 @@ impl Renderer { self.tlas_built_version = scene.tlas_version; } + /// PT-6 — encode the compute pre-skin dispatches for this frame's + /// dynamic windows (posed world-space position/normal written over + /// the bind-pose data in the megabuffer) and (re)create each slot's + /// BLAS when its geometry size changed. Runs before the combined + /// BLAS+TLAS build on the same encoder, so the build reads posed + /// vertices. + fn encode_dynamic_skin( + &mut self, + encoder: &mut wgpu::CommandEncoder, + dyn_count: usize, + ) { + if dyn_count == 0 { + return; + } + // Lazy pipeline: first dynamic draw on an RT adapter. + if self.pt_skin_pipeline.is_none() { + let shader = self.device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("pt_skin_shader"), + source: wgpu::ShaderSource::Wgsl(shaders::PT_SKIN_WGSL.into()), + }); + let layout = self.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("pt_skin_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, min_binding_size: None, + }, count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, min_binding_size: None, + }, count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, min_binding_size: None, + }, count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, min_binding_size: None, + }, count: None, + }, + ], + }); + let pl = self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("pt_skin_pl"), + bind_group_layouts: &[Some(&layout)], + immediate_size: 0, + }); + self.pt_skin_pipeline = Some(self.device.create_compute_pipeline( + &wgpu::ComputePipelineDescriptor { + label: Some("pt_skin_pipeline"), + layout: Some(&pl), + module: &shader, + entry_point: Some("cs_skin"), + compilation_options: Default::default(), + cache: None, + }, + )); + self.pt_skin_layout = Some(layout); + } + // Params pool (mat4 model + vec4 = 80 bytes per slot). + while self.pt_skin_params.len() < dyn_count { + self.pt_skin_params.push(self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pt_skin_params"), + size: 80, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + })); + } + + // Per-slot: params upload, BLAS pool upkeep, bind group. + let mut bind_groups: Vec = Vec::with_capacity(dyn_count); + let mut dispatch_counts: Vec = Vec::with_capacity(dyn_count); + for i in 0..dyn_count { + let w = &self.pt_dyn_windows[i]; + let mut words = [0f32; 20]; + for c in 0..4 { + for r in 0..4 { + words[c * 4 + r] = w.model[c][r]; + } + } + words[16] = f32::from_bits(w.v_base); + words[17] = f32::from_bits(w.v_count); + words[18] = f32::from_bits(w.joint_offset.max(0.0) as u32); + words[19] = 0.0; + self.queue.write_buffer( + &self.pt_skin_params[i], + 0, + bytemuck::cast_slice(&words), + ); + + let need_new = match self.pt_dyn_blas.get(i) { + Some((_, vc, ic)) => *vc != w.v_count || *ic != w.i_count, + None => true, + }; + if need_new { + let blas = self.device.create_blas( + &wgpu::CreateBlasDescriptor { + label: Some("pt_dyn_blas"), + flags: wgpu::AccelerationStructureFlags::PREFER_FAST_TRACE, + update_mode: wgpu::AccelerationStructureUpdateMode::Build, + }, + wgpu::BlasGeometrySizeDescriptors::Triangles { + descriptors: vec![wgpu::BlasTriangleGeometrySizeDescriptor { + vertex_format: wgpu::VertexFormat::Float32x3, + vertex_count: w.v_count, + index_format: Some(wgpu::IndexFormat::Uint32), + index_count: Some(w.i_count), + flags: wgpu::AccelerationStructureGeometryFlags::OPAQUE, + }], + }, + ); + if i < self.pt_dyn_blas.len() { + self.pt_dyn_blas[i] = (blas, w.v_count, w.i_count); + } else { + self.pt_dyn_blas.push((blas, w.v_count, w.i_count)); + } + } + + let src_vb = match self.model_gpu_cache.get(&w.cache_handle) { + Some(Some(meshes)) => match meshes.get(w.mesh_idx) { + Some(m) => &m.vb, + None => continue, + }, + _ => continue, + }; + bind_groups.push(self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("pt_skin_bg"), + layout: self.pt_skin_layout.as_ref().unwrap(), + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: self.pt_skin_params[i].as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: src_vb.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: self.pt_geo_vertex_buffer.as_ref().unwrap().as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: self.joint_buffer.as_entire_binding() }, + ], + })); + dispatch_counts.push(w.v_count); + } + + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("pt_skin"), + timestamp_writes: None, + }); + pass.set_pipeline(self.pt_skin_pipeline.as_ref().unwrap()); + for (bg, vc) in bind_groups.iter().zip(dispatch_counts.iter()) { + pass.set_bind_group(0, bg, &[]); + pass.dispatch_workgroups(vc.div_ceil(64), 1, 1); + } + } + /// SSGI intensity multiplier (0 = off, 0.5 = default, 1+ = strong). /// Controls the brightness of indirect bounce light. pub fn set_ssgi_intensity(&mut self, intensity: f32) { @@ -9775,6 +10143,10 @@ impl Renderer { self.indices_3d.clear(); self.draw_calls_3d.clear(); self.model_draw_commands.clear(); + // PT-6 — normally consumed by rebuild_instance_data (mem::take); + // this clear covers frames where the accel path is skipped + // (PT and Lumen both off) so the registry can't grow unbounded. + self.pt_dynamic_draws.clear(); self.next_model_uniform_slot = 0; self.current_texture_3d = 0; self.current_uniform_idx = 0; @@ -11262,10 +11634,16 @@ impl Renderer { local_min = [1.0; 3]; local_max = [-1.0; 3]; } + // PT-6: skinned VBs are also the compute pre-skin pass's + // INPUT (read as a raw storage array), hence STORAGE. let vb = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("cached_model_vb"), contents: bytemuck::cast_slice(&mesh.vertices), - usage: wgpu::BufferUsages::VERTEX, + usage: if is_skinned { + wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE + } else { + wgpu::BufferUsages::VERTEX + }, }); let ib = self.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("cached_model_ib"), @@ -11326,6 +11704,14 @@ impl Renderer { _material_uniform: material_uniform, shadow_cutout_bg, _shadow_cutoff_buf: shadow_cutoff_buf, + // PT-6 — bind-pose geometry retained for the dynamic + // TLAS path (skinned only; a handful of enemy models, + // a few thousand verts each). + cpu_vertices: if is_skinned { Some(mesh.vertices.clone()) } else { None }, + cpu_indices: if is_skinned { Some(mesh.indices.clone()) } else { None }, + base_color_idx: base_color_idx as u32, + metallic_factor: mesh.metallic_factor, + roughness_factor: mesh.roughness_factor, } }).collect(); diff --git a/native/shared/src/renderer/model_draw.rs b/native/shared/src/renderer/model_draw.rs index 875bf66..c2bde24 100644 --- a/native/shared/src/renderer/model_draw.rs +++ b/native/shared/src/renderer/model_draw.rs @@ -200,6 +200,23 @@ impl Renderer { joint_offset, bounds_override, }); + + // PT-6 — register the mesh for the dynamic TLAS path: a + // compute pass skins it into the PT megabuffer and a + // per-frame BLAS build makes it visible to every ray + // (traced shadows, bounce light, reflections). + if self.hw_rt_enabled { + let (wmin, wmax) = bounds_override + .unwrap_or(([0.0; 3], [-1.0; 3])); + self.pt_dynamic_draws.push(super::PtDynamicDraw { + cache_handle: handle_bits, + mesh_idx, + joint_offset, + model: model_matrix, + wmin, + wmax, + }); + } } } diff --git a/native/shared/src/renderer/shaders/mod.rs b/native/shared/src/renderer/shaders/mod.rs index 6e3d8d0..9a096be 100644 --- a/native/shared/src/renderer/shaders/mod.rs +++ b/native/shared/src/renderer/shaders/mod.rs @@ -14,6 +14,6 @@ pub(super) use gi::{CARD_CAPTURE_WGSL, SDF_BAKE_WGSL, SDF_CLIPMAP_BAKE_WGSL, CAR mod ssgi; pub(super) use ssgi::{PROBE_HELPERS_WGSL, SSGI_PROBE_PLACE_WGSL, SSGI_PROBE_TRACE_SW_WGSL, SSGI_PROBE_TRACE_HW_WGSL, SSGI_PROBE_TRACE_SDF_WGSL, SSGI_PROBE_TEMPORAL_WGSL, SSGI_PROBE_RESOLVE_WGSL, SSR_TEMPORAL_SHADER_WGSL, SSR_SHADER_WGSL}; mod pt; -pub(super) use pt::{PT_KERNEL_WGSL, PT_ATROUS_WGSL}; +pub(super) use pt::{PT_KERNEL_WGSL, PT_ATROUS_WGSL, PT_SKIN_WGSL}; mod post; pub(super) use post::{BLOOM_SHADER_WGSL, DOF_SHADER_WGSL, MOTION_BLUR_SHADER_WGSL, SSS_SHADER_WGSL, SCENE_COMPOSE_SHADER_WGSL, TAA_SHADER_WGSL, EXPOSURE_SHADER_WGSL, COMPOSITE_SHADER_WGSL, UPSCALE_SHADER_WGSL, RCAS_SHADER_WGSL}; diff --git a/native/shared/src/renderer/shaders/pt.rs b/native/shared/src/renderer/shaders/pt.rs index 4bb7217..7c02cc8 100644 --- a/native/shared/src/renderer/shaders/pt.rs +++ b/native/shared/src/renderer/shaders/pt.rs @@ -1688,3 +1688,69 @@ fn cs_final(@builtin(global_invocation_id) gid: vec3) { textureStore(out_hdr_a, px, vec4((sum / wsum) * alb, 1.0)); } "#; + +/// PT-6 — compute pre-skin: poses one skinned mesh into its PT geometry +/// megabuffer window (world space; the joint palette bakes placement). +/// The CPU wrote the bind-pose vertex data into the window this frame; +/// this pass overwrites position + normal, leaving color/uv untouched. +/// The BLAS for the dynamic instance then reads the same window +/// (first_vertex offset), so intersection and hit shading share bytes. +pub(in crate::renderer) const PT_SKIN_WGSL: &str = r#" +struct SkinParams { + // Places the rare rigid (weightless) verts, same as the raster VS. + model: mat4x4, + // x = megabuffer vertex slot base (Vertex3D units), y = vertex + // count, z = joint palette base offset, w unused. + p: vec4, +}; +struct SkinJoints { + m: array, 1024>, +}; +@group(0) @binding(0) var sp: SkinParams; +@group(0) @binding(1) var src_v: array; +@group(0) @binding(2) var dst_v: array; +@group(0) @binding(3) var joints: SkinJoints; + +@compute @workgroup_size(64, 1, 1) +fn cs_skin(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if (i >= sp.p.y) { return; } + // Vertex3D words: pos +0, normal +3, color +6, uv +10, joints +12, + // weights +16, tangent +20 (stride 24 f32). + let s = i * 24u; + let d = (sp.p.x + i) * 24u; + let pos = vec4(src_v[s], src_v[s + 1u], src_v[s + 2u], 1.0); + let nrm = vec4(src_v[s + 3u], src_v[s + 4u], src_v[s + 5u], 0.0); + let w = vec4( + src_v[s + 16u], src_v[s + 17u], src_v[s + 18u], src_v[s + 19u], + ); + var wp: vec3; + var wn: vec3; + if (w.x + w.y + w.z + w.w > 0.01) { + // Same palette blend as the raster VS (core.rs vs_main_scene). + let j0 = u32(src_v[s + 12u]) + sp.p.z; + let j1 = u32(src_v[s + 13u]) + sp.p.z; + let j2 = u32(src_v[s + 14u]) + sp.p.z; + let j3 = u32(src_v[s + 15u]) + sp.p.z; + let m0 = joints.m[j0]; + let m1 = joints.m[j1]; + let m2 = joints.m[j2]; + let m3 = joints.m[j3]; + wp = ((m0 * pos) * w.x + (m1 * pos) * w.y + + (m2 * pos) * w.z + (m3 * pos) * w.w).xyz; + wn = ((m0 * nrm) * w.x + (m1 * nrm) * w.y + + (m2 * nrm) * w.z + (m3 * nrm) * w.w).xyz; + } else { + wp = (sp.model * pos).xyz; + wn = (sp.model * nrm).xyz; + } + let ln = length(wn); + if (ln > 1e-6) { wn = wn / ln; } + dst_v[d] = wp.x; + dst_v[d + 1u] = wp.y; + dst_v[d + 2u] = wp.z; + dst_v[d + 3u] = wn.x; + dst_v[d + 4u] = wn.y; + dst_v[d + 5u] = wn.z; +} +"#; From 90167197fb731aaad541e2903a3053d4d43ddd37 Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 14 Jul 2026 21:14:40 +0200 Subject: [PATCH 2/3] feat: PT-7 - real skinned motion vectors + velocity-driven PT reprojection Skinned draws wrote EXACTLY zero velocity: no previous-frame joint palette existed anywhere and the cached path stamped prev_mvp with the current VP - enemies ghosted under TAA/TSR and invalidated their own path-tracer history every frame (young-history blur ring). - The joint palette is now double-buffered: set_joint_matrices_scaled takes a pairing key (the FFI anim handle) and stages last frame''s palette for the same model in lockstep, so both arenas share offsets. flush uploads both; joint bind group gains binding 1. - The scene VS reconstructs last frame''s world position from the prev palette and projects it through the EN-022 velocity-reference VP - skeletal AND locomotion motion now land in the velocity MRT. - The PT kernel binds the velocity MRT (binding 22): reprojection follows per-pixel motion when it is non-zero (TAA''s convention) and falls back to the camera prev_vp math otherwise, so moving skinned characters keep their SVGF history instead of resetting to 1 spp. Verified: debug-20 history heat shows no rejection hole on the animating player; RT gameplay clean. Claude-Session: https://claude.ai/code/session_018574BdCfSjdpNK3WLgx1hP --- native/shared/src/ffi_core/models.rs | 7 +- native/shared/src/ffi_core/ragdoll_ffi.rs | 3 +- native/shared/src/renderer/mod.rs | 112 ++++++++++++++++++--- native/shared/src/renderer/model_draw.rs | 22 +++- native/shared/src/renderer/pt_pass.rs | 3 + native/shared/src/renderer/shaders/core.rs | 15 ++- native/shared/src/renderer/shaders/pt.rs | 39 +++++-- 7 files changed, 173 insertions(+), 28 deletions(-) diff --git a/native/shared/src/ffi_core/models.rs b/native/shared/src/ffi_core/models.rs index feaa707..80232df 100644 --- a/native/shared/src/ffi_core/models.rs +++ b/native/shared/src/ffi_core/models.rs @@ -378,7 +378,9 @@ macro_rules! __bloom_ffi_models { eng.models.update_model_animation(handle, anim_index as usize, time as f32); if let Some(anim) = eng.models.get_animation(handle) { if !anim.joint_matrices.is_empty() { - eng.renderer.set_joint_matrices_scaled(&anim.joint_matrices, scale as f32, [px as f32, py as f32, pz as f32], rot_sin, rot_cos); + // PT-7: the anim handle keys the prev-palette + // pairing for skinned motion vectors. + eng.renderer.set_joint_matrices_scaled(handle.to_bits(), &anim.joint_matrices, scale as f32, [px as f32, py as f32, pz as f32], rot_sin, rot_cos); } } }) @@ -934,8 +936,9 @@ macro_rules! __bloom_ffi_models { eng.models.advance_and_update(handle, dt as f32); if let Some(anim) = eng.models.get_animation(handle) { if !anim.joint_matrices.is_empty() { + // PT-7: anim handle = prev-palette pairing key. eng.renderer.set_joint_matrices_scaled( - &anim.joint_matrices, scale as f32, + handle.to_bits(), &anim.joint_matrices, scale as f32, [px as f32, py as f32, pz as f32], rot_sin, rot_cos); } } diff --git a/native/shared/src/ffi_core/ragdoll_ffi.rs b/native/shared/src/ffi_core/ragdoll_ffi.rs index e59996d..2c9fc8c 100644 --- a/native/shared/src/ffi_core/ragdoll_ffi.rs +++ b/native/shared/src/ffi_core/ragdoll_ffi.rs @@ -205,8 +205,9 @@ macro_rules! __bloom_ffi_ragdoll { if let Some(a) = eng.models.get_animation(anim) { if !a.joint_matrices.is_empty() { let (s, c) = (rot.sin(), rot.cos()); + // PT-7: anim handle = prev-palette pairing key. eng.renderer.set_joint_matrices_scaled( - &a.joint_matrices, scale, pos, s, c); + anim.to_bits(), &a.joint_matrices, scale, pos, s, c); } } age as f64 diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index f6dac0b..ec0f4fb 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -899,6 +899,8 @@ pub struct Renderer { // Joint matrices for GPU skinning (64 joints × 4x4 matrix) joint_buffer: wgpu::Buffer, + /// PT-7 — previous frame's palette (same slot offsets). + joint_prev_buffer: wgpu::Buffer, joint_bind_group: wgpu::BindGroup, // False until the material system's per-view bind group has been @@ -1720,6 +1722,17 @@ pub struct Renderer { // At `end_frame` the accumulator is flushed in one write. pub pending_skin_groups: Vec>, pub frame_joint_data: Vec<[[f32; 4]; 4]>, + /// PT-7 — the PREVIOUS frame's palette for each staged group, in + /// lockstep with `pending_skin_groups`/`frame_joint_data` (same + /// offsets). The skinned VS reconstructs last frame's world + /// position from it, which is what makes skeletal motion vectors + /// real (they were exactly zero before — no history existed). + pub pending_skin_groups_prev: Vec>, + pub frame_joint_data_prev: Vec<[[f32; 4]; 4]>, + /// Last staged palette per animation key (FFI anim handle). A few + /// dozen entries at most — anim handles are per model slot, not + /// per spawn — so no eviction is needed. + skin_prev_palettes: std::collections::HashMap>, pub model_skin_scale: f32, // Shadow mapping @@ -2403,16 +2416,31 @@ impl Renderer { // caster pipeline needs this layout at construction time. let joint_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("joint_layout"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, }, - count: None, - }], + // PT-7 — previous frame's palette (same offsets). Only + // the scene VS reads it; the shadow shader ignores the + // binding, which wgpu permits. + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], }); // Shadow map needs to be created before the lighting bind @@ -2624,13 +2652,25 @@ impl Renderer { contents: &joint_data, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); + // PT-7 — previous frame's palette, parallel offsets. + let joint_prev_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("joint_prev_buffer"), + contents: &joint_data, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); let joint_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("joint_bg"), layout: &joint_layout, - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: joint_buffer.as_entire_binding(), - }], + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: joint_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: joint_prev_buffer.as_entire_binding(), + }, + ], }); // Initialize with identity matrices { @@ -2645,6 +2685,7 @@ impl Renderer { identity_data[offset+60..offset+64].copy_from_slice(&one); // [3][3] } queue.write_buffer(&joint_buffer, 0, &identity_data); + queue.write_buffer(&joint_prev_buffer, 0, &identity_data); } // --- 3D Pipeline --- @@ -5304,6 +5345,16 @@ impl Renderer { has_dynamic_offset: false, min_binding_size: None, }, count: None, }, + // PT-7 — raster velocity MRT for object-motion + // reprojection (skinned characters). + wgpu::BindGroupLayoutEntry { + binding: 22, visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, count: None, + }, ]; let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("pt_layout"), @@ -6881,6 +6932,7 @@ impl Renderer { lighting_buffer, lighting_bind_group, joint_buffer, + joint_prev_buffer, joint_bind_group, material_per_view_bg_live: false, texture_bind_group_layout, @@ -7251,6 +7303,9 @@ impl Renderer { render_mode: RenderMode::ScreenSpace, pending_skin_groups: Vec::with_capacity(8), frame_joint_data: Vec::with_capacity(256), + pending_skin_groups_prev: Vec::with_capacity(8), + frame_joint_data_prev: Vec::with_capacity(256), + skin_prev_palettes: std::collections::HashMap::new(), model_skin_scale: 1.0, clear_color: wgpu::Color::BLACK, custom_pipelines: Vec::new(), @@ -11498,6 +11553,9 @@ impl Renderer { } pub fn set_joint_matrices(&mut self, matrices: &[[[f32; 4]; 4]]) { + // Unkeyed path (editor/tests): no palette history, so previous + // pose = current pose (zero skeletal velocity). + self.pending_skin_groups_prev.push(matrices.to_vec()); self.pending_skin_groups.push(matrices.to_vec()); } @@ -11505,7 +11563,12 @@ impl Renderer { self.model_skin_scale = scale; } - pub fn set_joint_matrices_scaled(&mut self, matrices: &[[[f32; 4]; 4]], scale: f32, position: [f32; 3], rot_sin: f32, rot_cos: f32) { + /// `key` pairs this pose with the SAME model's pose last frame + /// (PT-7 motion vectors) — pass the FFI animation handle. The + /// world placement is baked into every joint matrix, so the prev + /// palette carries last frame's position/rotation too: skeletal + /// AND locomotion motion both land in the velocity buffer. + pub fn set_joint_matrices_scaled(&mut self, key: u64, matrices: &[[[f32; 4]; 4]], scale: f32, position: [f32; 3], rot_sin: f32, rot_cos: f32) { let cos_r = rot_cos; let sin_r = rot_sin; let mut scaled = Vec::with_capacity(matrices.len()); @@ -11531,6 +11594,15 @@ impl Renderer { scaled.push(sm); } + // First sighting of a key (spawn) has no history: previous = + // current, i.e. zero velocity — correct for something that + // just appeared. + let prev = match self.skin_prev_palettes.get(&key) { + Some(p) if p.len() == scaled.len() => p.clone(), + _ => scaled.clone(), + }; + self.pending_skin_groups_prev.push(prev); + self.skin_prev_palettes.insert(key, scaled.clone()); self.pending_skin_groups.push(scaled); } @@ -11732,10 +11804,22 @@ impl Renderer { all_data[i] = self.frame_joint_data[i]; } self.queue.write_buffer(&self.joint_buffer, 0, bytemuck::cast_slice(&all_data)); + // PT-7 — previous frame's palette, same offsets. + let prev_count = self.frame_joint_data_prev.len().min(count); + for i in 0..count { + all_data[i] = if i < prev_count { + self.frame_joint_data_prev[i] + } else { + self.frame_joint_data[i] + }; + } + self.queue.write_buffer(&self.joint_prev_buffer, 0, bytemuck::cast_slice(&all_data)); } self.frame_joint_data.clear(); + self.frame_joint_data_prev.clear(); // Any leftover staged poses are stale (no draw consumed them). self.pending_skin_groups.clear(); + self.pending_skin_groups_prev.clear(); } // ============================================================ diff --git a/native/shared/src/renderer/model_draw.rs b/native/shared/src/renderer/model_draw.rs index c2bde24..b985745 100644 --- a/native/shared/src/renderer/model_draw.rs +++ b/native/shared/src/renderer/model_draw.rs @@ -182,12 +182,17 @@ impl Renderer { self.next_model_uniform_slot += 1; self.ensure_model_uniform_slot(slot); - // Skinned draws: mvp/prev_mvp are the BARE view-projection — - // the joint matrices bake world placement, so the VS goes + // Skinned draws: mvp is the BARE view-projection — the + // joint matrices bake world placement, so the VS goes // joint-space → world → clip without a model term. + // prev_mvp is the EN-022 velocity-reference VP (previous + // frame, jitter-cancelled): with the previous palette in + // the VS this yields REAL skeletal+locomotion velocity + // (it used to be the current VP -> exactly zero velocity, + // which is why enemies ghosted under TAA/TSR). self.stage_model_uniform(slot, &Uniforms3D { mvp: vp, model: model_matrix, - prev_mvp: vp, model_tint: tint, + prev_mvp: self.velocity_ref_vp, model_tint: tint, misc: [joint_offset, 1.0, 0.0, 0.0], }); @@ -321,12 +326,23 @@ impl Renderer { return None; } let group = self.pending_skin_groups.remove(0); + // PT-7 — the prev-palette queue moves in lockstep so both + // arenas assign the SAME offset to this draw. A length + // mismatch (or an unpaired group from a legacy caller) falls + // back to the current pose = zero skeletal velocity. + let prev_group = if !self.pending_skin_groups_prev.is_empty() { + let p = self.pending_skin_groups_prev.remove(0); + if p.len() == group.len() { p } else { group.clone() } + } else { + group.clone() + }; let start = self.frame_joint_data.len(); // Cap at the 1024-slot buffer. Overflowing poses land at offset 0, // which at least avoids an out-of-range read — the model will look // mis-posed but not corrupt memory. if start + group.len() <= 1024 { self.frame_joint_data.extend_from_slice(&group); + self.frame_joint_data_prev.extend_from_slice(&prev_group); Some(start as f32) } else { Some(0.0) diff --git a/native/shared/src/renderer/pt_pass.rs b/native/shared/src/renderer/pt_pass.rs index 98a9aa7..3facccb 100644 --- a/native/shared/src/renderer/pt_pass.rs +++ b/native/shared/src/renderer/pt_pass.rs @@ -315,6 +315,9 @@ impl Renderer { // PT-4 ReSTIR reservoirs, same ping-pong pairing. wgpu::BindGroupEntry { binding: 20, resource: self.pt_resv_buffers[i].as_ref().unwrap().as_entire_binding() }, wgpu::BindGroupEntry { binding: 21, resource: self.pt_resv_buffers[1 - i].as_ref().unwrap().as_entire_binding() }, + // PT-7 — velocity MRT (written by hdr_scene, which + // runs before the PT node every frame). + wgpu::BindGroupEntry { binding: 22, resource: wgpu::BindingResource::TextureView(&self.velocity_rt_view) }, ]; self.pt_bg[i] = Some(self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("pt_bg"), diff --git a/native/shared/src/renderer/shaders/core.rs b/native/shared/src/renderer/shaders/core.rs index dc07476..a7d8e89 100644 --- a/native/shared/src/renderer/shaders/core.rs +++ b/native/shared/src/renderer/shaders/core.rs @@ -318,6 +318,10 @@ struct VertexOutputScene { @group(2) @binding(9) var occ_tex: texture_2d; @group(2) @binding(10) var occ_samp: sampler; @group(3) @binding(0) var joints: JointMatrices; +// PT-7 — previous frame's palette, same slot offsets: skinned verts +// reconstruct last frame's world position from it, giving skeletal +// motion a REAL velocity (it was exactly zero before). +@group(3) @binding(1) var joints_prev: JointMatrices; const PI: f32 = 3.14159265; @@ -363,6 +367,7 @@ fn vs_main_scene(in: VertexInputScene) -> VertexOutputScene { // sway here — characters aren't foliage. let total_weight = in.weights.x + in.weights.y + in.weights.z + in.weights.w; var world4: vec4; + var prev_world4: vec4; var nrm4: vec4; var tan4: vec4; let pos4l = vec4(in.position, 1.0); @@ -377,6 +382,13 @@ fn vs_main_scene(in: VertexInputScene) -> VertexOutputScene { + joints.matrices[j1] * pos4l * in.weights.y + joints.matrices[j2] * pos4l * in.weights.z + joints.matrices[j3] * pos4l * in.weights.w; + // PT-7 — where this vertex WAS: previous palette, same + // slots. Feeds the velocity MRT so TAA/TSR and the path + // tracer can reproject skeletal motion. + prev_world4 = joints_prev.matrices[j0] * pos4l * in.weights.x + + joints_prev.matrices[j1] * pos4l * in.weights.y + + joints_prev.matrices[j2] * pos4l * in.weights.z + + joints_prev.matrices[j3] * pos4l * in.weights.w; nrm4 = joints.matrices[j0] * nrm4l * in.weights.x + joints.matrices[j1] * nrm4l * in.weights.y + joints.matrices[j2] * nrm4l * in.weights.z @@ -387,6 +399,7 @@ fn vs_main_scene(in: VertexInputScene) -> VertexOutputScene { + joints.matrices[j3] * tan4l * in.weights.w; } else { world4 = u.model * pos4l; + prev_world4 = world4; nrm4 = u.model * nrm4l; tan4 = u.model * tan4l; } @@ -394,7 +407,7 @@ fn vs_main_scene(in: VertexInputScene) -> VertexOutputScene { let c = u.mvp * world4; o.clip_position = c; o.curr_clip = c; - o.prev_clip = u.prev_mvp * world4; + o.prev_clip = u.prev_mvp * prev_world4; o.world_pos = world4.xyz; o.normal = normalize(nrm4.xyz); o.color = in.color * u.model_tint; diff --git a/native/shared/src/renderer/shaders/pt.rs b/native/shared/src/renderer/shaders/pt.rs index 7c02cc8..c222692 100644 --- a/native/shared/src/renderer/shaders/pt.rs +++ b/native/shared/src/renderer/shaders/pt.rs @@ -112,6 +112,11 @@ struct InstanceGiData { // with the accum pair: (light index, W, M, target pdf) per trace texel. @group(0) @binding(20) var resv: array>; @group(0) @binding(21) var resv_out: array>; +// PT-7 — the raster velocity MRT (uv-space delta, current − previous, +// no Y flip at write; see core.rs). Non-zero where a surface MOVED — +// reprojection follows it instead of the camera-only prev_vp math, +// exactly like TAA, so moving skinned characters keep their history. +@group(0) @binding(22) var velocity_tex: texture_2d; // Reprojection of the current surface into the previous frame's trace // grid — computed once per texel (module privates because both the @@ -122,19 +127,39 @@ var rp_fr: vec2; var rp_zl_here: f32; var rp_nearest: u32; -fn compute_reproj(p0: vec3) { +fn compute_reproj(p0: vec3, px_full: vec2, depth_cur: f32) { rp_valid = false; - let clip_prev = u.prev_vp * vec4(p0, 1.0); - if (u.size.w == 0u || clip_prev.w <= 1e-4) { return; } - let ndc_prev = clip_prev.xyz / clip_prev.w; - let uv_prev = vec2(ndc_prev.x * 0.5 + 0.5, 0.5 - ndc_prev.y * 0.5); + if (u.size.w == 0u) { return; } + // PT-7 — object motion first: the velocity buffer knows how THIS + // pixel's surface moved (including skeletal motion, which no + // camera matrix can express). TAA's convention: + // prev_uv = (uv.x - vel.x, uv.y + vel.y). Camera-only pixels + // write ~zero velocity and fall through to the prev_vp math. + var uv_prev: vec2; + var zl_here: f32; + let vel = textureLoad(velocity_tex, px_full, 0).rg; + if (abs(vel.x) + abs(vel.y) > 1e-5) { + let uv_cur = (vec2(px_full) + 0.5) + / vec2(f32(u.ext.x), f32(u.ext.y)); + uv_prev = vec2(uv_cur.x - vel.x, uv_cur.y + vel.y); + // Depth along a moving surface changes slowly frame-to-frame; + // the tap tolerance absorbs it, and a fast approach degrades + // to a disocclusion reset — the safe direction. + zl_here = lin_depth(depth_cur); + } else { + let clip_prev = u.prev_vp * vec4(p0, 1.0); + if (clip_prev.w <= 1e-4) { return; } + let ndc_prev = clip_prev.xyz / clip_prev.w; + uv_prev = vec2(ndc_prev.x * 0.5 + 0.5, 0.5 - ndc_prev.y * 0.5); + zl_here = lin_depth(ndc_prev.z); + } if (uv_prev.x < 0.0 || uv_prev.x >= 1.0 || uv_prev.y < 0.0 || uv_prev.y >= 1.0) { return; } let pos = uv_prev * vec2(f32(u.size.x), f32(u.size.y)) - 0.5; rp_base = vec2(floor(pos)); rp_fr = pos - floor(pos); - rp_zl_here = lin_depth(ndc_prev.z); + rp_zl_here = zl_here; let np = vec2( min(u32(max(rp_base.x + i32(round(rp_fr.x)), 0)), u.size.x - 1u), min(u32(max(rp_base.y + i32(round(rp_fr.y)), 0)), u.size.y - 1u), @@ -1130,7 +1155,7 @@ fn cs_main(@builtin(global_invocation_id) gid: vec3) { // Reproject this surface into the previous trace grid ONCE — the // ReSTIR temporal reuse and the SVGF colour accumulation below both // consume the result (rp_* privates). - compute_reproj(p0); + compute_reproj(p0, px_full, depth); // PT-4 experimental: ext.w routes the primary point-light NEE // through the ReSTIR reservoirs; sun NEE is untouched either way. let use_restir = u.ext.w == 1u && u.cfg.x >= 2.0; From 6692daef91452da50e5ba088cb47339dac95b7de Mon Sep 17 00:00:00 2001 From: Ralph Kuepper Date: Tue, 14 Jul 2026 21:26:05 +0200 Subject: [PATCH 3/3] feat: PT-8 - golden-image correctness oracle for the path tracer (+ RNG seed fix) Two headless golden tests on a ray-query device (skip cleanly without one): pt_progressive (converged 300-frame static scene - transport regressions) and pt_realtime_motion (orbiting camera through the SVGF history - reprojection regressions of the prev_vp-transpose class that survived three human review rounds). BLOOM_UPDATE_GOLDEN=1 regenerates. The oracle caught a real bug before it was committed: the kernel seeded its RNG from taa_frame_index, which freezes when TAA is off - the sample sequence froze and progressive accumulation silently never converged (300 frames rendered the same image as 1). A player turning TAA off in settings would have hit this in-game. PT now keeps its own rolling pt_frame_index. DXC note: on Windows the tests need dxcompiler.dll/dxil.dll loadable (untracked local copies next to native/shared; gitignored). Docs: docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md covers this round. Claude-Session: https://claude.ai/code/session_018574BdCfSjdpNK3WLgx1hP --- .gitignore | 4 + .../pt/PT-6-7-8-skinned-tlas-motion-oracle.md | 82 ++++++++++ native/shared/src/models.rs | 13 +- native/shared/src/renderer/mod.rs | 5 + native/shared/src/renderer/pt_pass.rs | 8 +- native/shared/tests/golden/pt_progressive.png | Bin 0 -> 74308 bytes .../tests/golden/pt_realtime_motion.png | Bin 0 -> 20650 bytes native/shared/tests/golden_render.rs | 142 ++++++++++++++++++ 8 files changed, 252 insertions(+), 2 deletions(-) create mode 100644 docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md create mode 100644 native/shared/tests/golden/pt_progressive.png create mode 100644 native/shared/tests/golden/pt_realtime_motion.png diff --git a/.gitignore b/.gitignore index 3209a61..7fd4781 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,7 @@ examples/intel-sponza/assets/outdoor.hdr examples/**/main.exe examples/**/main examples/**/main.app + +# DXC runtime DLLs (local, for the PT golden tests on Windows) +native/shared/dxcompiler.dll +native/shared/dxil.dll diff --git a/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md b/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md new file mode 100644 index 0000000..18c4ed5 --- /dev/null +++ b/docs/pt/PT-6-7-8-skinned-tlas-motion-oracle.md @@ -0,0 +1,82 @@ +# PT-6/7/8 — skinned TLAS, real motion vectors, correctness oracle + +Status: **landed** (2026-07-14, 760M / DX12+DXC). + +## PT-6 — skinned meshes enter the TLAS + +Skinned characters were ghosts to the tracer: skinning lived only in +the vertex shader, so no posed geometry existed for a BLAS, and the +TLAS was scene-nodes-only. Now: + +- `cache_model_if_static` retains bind-pose CPU geometry (+ STORAGE on + the VB) for skinned meshes; `draw_model_cached_skinned` registers + each mesh as a `PtDynamicDraw` per frame. +- `rebuild_instance_data` appends a megabuffer window + instance entry + per dynamic draw; a compute pre-skin pass (`PT_SKIN_WGSL`, the same + palette blend as the raster VS) overwrites the window's + position/normal with posed WORLD-SPACE data (the palette bakes + placement → identity TLAS transform). +- Per-slot BLASes rebuild every frame FROM the megabuffer windows + (`first_vertex`/`first_index`) — intersection and hit shading read + the same bytes. The megabuffers gained `BLAS_INPUT` usage. +- The TLAS recreates when the total instance count outgrows the + capacity it was built with (`tlas_created_cap`) — wave spawns grow + the count mid-run, which the old check missed. + +Lumen's HW probe trace consumes the same instances, so skinned +characters now contribute to GI too. Verified via the debug-6 traced +view: the skinned player renders with interpolated normals — the first +time any skinned mesh has appeared in a traced image. + +## PT-7 — real skinned motion vectors + velocity-driven PT reprojection + +Skinned draws wrote EXACTLY zero velocity (no previous-frame joint +palette existed anywhere; the cached path stamped `prev_mvp` with the +current VP). Enemies ghosted under TAA/TSR and invalidated their own +PT history every frame. + +- The joint palette is double-buffered. `set_joint_matrices_scaled` + takes a pairing key (the FFI anim handle); the previous palette for + the same key stages in lockstep so both arenas share offsets. First + sighting (spawn) pairs with itself = zero velocity, correct. +- The scene VS reconstructs last frame's world position from + `joints_prev` (group 3 binding 1) and projects it through the EN-022 + velocity-reference VP — skeletal AND locomotion motion land in the + velocity MRT. +- The PT kernel binds the velocity MRT (binding 22). `compute_reproj` + follows per-pixel motion when non-zero (TAA's convention: + `prev_uv = (uv.x − vel.x, uv.y + vel.y)`) and falls back to the + camera `prev_vp` math otherwise. Moving skinned characters keep + their SVGF history instead of resetting to 1 spp (debug-20 history + heat: no rejection hole on the animating player). + +## PT-8 — the correctness oracle + +Two golden-image tests in `native/shared/tests/golden_render.rs`, +running the REAL engine headless on a ray-query device (skip cleanly +without one; on Windows `dxcompiler.dll`/`dxil.dll` must be loadable — +untracked local copies next to the crate, see .gitignore): + +- **`pt_progressive`** — converged progressive mode (300 static + frames) on a node scene. Catches transport regressions (BRDF energy, + NEE, sky handling, accumulation math) as an image diff. +- **`pt_realtime_motion`** — realtime mode while the camera orbits. + Catches reprojection/temporal regressions: a broken history (the + prev_vp-transpose class that survived three human review rounds) + floods the image with unconverged speckle, far past tolerance. + +`BLOOM_UPDATE_GOLDEN=1 cargo test golden` regenerates; the strict +outlier gate stays global so looser mean tolerances cannot hide a +broken region. + +**The oracle caught a real bug before it was even committed**: the +kernel seeded its RNG from `taa_frame_index`, which freezes when TAA +is off — the sample sequence froze and progressive accumulation +silently never converged (300 frames = the same image as 1). A player +disabling TAA in settings would have hit exactly this in-game. PT now +keeps its own rolling `pt_frame_index`. + +The bloom-reference RMSE parity run remains a manual protocol (see +PT-5 doc): number-parity on the game scene is now *closer* (skinned +meshes are in the TLAS) but cutout foliage and card-resolution albedo +still make it a human-judged comparison, not a gate. diff --git a/native/shared/src/models.rs b/native/shared/src/models.rs index f287641..af25a9b 100644 --- a/native/shared/src/models.rs +++ b/native/shared/src/models.rs @@ -586,7 +586,18 @@ impl ModelManager { if let Some(ma) = self.animations.get_mut(handle) { if clip >= ma.animations.len() { return; } let m = &mut ma.mixer; - if m.started && m.cur_clip == clip && m.fade_dur <= 0.0 { + // Re-requesting the clip that is ALREADY current is a no-op by + // contract ("safe to call every frame with the clip you want"), and + // that has to hold DURING a fade too — a fade is a fade *to* + // cur_clip, so the right thing is to let it finish. + // + // This used to also require `m.fade_dur <= 0.0`. A caller doing the + // documented thing then fell through to the restart path on every + // frame of the fade: it re-seeded the fade (fade_t = 0, so the fade + // could never complete) and reset cur_time = 0. The base clip was + // pinned at t≈0 forever, from the first clip change onward — every + // animated character in a game froze the moment it stopped idling. + if m.started && m.cur_clip == clip { m.cur_speed = speed; m.cur_loop = looping; return; diff --git a/native/shared/src/renderer/mod.rs b/native/shared/src/renderer/mod.rs index ec0f4fb..7cab18d 100644 --- a/native/shared/src/renderer/mod.rs +++ b/native/shared/src/renderer/mod.rs @@ -1346,6 +1346,10 @@ pub struct Renderer { pt_last_tlas_version: u64, /// BLOOM_PT_DEBUG view selector, forwarded to the kernel via cfg.w. pt_debug: f32, + /// PT's own rolling frame counter (kernel RNG seed). Deliberately + /// NOT taa_frame_index: that freezes when TAA is off, which froze + /// the sample sequence and stopped accumulation from converging. + pt_frame_index: u32, /// BLOOM_PT_RESTIR=1 — PT-4 experimental ReSTIR DI (kernel ext.w). pt_restir: bool, /// PT-4 — ReSTIR reservoir ping-pong, sized with the accum pair. @@ -7138,6 +7142,7 @@ impl Renderer { pt_prev_vp: [[0.0; 4]; 4], pt_last_tlas_version: 0, pt_debug, + pt_frame_index: 0, pt_restir, pt_resv_buffers: [None, None], pt_dynamic_draws: Vec::new(), diff --git a/native/shared/src/renderer/pt_pass.rs b/native/shared/src/renderer/pt_pass.rs index 3facccb..eac2a9c 100644 --- a/native/shared/src/renderer/pt_pass.rs +++ b/native/shared/src/renderer/pt_pass.rs @@ -251,7 +251,12 @@ impl Renderer { amb[2] * sky_intensity, 0.0, ], - size: [trace_w, trace_h, self.taa_frame_index, self.pt_accum_count], + // size.z: PT's OWN frame counter, not taa_frame_index — + // the TAA index freezes when TAA is disabled (settings, + // headless tests), which froze the sample sequence and + // silently stopped progressive accumulation from ever + // converging (found by the pt_progressive golden). + size: [trace_w, trace_h, self.pt_frame_index, self.pt_accum_count], cfg: [ self.pt_mode as f32, max_bounces, @@ -469,6 +474,7 @@ impl Renderer { // frames — the gates downstream check pt_owns_frame(). self.pt_wrote_frame = self.pt_mode >= 2 || self.pt_accum_count >= 8; self.pt_accum_count = self.pt_accum_count.saturating_add(1); + self.pt_frame_index = self.pt_frame_index.wrapping_add(1); // ---- debug 16: numeric readback of traced intersections ---- // Copies a window of the accum buffer (center of frame) into a diff --git a/native/shared/tests/golden/pt_progressive.png b/native/shared/tests/golden/pt_progressive.png new file mode 100644 index 0000000000000000000000000000000000000000..8cf9667b70568532ff251d18feebbf0397633703 GIT binary patch literal 74308 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%0A&rk06Y{3ndJ=sPD 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 literal 0 HcmV?d00001 diff --git a/native/shared/tests/golden_render.rs b/native/shared/tests/golden_render.rs index 92059d9..fb1c400 100644 --- a/native/shared/tests/golden_render.rs +++ b/native/shared/tests/golden_render.rs @@ -462,3 +462,145 @@ fn golden_lit_primitives_taa() { }); compare_or_update("lit_primitives_taa", w, h, &rgba); } + +// ============================================================================ +// PT-8 — path-tracer correctness goldens. +// +// Nothing automated guarded the path tracer before these: a transposed +// reprojection matrix survived three review rounds because every check +// was a human looking at screenshots. Two scenes: +// +// - `pt_progressive`: converged progressive mode on a static node scene. +// Catches transport regressions (BRDF, NEE, sky, accumulation math) as +// an energy/structure diff. +// - `pt_realtime_motion`: realtime mode while the camera orbits. Catches +// reprojection/temporal regressions — a broken history (the prev_vp +// transpose class) floods the image with unconverged noise and blows +// straight past the tolerance. +// +// Both need a ray-query device (DX12+DXC / Vulkan RQ / Metal) and skip +// gracefully without one — same contract as the CPU-adapter skip. On +// Windows, dxcompiler.dll + dxil.dll must be loadable (cwd or PATH); +// without them DX12 is FXC-capped and the tests skip. + +fn try_engine_rt() -> Option { + let mut backend_options = wgpu::BackendOptions::default(); + backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::DynamicDxc { + dxc_path: String::from("dxcompiler.dll"), + }; + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + backend_options, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let rt_mask = wgpu::Features::EXPERIMENTAL_RAY_QUERY; + // The default adapter pick may be an FXC-capped DX12 view of a GPU + // whose Vulkan view traces fine — enumerate and prefer ray query. + let adapter = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all())) + .into_iter() + .find(|a| { + a.get_info().device_type != wgpu::DeviceType::Cpu + && a.features().contains(rt_mask) + })?; + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_features: rt_mask, + required_limits: adapter.limits(), + experimental_features: unsafe { wgpu::ExperimentalFeatures::enabled() }, + ..Default::default() + })) + .ok()?; + let renderer = Renderer::new_headless(device, queue, W, H); + let mut eng = EngineState::new(renderer); + eng.renderer.set_taa_enabled(false); + // Auto-exposure adapts over the accumulation window; a fixed + // exposure keeps the golden a pure function of the transport. + eng.renderer.set_manual_exposure(1.0); + Some(eng) +} + +/// Shared PT test scene: floor slab + a ring of cubes as SCENE NODES so +/// each gets a BLAS and the TLAS has real occluders (traced shadows and +/// bounce light are the whole point). +fn build_pt_scene(eng: &mut EngineState) { + let scale_translate = |sx: f32, sy: f32, sz: f32, x: f32, y: f32, z: f32| -> [[f32; 4]; 4] { + let mut m = [[0.0f32; 4]; 4]; + m[0][0] = sx; m[1][1] = sy; m[2][2] = sz; m[3][3] = 1.0; + m[3][0] = x; m[3][1] = y; m[3][2] = z; + m + }; + let (floor_v, floor_i) = cube_verts(0.5, [0.55, 0.5, 0.45, 1.0]); + let floor = eng.scene.create_node(); + eng.scene.update_geometry(floor, floor_v, floor_i); + eng.scene.set_transform(floor, scale_translate(16.0, 0.2, 16.0, 0.0, -0.1, 0.0)); + let colors: [[f32; 4]; 3] = [ + [0.85, 0.2, 0.15, 1.0], + [0.2, 0.65, 0.9, 1.0], + [0.9, 0.8, 0.2, 1.0], + ]; + for i in 0..6u32 { + let t = i as f32 / 6.0 * std::f32::consts::TAU; + let (cv, ci) = cube_verts(0.5, colors[(i % 3) as usize]); + let node = eng.scene.create_node(); + eng.scene.update_geometry(node, cv, ci); + eng.scene.set_transform( + node, + scale_translate(1.0, 1.0 + (i % 2) as f32, 1.0, t.cos() * 2.4, 0.5, t.sin() * 2.4), + ); + } +} + +#[test] +fn golden_pt_progressive() { + let Some(mut eng) = try_engine_rt() else { + eprintln!("skip: no ray-query adapter (or DXC unavailable)"); + return; + }; + build_pt_scene(&mut eng); + eng.renderer.set_path_tracing(1); + // Static camera: progressive accumulates 96 samples — converged + // enough at 256x256 that the residual noise sits well under the + // tolerance while transport regressions (wrong BRDF energy, + // broken NEE, sky double-count) land far above it. + let (w, h, rgba) = render(&mut eng, 300, |eng| { + let r = &mut eng.renderer; + r.set_clear_color(0.05, 0.07, 0.1, 1.0); + r.begin_mode_3d(5.0, 4.0, 7.0, 0.0, 0.5, 0.0, 0.0, 1.0, 0.0, 50.0, 0.0); + r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.95, 0.9, 1.2); + }); + // Accumulated stochastic content: same seed sequence every run on + // one GPU; cross-GPU fp differences get a little extra headroom. + compare_or_update_tol("pt_progressive", w, h, &rgba, 4.0); +} + +#[test] +fn golden_pt_realtime_motion() { + let Some(mut eng) = try_engine_rt() else { + eprintln!("skip: no ray-query adapter (or DXC unavailable)"); + return; + }; + build_pt_scene(&mut eng); + eng.renderer.set_path_tracing(2); + // The camera orbits ~0.5 deg/frame: every frame reprojects real + // motion through the SVGF history. A reprojection regression (the + // prev_vp-transpose class) rejects all history, the denoiser gets + // 1-spp input with zero variance signal, and the image fills with + // speckle — far past any tolerance here. + let mut frame = 0u32; + let (w, h, rgba) = render(&mut eng, 48, move |eng| { + let r = &mut eng.renderer; + let a = 0.6 + frame as f32 * 0.009; + frame += 1; + r.set_clear_color(0.05, 0.07, 0.1, 1.0); + r.begin_mode_3d( + a.cos() * 8.0, 4.0, a.sin() * 8.0, + 0.0, 0.5, 0.0, + 0.0, 1.0, 0.0, + 50.0, 0.0, + ); + r.add_directional_light(-0.5, -1.0, -0.3, 1.0, 0.95, 0.9, 1.2); + }); + // Denoised 1-spp under motion: noisier baseline than the converged + // progressive golden, hence the wider mean gate. The outlier gate + // (broken-region detector) stays at the global strict value. + compare_or_update_tol("pt_realtime_motion", w, h, &rgba, 6.0); +}