Skip to content

Visualization

ViserViewer is a browser-based viewer, built on Viser, for inspecting an env's scene and link frames outside the Genesis viewer. It can also record a rollout to a self-contained .html page (or a .viser file) that replays in a browser. Install it with the vis extra (pip install 'eden-world[vis]', or uv sync --inexact --extra vis in a checkout). The sensors and rendering guide shows recording; examples/recipes/rendering/viser_recording.py is a runnable example.

from eden.extensions.visualization.viser import ViserViewer

eden/extensions/visualization/ has no __init__.py, so the docs build finds the class below through its eden/extensions search path, and any module path it shows reads visualization.viser. Import it as above.

ViserViewer

ViserViewer(env: EnvBase, host: str = 'localhost', port: int = 8080, enable_gui: bool = True)

Viser visualizer for Eden.

This extension allows visualizing Eden environments using the Viser library.

Source code in eden/extensions/visualization/viser.py
def __init__(
    self,
    env: EnvBase,
    host: str = "localhost",
    port: int = 8080,
    enable_gui: bool = True,
):
    self.env = env
    self.host = host
    self.port = port
    self.enable_gui = enable_gui

    self.server = viser.ViserServer(host=host, port=port)
    self.server.scene.set_up_direction("+z")
    self.gui_elements = {}

    self._entity_handles: dict[str, LinkHandle] = {}
    self._collision_entity_handles: dict[str, LinkHandle] = {}
    self._handles_lock = threading.Lock()

    self.collision_mesh_enabled = False

    self._built = False

    self._gui_event_state = {}
    self._recording: StateSerializer | None = None
    self._recording_path: Path | None = None
    self._recording_dt = 0.0

Attributes

env instance-attribute

env = env

host instance-attribute

host = host

port instance-attribute

port = port

enable_gui instance-attribute

enable_gui = enable_gui

server instance-attribute

server = viser.ViserServer(host=host, port=port)

gui_elements instance-attribute

gui_elements = {}

collision_mesh_enabled instance-attribute

collision_mesh_enabled = False

envs_offset property

envs_offset: ndarray

is_recording property

is_recording: bool

Whether viewer updates are being recorded.

Methods:

build

build()

Build the visualization scene from the Eden environment.

Source code in eden/extensions/visualization/viser.py
def build(self):
    """Build the visualization scene from the Eden environment."""
    if self._built:
        return

    if self.enable_gui:
        self._setup_gui()

    for name, entity in self.env.entities.items():
        self.add_entity(name, entity)

    self._built = True

add_entity

add_entity(name: str, entity: Entity, use_visual_mesh: bool = True)
Source code in eden/extensions/visualization/viser.py
def add_entity(self, name: str, entity: Entity, use_visual_mesh: bool = True):
    # Check if entity is heterogeneous (grouped entity with multiple morphs)
    if entity.is_heterogeneous:
        self._add_heterogeneous_entity(name, entity, use_visual_mesh)
    elif isinstance(entity.morph, (URDF, MJCF, USD)):
        self._add_articulated_entity(name, entity, use_visual_mesh)
    elif isinstance(entity.morph, Plane):
        self._add_mesh_entity(name, entity, use_visual_mesh, is_batched=False)
    elif isinstance(entity.morph, (Mesh, Primitive)):
        self._add_mesh_entity(name, entity, use_visual_mesh)
    else:
        raise ValueError(f"Entity type not supported: {type(entity.morph)}")

add_point_cloud

add_point_cloud(name: str, points: ndarray, colors: ndarray)
Source code in eden/extensions/visualization/viser.py
def add_point_cloud(self, name: str, points: np.ndarray, colors: np.ndarray):
    self.server.scene.add_point_cloud(
        name=name,
        points=points,
        colors=colors,
        point_size=0.001,
    )

start_recording

start_recording(output_path: str | Path, fps: float | None = None) -> None

Record the current scene and subsequent updates to a .html or .viser file.

Each update advances playback by env.dt, or by 1 / fps when supplied. HTML exports include the interactive player and scene data. Call from the simulation thread, alongside update and stop_recording.

Everything knowable up front is checked here rather than at export time -- the suffix, the frame duration, that Viser can render the requested format, and that the output directory is writable. Viser's serializer is single-use, so an export that only fails once the run is over has lost the trajectory.

Source code in eden/extensions/visualization/viser.py
def start_recording(self, output_path: str | Path, fps: float | None = None) -> None:
    """Record the current scene and subsequent updates to a .html or .viser file.

    Each update advances playback by ``env.dt``, or by ``1 / fps`` when
    supplied. HTML exports include the interactive player and scene data.
    Call from the simulation thread, alongside ``update`` and ``stop_recording``.

    Everything knowable up front is checked here rather than at export time --
    the suffix, the frame duration, that Viser can render the requested format,
    and that the output directory is writable. Viser's serializer is single-use,
    so an export that only fails once the run is over has lost the trajectory.
    """
    if self.is_recording:
        raise RuntimeError("A Viser recording is already active; call stop_recording() first.")
    path = Path(output_path)
    suffix = path.suffix.lower()
    if suffix not in {".html", ".viser"}:
        raise ValueError("Viser recording output must end in .html or .viser.")
    if fps is not None and (not math.isfinite(fps) or fps <= 0):
        raise ValueError("fps must be finite and positive.")
    dt = self.env.dt if fps is None else 1.0 / fps
    if not math.isfinite(dt) or dt <= 0:
        raise ValueError("Recording frame duration must be finite and positive.")
    if suffix == ".html":
        self._check_html_exporter()
    if path.is_dir():
        raise IsADirectoryError(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    # Fail loudly now if the output directory is not writable, rather than losing a
    # whole recording when it is exported on stop. The directory is the only thing
    # that needs to be writable: ``stop_recording`` renames a sibling temporary file
    # into place, which never opens the destination itself. Mirrors the same check in
    # ``viewer_plugins/mesh_point_selector.py``.
    out_dir = path.parent.resolve()
    if not os.access(out_dir, os.W_OK):
        raise ValueError(f"Cannot record to '{path}': directory '{out_dir}' is not writable.")
    # Seed the initial frame with the current state. Deliberately not ``update()``:
    # that drains the GUI event state, so a reset clicked just before recording
    # started would be consumed here and never reported to the simulation loop.
    if not self._built:
        self.build()
    self._refresh_handles()
    self._recording_path = path
    self._recording_dt = dt
    self._recording = self.server.get_scene_serializer()

stop_recording

stop_recording() -> Path

Save the recording and return its path; fail if no recording is active.

The export is atomic: it writes a sibling temporary file and renames it into place, so a failure leaves the destination -- including a recording already saved there -- untouched instead of truncated. Viser's serializer is single-use, so a failed export ends the recording; that is what the checks in :meth:start_recording are there to make unlikely.

Source code in eden/extensions/visualization/viser.py
def stop_recording(self) -> Path:
    """Save the recording and return its path; fail if no recording is active.

    The export is atomic: it writes a sibling temporary file and renames it into
    place, so a failure leaves the destination -- including a recording already
    saved there -- untouched instead of truncated. Viser's serializer is single-use,
    so a failed export ends the recording; that is what the checks in
    :meth:`start_recording` are there to make unlikely.
    """
    if self._recording is None:
        raise RuntimeError("No Viser recording is active; call start_recording() first.")
    # Retire the handle up front: ``as_html``/``serialize`` consume it, so nothing below
    # can be retried and leaving it in place would only turn a second call into a
    # confusing "serialize() was already called!" from inside Viser.
    recording, self._recording = self._recording, None
    path, self._recording_path = self._recording_path, None
    assert path is not None
    tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
    try:
        if path.suffix.lower() == ".html":
            tmp.write_text(recording.as_html(), encoding="utf-8")
        else:
            tmp.write_bytes(recording.serialize())
        os.replace(tmp, path)
    except OSError as e:
        tmp.unlink(missing_ok=True)
        raise RuntimeError(
            f"Failed to save the Viser recording to '{path}': {e}. Viser's serializer is "
            "single-use, so this recording cannot be exported again."
        ) from e
    return path

update

update()

Update the visualization with current simulation state.

Source code in eden/extensions/visualization/viser.py
def update(self):
    """Update the visualization with current simulation state."""
    if not self._built:
        self.build()

    if self._recording is not None:
        self._recording.insert_sleep(self._recording_dt)

    self._refresh_handles()

    gui_info = {
        "reset": self._gui_event_state.get("reset_triggered", False),
    }
    self._gui_event_state["reset_triggered"] = False
    return gui_info

close

close()

Save any active recording and stop the Viser server.

Source code in eden/extensions/visualization/viser.py
def close(self):
    """Save any active recording and stop the Viser server."""
    try:
        if self.is_recording:
            self.stop_recording()
    finally:
        self.server.stop()

See also