Skip to content

Add NexusViewer.render() to export frames off-screen#7

Open
haixuanTao wants to merge 4 commits into
dimforge:mainfrom
haixuanTao:feat/viewer-render-export
Open

Add NexusViewer.render() to export frames off-screen#7
haixuanTao wants to merge 4 commits into
dimforge:mainfrom
haixuanTao:feat/viewer-render-export

Conversation

@haixuanTao

Copy link
Copy Markdown
Contributor

The viewer could only present to its window; there was no way to get rendered pixels back to Python for headless frame/video export (as mujoco.Renderer.render() or a Genesis camera.render() would give).

kiss3d's Window already exposes snap_image(), so this just wires it through:

  • nexus_viewer3d::NexusViewer::snap_rgb() -> (w, h, RGB bytes)
  • Python NexusViewer.render() -> (H, W, 3) uint8 NumPy array

Call render_frame() to draw, then render() to read the frame back.

The viewer could only present to its window; there was no way to get
rendered pixels back to Python for headless frame/video export (as
mujoco.Renderer.render() or a Genesis camera.render() would give).

kiss3d's Window already exposes snap_image(), so this just wires it
through:
- nexus_viewer3d::NexusViewer::snap_rgb() -> (w, h, RGB bytes)
- Python NexusViewer.render() -> (H, W, 3) uint8 NumPy array

Call render_frame() to draw, then render() to read the frame back.
@haixuanTao

Copy link
Copy Markdown
Contributor Author

Heads-up: render() is correctness-first — per-frame readback is slow (a kiss3d-level concern)

Profiling this on an RTX 5080 (release build, 1200×900, single-cube scene), render() works but is throughput-limited. Per-frame averages:

┌───────────────────────────────────────┬──────────┐
│ Call │ ms/frame │
├───────────────────────────────────────┼──────────┤
│ render_frame() (GPU render + present) │ 2.9 │
├───────────────────────────────────────┼──────────┤
│ simulate() (physics) │ 1.8 │
├───────────────────────────────────────┼──────────┤
│ sync() (state readback) │ 0.1 │
├───────────────────────────────────────┼──────────┤
│ render() (framebuffer readback) │ 365.6 │
└───────────────────────────────────────┴──────────┘

So the render/physics are fast (~3 ms, consistent with the viewer's "GPU passes" stat); the cost is entirely the GPU→CPU framebuffer readback. Notably, release ≈ debug (2.68 vs 2.36 fps overall) — i.e. it's a synchronization/latency cost, not CPU compute.

Root cause is in kiss3d's read_pixels (window/wgpu_canvas.rs), which snap_image() calls: every invocation

  • allocates a fresh staging buffer,
  • submits a texture→buffer copy, then does a device.poll(wait_indefinitely()) — a full-device stall with no pipelining, so you pay the entire submit→fence→map round-trip latency serialized per frame,
  • then converts BGRA→RGB, de-pads rows, and flips vertically on the CPU.

That's ideal for the occasional screenshot it was designed for, but expensive at video frame rates.

This PR is still worth landing as-is — it makes headless frame export possible, which it wasn't before, and it's fine for offline/short-clip capture. Making it real-time is a separate kiss3d optimization (out of scope here): reuse a persistent staging buffer, pipeline the readback (map frame N‑1 while the GPU renders N, dropping wait_indefinitely), and move/skip the color conversion. Happy to open that as a follow-up on kiss3d if useful.

haixuanTao and others added 2 commits July 8, 2026 11:55
Adds NexusViewer::new_with_size and exposes width/height (default
1200x900) on the Python constructor, so headless capture and path
tracing can run at arbitrary resolutions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
render_frame always drew the egui main panel, which then appeared in
snap_rgb captures. Expose a toggle (default on) so headless recording
gets clean frames.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@sebcrozet sebcrozet left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a small nitpick but that looks good, thank you!

Comment thread crates/nexus_python3d/src/viewer.rs Outdated
/// Call once per frame after [`render_frame`][Self::render_frame] to export
/// frames off-screen (e.g. to encode a video) instead of only presenting to
/// the window.
fn render<'py>(&mut self, py: Python<'py>) -> PyResult<Bound<'py, PyArray3<u8>>> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can keep the name of what’s it’s binding.

Suggested change
fn render<'py>(&mut self, py: Python<'py>) -> PyResult<Bound<'py, PyArray3<u8>>> {
fn snap_rgb<'py>(&mut self, py: Python<'py>) -> PyResult<Bound<'py, PyArray3<u8>>> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — renamed to snap_rgb in d970edb, and propagated through the stacked branches (#8, #11): the pipelined variants there are now snap_rgb_async()/snap_rgb_flush() to match.

haixuanTao added a commit to haixuanTao/nexus that referenced this pull request Jul 8, 2026
…ewer, pipelined readback

Makes the nexus3d Python bindings usable for off-screen video capture:

- NexusViewer(width, height): configurable resolution; set_draw_ui(False)
  keeps the egui panel out of captured frames.
- NexusViewer.render() -> (H, W, 3) uint8 numpy frame export, like
  mujoco.Renderer.render().
- raytrace_frame() + set_raytracer_samples_per_frame/max_bounces/denoise
  + raytracer_backend(): kiss3d's GPU path tracer from Python, with
  progressive sample accumulation. sync takes the CPU-readback path while
  the tracer is active (the zero-readback kernel only updates the
  rasterizer's instance buffers, which the tracer's BVH never reads).
- NexusViewer(..., headless=True): no OS window, no swapchain — frames
  render into an off-screen texture, never throttled by display vsync,
  works without a display server.
- set_vsync(enabled)/vsync(): uncap a windowed viewer instead.
- render_async()/render_flush(): pipelined capture — returns the previous
  frame while the current frame's GPU->CPU copy runs in the background.

A windowed capture loop was vsync-locked at ~30 fps (blocking readback +
Fifo swapchain = ~2 vblanks per iteration); headless + pipelined readback
reaches 92 gen-fps at 640x480 (GPU physics) on an RTX 5080.

Requires the kiss3d headless-capture branch (dimforge/kiss3d#398).
Supersedes dimforge#7 and dimforge#8 (included here).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants