From 7b767bb9c1aec1ac9a63203649cb9bff18a6b644 Mon Sep 17 00:00:00 2001 From: haixuantao Date: Wed, 8 Jul 2026 10:43:45 +0200 Subject: [PATCH 1/3] feat: expose kiss3d path tracing + frame readback in the Python viewer Adds NexusViewer.raytrace_frame() (kiss3d 0.45 GPU path tracer with cross-frame sample accumulation), set_raytracer_samples_per_frame/ max_bounces/denoise, and snap_rgb() returning the framebuffer as an (H, W, 3) uint8 numpy array. Co-Authored-By: Claude Fable 5 --- crates/nexus_python3d/src/viewer.rs | 23 ++++++++++++ src_viewer/viewer.rs | 56 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/crates/nexus_python3d/src/viewer.rs b/crates/nexus_python3d/src/viewer.rs index 864c807..16c691d 100644 --- a/crates/nexus_python3d/src/viewer.rs +++ b/crates/nexus_python3d/src/viewer.rs @@ -171,6 +171,29 @@ impl NexusViewer { self.inner_mut().set_draw_ui(enabled); } + /// Renders one path-traced frame with kiss3d's GPU path tracer instead of + /// the rasterizer. Samples accumulate across calls while the scene is + /// static; call it several times (or raise `set_raytracer_samples_per_frame`) + /// before `render` to converge. Returns `False` when the window is closed. + fn raytrace_frame(&mut self) -> bool { + pollster::block_on(self.inner_mut().raytrace_frame()) + } + + /// Number of path-tracing samples accumulated per `raytrace_frame` call. + fn set_raytracer_samples_per_frame(&mut self, samples: u32) { + self.inner_mut().set_raytracer_samples_per_frame(samples); + } + + /// Maximum path-tracing bounce depth. + fn set_raytracer_max_bounces(&mut self, bounces: u32) { + self.inner_mut().set_raytracer_max_bounces(bounces); + } + + /// Enables/disables the path tracer's denoiser. + fn set_raytracer_denoise(&mut self, enabled: bool) { + self.inner_mut().set_raytracer_denoise(enabled); + } + /// Whether the simulation should advance this frame (honors play/pause/step). fn simulating(&mut self) -> bool { self.inner_mut().simulating() diff --git a/src_viewer/viewer.rs b/src_viewer/viewer.rs index f80623d..976d8aa 100644 --- a/src_viewer/viewer.rs +++ b/src_viewer/viewer.rs @@ -23,6 +23,8 @@ use khal::re_exports::wgpu::{Features, Limits}; use std::time::Duration; use kiss3d::prelude::Color; +#[cfg(feature = "dim3")] +use kiss3d::renderer::RayTracer; use kiss3d::scene::{SceneNode2d, SceneNode3d}; use kiss3d::window::{NumSamples, Window}; @@ -148,6 +150,10 @@ pub struct NexusViewer { /// Whether [`Self::render_frame`] draws the built-in egui panel. Disable /// for clean frame capture ([`Self::snap_rgb`]). draw_ui: bool, + /// Lazily-created path tracer for [`Self::raytrace_frame`]. Kept across + /// frames so samples keep accumulating while the scene is static. + #[cfg(feature = "dim3")] + raytracer: Option, pub ui: UiState, } @@ -222,6 +228,8 @@ impl NexusViewer { last_gpu_pass_times: Vec::new(), last_gpu_total_time_ms: 0.0, draw_ui: true, + #[cfg(feature = "dim3")] + raytracer: None, ui: UiState { run_state: RunState::Paused, run_stats: RunStats::default(), @@ -805,6 +813,54 @@ impl NexusViewer { self.draw_ui = enabled; } + /// Renders one path-traced frame of the viewer-owned scene with kiss3d's + /// GPU path tracer, instead of the rasterizer used by + /// [`Self::render_frame`]. + /// + /// The tracer accumulates samples across calls while the scene and camera + /// are static, and restarts accumulation automatically when either changes. + /// Call it several times per video frame (or raise + /// [`Self::set_raytracer_samples_per_frame`]) to converge before grabbing + /// the image with [`Self::snap_rgb`]. Returns `false` when the loop + /// should end (window closed). + #[cfg(feature = "dim3")] + pub async fn raytrace_frame(&mut self) -> bool { + let raytracer = self.raytracer.get_or_insert_with(RayTracer::new); + let cont = self + .window + .raytrace_3d(&mut self.scene3d, &mut self.camera3d, raytracer) + .await; + if !cont { + self.ui.transition = Some(Transition::Quit); + } + cont + } + + /// Number of path-tracing samples accumulated per [`Self::raytrace_frame`] + /// call (default: chosen by kiss3d from the GPU tier). + #[cfg(feature = "dim3")] + pub fn set_raytracer_samples_per_frame(&mut self, samples: u32) { + self.raytracer + .get_or_insert_with(RayTracer::new) + .set_samples_per_frame(samples); + } + + /// Maximum path-tracing bounce depth. + #[cfg(feature = "dim3")] + pub fn set_raytracer_max_bounces(&mut self, bounces: u32) { + self.raytracer + .get_or_insert_with(RayTracer::new) + .set_max_bounces(bounces); + } + + /// Enables/disables the path tracer's denoiser. + #[cfg(feature = "dim3")] + pub fn set_raytracer_denoise(&mut self, enabled: bool) { + self.raytracer + .get_or_insert_with(RayTracer::new) + .set_denoise(enabled); + } + /// Draws example-specific egui widgets into the current frame's UI pass. /// /// Call this once per frame, after [`Self::render_frame`], to overlay a From a85934a0ecb7d6184be796d68bbeb252e43f733f Mon Sep 17 00:00:00 2001 From: haixuantao Date: Wed, 8 Jul 2026 10:54:27 +0200 Subject: [PATCH 2/3] fix: force readback sync while the path tracer is active MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zero-readback sync kernel writes body poses straight into kiss3d's GPU instance buffers, but the ray tracer rebuilds its acceleration structure from the CPU-side instance data — so path-traced frames froze at the initial scene. Route sync through the readback path whenever a RayTracer exists. Co-Authored-By: Claude Fable 5 --- src_viewer/viewer.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src_viewer/viewer.rs b/src_viewer/viewer.rs index 976d8aa..abda294 100644 --- a/src_viewer/viewer.rs +++ b/src_viewer/viewer.rs @@ -340,10 +340,26 @@ impl NexusViewer { /// falls back to the GPU→CPU readback path. fn direct_render_path(&self) -> bool { self.webgpu_shared + && !self.rt_active() && self.ui.backend_type == BackendType::Gpu && matches!(self.webgpu, Some(KhalGpuBackend::WebGpu(_))) } + /// Whether the path tracer has been used/configured. The tracer rebuilds its + /// acceleration structure from the CPU-side instance buffers, so while it is + /// active `sync` must take the readback path — the zero-readback kernel only + /// updates kiss3d's GPU instance buffers, which the tracer never reads. + fn rt_active(&self) -> bool { + #[cfg(feature = "dim3")] + { + self.raytracer.is_some() + } + #[cfg(not(feature = "dim3"))] + { + false + } + } + #[cfg(feature = "cuda")] fn init_cuda(&mut self) -> Option { match khal::backend::cuda::Cuda::new(0) { From 4e5725dbb81b8746472bf579ee17820f3d30b85a Mon Sep 17 00:00:00 2001 From: haixuantao Date: Wed, 8 Jul 2026 12:38:58 +0200 Subject: [PATCH 3/3] feat: expose the path tracer's intersection backend to Python NexusViewer.raytracer_backend() returns "hardware" (RT-core ray queries) or "software" (compute-shader BVH fallback) so users can verify which backend kiss3d selected on their GPU. Co-Authored-By: Claude Fable 5 --- crates/nexus_python3d/src/viewer.rs | 6 ++++++ src_viewer/viewer.rs | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/crates/nexus_python3d/src/viewer.rs b/crates/nexus_python3d/src/viewer.rs index 16c691d..9cc4e60 100644 --- a/crates/nexus_python3d/src/viewer.rs +++ b/crates/nexus_python3d/src/viewer.rs @@ -194,6 +194,12 @@ impl NexusViewer { self.inner_mut().set_raytracer_denoise(enabled); } + /// Which intersection backend the path tracer uses: `"hardware"` (RT-core + /// ray queries) or `"software"` (portable compute-shader BVH fallback). + fn raytracer_backend(&mut self) -> &'static str { + self.inner_mut().raytracer_backend_name() + } + /// Whether the simulation should advance this frame (honors play/pause/step). fn simulating(&mut self) -> bool { self.inner_mut().simulating() diff --git a/src_viewer/viewer.rs b/src_viewer/viewer.rs index abda294..5b9773e 100644 --- a/src_viewer/viewer.rs +++ b/src_viewer/viewer.rs @@ -869,6 +869,25 @@ impl NexusViewer { .set_max_bounces(bounces); } + /// Which intersection backend the path tracer uses: hardware ray queries + /// (RT cores, when the device supports them) or the portable + /// compute-shader BVH fallback. + #[cfg(feature = "dim3")] + pub fn raytracer_backend(&mut self) -> kiss3d::renderer::RayBackend { + self.raytracer.get_or_insert_with(RayTracer::new).backend() + } + + /// [`Self::raytracer_backend`] as a string (`"hardware"` / `"software"`), + /// for consumers that don't depend on kiss3d directly (e.g. the Python + /// bindings). + #[cfg(feature = "dim3")] + pub fn raytracer_backend_name(&mut self) -> &'static str { + match self.raytracer_backend() { + kiss3d::renderer::RayBackend::Hardware => "hardware", + kiss3d::renderer::RayBackend::Software => "software", + } + } + /// Enables/disables the path tracer's denoiser. #[cfg(feature = "dim3")] pub fn set_raytracer_denoise(&mut self, enabled: bool) {