Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
71 changes: 61 additions & 10 deletions crates/nexus_python3d/src/viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ 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<u8>) -> PyResult<Bound<'_, PyArray3<u8>>> {
rgb.into_pyarray(py)
.reshape([h as usize, w as usize, 3])
.map_err(|e| PyRuntimeError::new_err(format!("{e:?}")))
}
}

#[pymethods]
Expand All @@ -61,14 +68,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) --------------------------------------
Expand Down Expand Up @@ -213,9 +238,35 @@ impl NexusViewer {
/// the window.
fn snap_rgb<'py>(&mut self, py: Python<'py>) -> PyResult<Bound<'py, PyArray3<u8>>> {
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<Option<Bound<'py, PyArray3<u8>>>> {
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<Option<Bound<'py, PyArray3<u8>>>> {
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
Expand Down
61 changes: 56 additions & 5 deletions src_viewer/viewer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<u8>)> {
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<u8>)> {
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) {
Expand Down
Loading