From c8c558e50b43cec6a328c056c32c5f728e07a600 Mon Sep 17 00:00:00 2001 From: haixuantao Date: Wed, 8 Jul 2026 21:48:00 +0200 Subject: [PATCH 1/3] feat: headless viewer, vsync control, and pipelined frame capture (Python) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The windowed capture loop was vsync-locked: kiss3d's swapchain defaults to AutoVsync and the blocking per-frame snap_rgb() readback defeats frame pipelining, so each render_frame+render iteration ate ~2 vblanks (~30 fps at 60 Hz) no matter how fast the GPU was. Fixes, exposed through nexus3d: - NexusViewer(width, height, headless=True): no OS window, no swapchain — frames render into an off-screen texture (kiss3d headless Window), never throttled by the display and usable without a display server. - NexusViewer.set_vsync(enabled)/vsync(): uncap a windowed viewer instead. - NexusViewer.render_async()/render_flush(): pipelined capture — returns the previous frame while this frame's GPU->CPU copy runs in the background (one frame of latency; flush collects the last frame). Cube-drop capture at 640x480: 33.1 -> 92.1 gen-fps (GPU physics), 26.5 -> 37.5 (Rapier CPU backend). Requires kiss3d feat/headless-capture-pipeline (new_headless_with_setup + snap_begin/snap_finish). --- crates/nexus_python3d/src/viewer.rs | 70 ++++++++++++++++++++++++----- src_viewer/viewer.rs | 61 ++++++++++++++++++++++--- 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/crates/nexus_python3d/src/viewer.rs b/crates/nexus_python3d/src/viewer.rs index 9cc4e60..ded138e 100644 --- a/crates/nexus_python3d/src/viewer.rs +++ b/crates/nexus_python3d/src/viewer.rs @@ -52,6 +52,18 @@ impl NexusViewer { pub fn backend(&self) -> &GpuBackend { self.inner().backend() } + + /// Wraps raw RGB pixels as an `(H, W, 3)` numpy array. + fn to_array( + py: Python<'_>, + w: u32, + h: u32, + rgb: Vec, + ) -> PyResult>> { + rgb.into_pyarray(py) + .reshape([h as usize, w as usize, 3]) + .map_err(|e| PyRuntimeError::new_err(format!("{e:?}"))) + } } #[pymethods] @@ -61,14 +73,32 @@ impl NexusViewer { /// `width`/`height` set the window and render-target resolution /// (default 1200x900). Must be called on the main thread (required by the /// OS windowing system). + /// + /// With `headless=True` no OS window (and no swapchain) is created: + /// frames render into an off-screen texture, unthrottled by the display's + /// refresh rate — the fast path for video capture, and the only path on + /// machines without a display server. #[new] - #[pyo3(signature = (width=1200, height=900))] - fn new(width: u32, height: u32) -> Self { - NexusViewer(Some(pollster::block_on(RViewer::new_with_size( - Vec::new(), - width, - height, - )))) + #[pyo3(signature = (width=1200, height=900, headless=false))] + fn new(width: u32, height: u32, headless: bool) -> Self { + let inner = if headless { + pollster::block_on(RViewer::new_headless_with_size(Vec::new(), width, height)) + } else { + pollster::block_on(RViewer::new_with_size(Vec::new(), width, height)) + }; + NexusViewer(Some(inner)) + } + + /// Whether presentation is vsync-locked (always `False` headless). + fn vsync(&self) -> bool { + self.inner().vsync() + } + + /// Enables/disables vsync. With vsync off, `render_frame` no longer waits + /// for the display refresh (~60 Hz), so windowed capture runs as fast as + /// the GPU allows. No-op on a headless viewer. + fn set_vsync(&mut self, enabled: bool) { + self.inner_mut().set_vsync(enabled); } // --- backend selection (fluent) -------------------------------------- @@ -213,9 +243,29 @@ impl NexusViewer { /// the window. fn snap_rgb<'py>(&mut self, py: Python<'py>) -> PyResult>> { let (w, h, rgb) = self.inner_mut().snap_rgb(); - rgb.into_pyarray(py) - .reshape([h as usize, w as usize, 3]) - .map_err(|e| PyRuntimeError::new_err(format!("{e:?}"))) + Self::to_array(py, w, h, rgb) + } + + /// Pipelined variant of [`snap_rgb`][Self::snap_rgb] for video capture: starts + /// a non-blocking capture of the frame just rendered and returns the + /// *previous* frame's pixels (one frame of latency), or `None` on the + /// first call. Unlike `snap_rgb` this never stalls the GPU pipeline waiting + /// for the copy. Call [`snap_rgb_flush`][Self::snap_rgb_flush] after the loop + /// to collect the final frame. + fn snap_rgb_async<'py>(&mut self, py: Python<'py>) -> PyResult>>> { + match self.inner_mut().snap_rgb_async() { + Some((w, h, rgb)) => Ok(Some(Self::to_array(py, w, h, rgb)?)), + None => Ok(None), + } + } + + /// Completes and returns the capture left in flight by + /// [`snap_rgb_async`][Self::snap_rgb_async], or `None` when there is none. + fn snap_rgb_flush<'py>(&mut self, py: Python<'py>) -> PyResult>>> { + match self.inner_mut().snap_rgb_flush() { + Some((w, h, rgb)) => Ok(Some(Self::to_array(py, w, h, rgb)?)), + None => Ok(None), + } } /// Reads GPU state back into the renderer. Call once per frame after diff --git a/src_viewer/viewer.rs b/src_viewer/viewer.rs index 5b9773e..770ea0c 100644 --- a/src_viewer/viewer.rs +++ b/src_viewer/viewer.rs @@ -168,13 +168,32 @@ impl NexusViewer { /// Like [`Self::new`] but with an explicit window/render resolution. pub async fn new_with_size(demos: Vec<(String, DemoKind)>, width: u32, height: u32) -> Self { - // NOTE: PASSTHROUGH_SHADERS is required for compute shaders with spirv_passthrough on - // platforms running vulkan (to work around some naga limitations). - let setup = kiss3d::window::CanvasSetup { + let window = Window::new_with_setup("nexus demos", width, height, Self::setup()).await; + Self::with_window(window, demos).await + } + + /// Like [`Self::new_with_size`] but headless: no OS window, no swapchain, + /// no vsync — frames render straight into an off-screen texture, which is + /// what you want for video capture or on a machine with no display server. + pub async fn new_headless_with_size( + demos: Vec<(String, DemoKind)>, + width: u32, + height: u32, + ) -> Self { + let window = Window::new_headless_with_setup(width, height, Self::setup()).await; + Self::with_window(window, demos).await + } + + // NOTE: PASSTHROUGH_SHADERS is required for compute shaders with spirv_passthrough on + // platforms running vulkan (to work around some naga limitations). + fn setup() -> kiss3d::window::CanvasSetup { + kiss3d::window::CanvasSetup { required_features: Features::PASSTHROUGH_SHADERS, ..Default::default() - }; - let mut window = Window::new_with_setup("nexus demos", width, height, setup).await; + } + } + + async fn with_window(mut window: Window, demos: Vec<(String, DemoKind)>) -> Self { window.set_background_color(Color::new(245.0 / 255.0, 245.0 / 255.0, 236.0 / 255.0, 1.0)); // Disable MSAA, this puts extra load on the GPU that ends up // falsifying the gpu physics timestamps. @@ -823,6 +842,38 @@ impl NexusViewer { (img.width(), img.height(), img.into_raw()) } + /// Pipelined frame capture: completes the capture started by the previous + /// call (returning *that* frame, one frame late) and starts a non-blocking + /// capture of the frame just rendered. Returns `None` on the first call. + /// Unlike [`Self::snap_rgb`] this never stalls waiting for the GPU, since + /// the copy of frame N is collected only after frame N+1 was rendered. + /// Call [`Self::snap_rgb_flush`] after the loop to collect the last frame. + pub fn snap_rgb_async(&mut self) -> Option<(u32, u32, Vec)> { + let prev = self.snap_rgb_flush(); + self.window.snap_begin(); + prev + } + + /// Completes and returns a capture left in flight by + /// [`Self::snap_rgb_async`], or `None` when there is none. + pub fn snap_rgb_flush(&mut self) -> Option<(u32, u32, Vec)> { + let img = self.window.snap_finish()?; + Some((img.width(), img.height(), img.into_raw())) + } + + /// Whether presentation is vsync-locked (windowed viewers only; always + /// `false` headless). + pub fn vsync(&self) -> bool { + self.window.vsync() + } + + /// Enables/disables vsync on the window's swapchain. With vsync off, + /// [`Self::render_frame`] no longer blocks on the display refresh — + /// essential when capturing video faster than real time. No-op headless. + pub fn set_vsync(&mut self, enabled: bool) { + self.window.set_vsync(enabled); + } + /// Whether [`Self::render_frame`] draws the built-in egui panel (default /// `true`). Disable for clean frame capture. pub fn set_draw_ui(&mut self, enabled: bool) { From 2185c947b9d33466da2ae9de0839f852903d65b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 16 Jul 2026 14:01:22 +0200 Subject: [PATCH 2/3] chore: bump kiss3d version 0.45.1 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index ffaab53..b997730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ parry2d = { version = "0.29", default-features = false } parry3d = { version = "0.29", default-features = false } # Viewer / examples deps -kiss3d = "0.45" +kiss3d = "0.45.1" Inflector = "0.11" oorandom = "11" anyhow = "1" From 370d4afe7deb757918aad94e6bc39c3eb76a72ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Crozet?= Date: Thu, 16 Jul 2026 14:02:28 +0200 Subject: [PATCH 3/3] chore: cargo fmt --- crates/nexus_python3d/src/viewer.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/nexus_python3d/src/viewer.rs b/crates/nexus_python3d/src/viewer.rs index ded138e..d5e6983 100644 --- a/crates/nexus_python3d/src/viewer.rs +++ b/crates/nexus_python3d/src/viewer.rs @@ -54,12 +54,7 @@ impl NexusViewer { } /// Wraps raw RGB pixels as an `(H, W, 3)` numpy array. - fn to_array( - py: Python<'_>, - w: u32, - h: u32, - rgb: Vec, - ) -> PyResult>> { + fn to_array(py: Python<'_>, w: u32, h: u32, rgb: Vec) -> PyResult>> { rgb.into_pyarray(py) .reshape([h as usize, w as usize, 3]) .map_err(|e| PyRuntimeError::new_err(format!("{e:?}"))) @@ -252,7 +247,10 @@ impl NexusViewer { /// first call. Unlike `snap_rgb` this never stalls the GPU pipeline waiting /// for the copy. Call [`snap_rgb_flush`][Self::snap_rgb_flush] after the loop /// to collect the final frame. - fn snap_rgb_async<'py>(&mut self, py: Python<'py>) -> PyResult>>> { + fn snap_rgb_async<'py>( + &mut self, + py: Python<'py>, + ) -> PyResult>>> { match self.inner_mut().snap_rgb_async() { Some((w, h, rgb)) => Ok(Some(Self::to_array(py, w, h, rgb)?)), None => Ok(None), @@ -261,7 +259,10 @@ impl NexusViewer { /// Completes and returns the capture left in flight by /// [`snap_rgb_async`][Self::snap_rgb_async], or `None` when there is none. - fn snap_rgb_flush<'py>(&mut self, py: Python<'py>) -> PyResult>>> { + fn snap_rgb_flush<'py>( + &mut self, + py: Python<'py>, + ) -> PyResult>>> { match self.inner_mut().snap_rgb_flush() { Some((w, h, rgb)) => Ok(Some(Self::to_array(py, w, h, rgb)?)), None => Ok(None),