Skip to content

Visualization and Recording

NovaPhy exposes one renderer-independent lifecycle under novaphy.viewer. The same simulation driver can target an interactive ModernGL window, a bounded headless loop, an offline recording, or an optional remote viewer without changing the solver contract.

Install all optional viewer backends with:

python -m pip install -e ".[viewer]"

Run that command from a NovaPhy source checkout; there is no published PyPI package in the current distribution workflow.

ViewerNull and JSON ViewerFile recordings only require NovaPhy's core NumPy dependency. Individual optional backends fail with an actionable ImportError when their package is missing.

Common lifecycle

Every backend implements ViewerBase and follows the same frame calls:

import novaphy
from novaphy.viewer import ViewerGL

dt = 1.0 / 120.0
state_in = model.state()
state_out = model.state()
control = model.control()
collision_pipeline = novaphy.CollisionPipeline(model)
contacts = collision_pipeline.contacts()
viewer = ViewerGL(model=model, width=1280, height=720)

sim_time = 0.0
try:
    while viewer.is_running():
        if viewer.should_step():
            state_in.clear_forces()
            viewer.apply_forces(state_in)  # mouse picking / wind, when enabled
            collision_pipeline.collide(state_in, contacts)
            solver.step(state_in, state_out, control, contacts, dt)
            state_in, state_out = state_out, state_in
            sim_time += dt

        viewer.begin_frame(sim_time)
        viewer.log_state(state_in)
        viewer.end_frame()
finally:
    viewer.close()

should_step() respects pause and single-step state on interactive backends. Rendering should still run while paused so the window can process events. close() is important for flushing files and shutting down external viewer servers.

The shared logging surface also includes log_mesh(), log_instances(), log_lines(), log_points(), log_image(), log_array(), and log_scalar(). Backend support is intentionally asymmetric: for example, USD records geometry but ignores scalar and image logs.

Choose a backend

Backend Use it for Important behavior
ViewerGL Interactive desktop rendering, UI, screenshots, picking ModernGL/GLFW backend. Viewer and OpenGLViewer are aliases.
ViewerNull CI, tests, batch runs, timing No rasterization. num_frames bounds the loop; optional benchmark results are available after the run. NullViewer is an alias.
ViewerFile Experimental host snapshot serialization Writes .json; .bin additionally needs cbor2. Current C++ SimState scalar metadata has the limitation below.
ViewerRerun Rerun desktop/web streaming or .rrd capture Requires rerun-sdk; can connect to an address, serve a web viewer, or save with record_to_rrd.
ViewerUSD Time-sampled scene export Requires usd-core; writes meshes, instances, lines, and points to a USD stage.
ViewerViser Browser/Jupyter visualization and Viser recording Requires viser; texture paths also use the viewer extra's trimesh and Pillow dependencies. Collada/DAE meshes additionally use pycollada.

The default alias is deliberately explicit:

from novaphy import viewer

assert viewer.Viewer is viewer.ViewerGL

For applications with command-line backend selection, construct the desired class and then call set_model(model):

from novaphy import viewer

def make_viewer(name, model, *, frames=240, output_path="output.usd"):
    if name == "gl":
        result = viewer.ViewerGL()
    elif name == "null":
        result = viewer.ViewerNull(num_frames=frames)
    elif name == "file":
        result = viewer.ViewerFile("run.json", auto_save=False)
    elif name == "rerun":
        result = viewer.ViewerRerun()
    elif name == "usd":
        result = viewer.ViewerUSD(output_path, num_frames=frames)
    elif name == "viser":
        result = viewer.ViewerViser(browser=True)
    else:
        raise ValueError(f"unknown viewer backend: {name}")
    result.set_model(model)
    return result

This is the pattern used by the VBD demos, whose --viewer choices are gl, usd, rerun, null, and viser.

Headless execution

Use ViewerNull when no pixels are needed:

from novaphy.viewer import ViewerNull

viewer = ViewerNull(num_frames=600, benchmark=True)
viewer.set_model(model)

while viewer.is_running():
    if viewer.should_step():
        step_simulation()
    viewer.begin_frame(sim_time)
    viewer.log_state(state)
    viewer.end_frame()

result = viewer.benchmark_result()
viewer.close()

ViewerNull still runs model/state conversion and debug logging, so it is a useful API and data-path check. Its benchmark is viewer-loop timing, not a portable NovaPhy solver benchmark.

ViewerGL(headless=True) is different: it creates a rendering backend without a visible window and can return rasterized frames through get_frame(). It still needs a working OpenGL context. Prefer ViewerNull for display-free CI.

Recordings

NovaPhy snapshots

ViewerFile implements JSON/CBOR serialization, a bounded RingBuffer, and playback into caller-owned objects. The basic API is:

from types import SimpleNamespace
import numpy as np
from novaphy.viewer import ViewerFile

simple_array_state = SimpleNamespace(
    body_q=np.asarray(state.body_q, dtype=np.float32).copy(),
    body_qd=np.asarray(state.body_qd, dtype=np.float32).copy(),
)
recorder = ViewerFile("run.json", auto_save=False)
recorder.record(simple_array_state)
recorder.save_recording()

loaded = ViewerFile("run.json", auto_save=False)
loaded.load_recording()
loaded.load_state(simple_array_state, frame_id=0)

simple_array_state must expose serializable host array fields. JSON is self-contained and readable. Binary .bin recordings use CBOR and require pip install cbor2.

Current SimState recording limitation

A real C++ SimState exposes scalar metadata such as host_revision. ViewerFile.record() currently converts that scalar to a zero-dimensional NumPy array, which its serializer does not handle; log_state(sim_state) followed by save_recording() / close() therefore raises TypeError. Use ViewerUSD, ViewerRerun, or ViewerViser for their supported visualization/export output, or pass a Python snapshot object containing only the arrays you need. Do not rely on ViewerFile as a production checkpoint format yet.

USD, Rerun, and Viser

from novaphy.viewer import ViewerRerun, ViewerUSD, ViewerViser

usd = ViewerUSD("trajectory.usda", fps=60, num_frames=300, model=model)
rerun = ViewerRerun(model=model, record_to_rrd="trajectory.rrd")
viser = ViewerViser(model=model, browser=False, record_to="trajectory.viser")

Drive each object with the common frame lifecycle. ViewerUSD.close() saves the stage, ViewerRerun.close() disconnects the stream, and ViewerViser.close() saves an active recording and stops the server.

These backends are visualization/export sinks; their apply_forces() methods do not provide mouse interaction.

Picking and force application

ViewerGL.set_model() creates a Picking helper automatically. Right-click a dynamic rigid body or supported soft-particle surface, drag to update its target, and release to stop. The driver must call viewer.apply_forces(state) after state.clear_forces() and before solver.step(...), as shown in the lifecycle example.

Useful controls include:

viewer.picking_enabled = True
viewer.set_picking_linear_only_bodies([base_body])
# Later:
viewer.clear_picking_linear_only_bodies()

Linear-only bodies receive no picking torque. Kinematic rigid bodies are not pickable. Advanced integrations may construct Picking(model) directly and call pick(state, ray_start, ray_dir), update(...), and release(), but the GL backend already wires these calls to its mouse events.

CPU states use NovaPhy's model ray caster. CUDA device states require the GL renderer's CUDA picking/upload support. If a required CUDA path is absent, the viewer raises a RuntimeError instead of silently copying the complete state to the host.

CPU and CUDA data boundaries

All backends accept host NumPy data. ViewerGL additionally has direct CUDA/GL paths for supported state, particle, contact, and geometry buffers. Coverage is not universal: an unsupported CUDA DeviceArray logging path fails explicitly. Rerun, USD, Viser, and file recording are host-oriented and may snapshot device data to the CPU.

Selecting a viewer never selects a physics device. Configure the model and solver device independently, and treat any viewer readback as a potential synchronization point when measuring GPU simulation performance.

Legacy Polyscope compatibility

novaphy.viz and the novaphy[viz] extra remain available for compatibility. The IPC showcases keep their existing Polyscope compatibility path. The retained non-IPC demos, including dam break, unified collision, and the fluid examples, now use the common ViewerGL/ViewerNull lifecycle.

New drivers should use novaphy.viewer. See python/demos/demo_viewer_api_basic.py for the basic GL/null path and python/demos/demo_ik_arm.py for ViewerGL gizmo interaction.

See also