Attaching a camera#
A worked tour of the Genesis ↔ Nyx integration surface. The script mounts a Nyx camera on a Franka panda’s wrist, records the wrist view as an MP4 while the robot performs a short pick, and never once touches plugin-internal code, every interaction goes through stock Genesis APIs.
The integration in one sentence#
Nyx ships its renderer as a standard Genesis sensor. NyxCameraOptions derives from genesis.options.sensors.camera.BaseCameraOptions, NyxCameraSensor derives from genesis.engine.sensors.camera.BaseCameraSensor. Everything else, attachment, lifecycle, recording, follows from that.
Where the plugin meets Genesis#
Five hand-offs happen in this example. They are the entire integration contract; nothing in user code is plugin-private.
1. Sensor registration — scene.add_sensor#
cam = scene.add_sensor(NyxCameraOptions(
res = (1280, 720),
fov = 60.0,
near = 0.02,
far = 50.0,
entity_idx = franka.idx,
link_idx_local = franka.get_link("hand").idx_local,
offset_T = WRIST_OFFSET_T,
spp = 32,
render_mode = npr.ERenderMode.FastPathTracer,
env_maps = (env_map,),
))
scene.add_sensor is Genesis’ built-in sensor factory. It uses the type of the options object to pick a sensor class, NyxCameraOptions resolves to NyxCameraSensor via the typed generic in its base class. The sensor is then held by Genesis’ SensorManager and gets the same lifecycle (build, step, read) as a contact sensor, an IMU, or any other built-in.
2. Link attachment — Genesis-owned, not Nyx#
entity_idx, link_idx_local, and offset_T are inherited from the KinematicSensorOptionsMixin Genesis ships. When you set them, Genesis’ SensorManager calls move_to_attach() on the sensor every time the scene steps. move_to_attach() reads the link’s world transform from the rigid solver, composes it with offset_T, and feeds the result back into the Nyx renderer as the camera pose. The plugin only implements the small _apply_camera_transform callback that Genesis invokes with the final 4×4 matrix.
Net effect: the wrist camera tracks the gripper for free. There is no cam.update_pose() loop in the example, and there couldn’t be, the per-step camera transform is owned by the Genesis side.
3. SDK lifecycle — gs.register_external_module#
# excerpt from gs_nyx_plugin/nyx_camera_options.py
gs.register_external_module(nps.startup, nps.shutdown)
The plugin registers its SDK start / stop pair with Genesis at import time. gs.init() then walks its registry and calls every registered startup; gs.destroy() (or interpreter teardown) does the symmetric shutdown.
This is also why mocking genesis for docs builds works, the plugin doesn’t have a hard dependency on a running Nyx SDK to be importable, only to be used.
4. Reading triggers rendering — cam.read()#
scene.start_recording(
data_func = lambda: cam.read().rgb,
rec_options = gs.recorders.VideoFile(filename=OUTPUT_PATH, fps=FPS),
)
cam.read() is the Genesis sensor read API. For NyxCameraSensor the read is lazy: it asks Genesis’ base machinery “am I stale for the current sim step?”, and only if the answer is yes calls _render_current_state(). That method does the actual Genesis ↔ Nyx glue:
scene.visualizer.update_visual_states()(Genesis) syncs visual transforms from the solvers.move_to_attach()(Genesis, on each attached sensor) refreshes the camera pose from the link.The Nyx renderer’s
update_scene/renderare then called with the up-to-date state.The frame is wrapped in a
NyxCameraDatanamed-tuple. Because the scene was built withoutn_envs,cam.read().rgbis a(H, W, 3)uint8CUDA tensor; withscene.build(n_envs=N)the same field comes out as(N, H, W, 3)and you’d index[0], that’s the single change between this script and01_hello_nyx.py.
User code never calls a render function explicitly. One scene.step() followed by one cam.read() is the whole loop.
5. Recording — stock Genesis pipeline#
scene.start_recording and gs.recorders.VideoFile are both Genesis, not Nyx. The Genesis RecorderManager invokes data_func after each step, hands the returned (H, W, 3) uint8 array to the VideoFile writer, and the writer pipes frames through PyAV to an H.264 MP4. The Nyx camera is just a data source, swap it for a Genesis rasterizer camera and the same recorder code works unchanged.
Why the timing lines up#
dt = 1 / FPS ties simulation time to video time: one scene.step() produces exactly one frame of the recording, so the MP4 plays back at the same speed the robot moved. If you raise FPS, drop dt correspondingly; the recorder doesn’t care about real wall-clock time, only step counts.
Source#
1"""Attached camera example for the Nyx renderer plugin.
2
3Mounts a ``NyxCameraOptions`` on the Franka panda's wrist via Genesis'
4built-in sensor-attachment fields (``entity_idx`` / ``link_idx_local`` /
5``offset_T``), then streams its RGB output to an MP4 through
6``scene.start_recording`` while the robot performs a short pick. The camera
7follows the link automatically every step, no plugin-specific glue.
8
9Usage:
10 uv run python examples/02_attached_camera.py
11"""
12
13from __future__ import annotations
14
15import os
16
17import numpy as np
18
19import genesis as gs
20import gs_nyx.nyx_py_renderer as npr
21from gs_nyx_plugin.nyx_camera_options import NyxCameraOptions
22
23
24HERE = os.path.dirname(__file__)
25OUTPUT_PATH = os.path.join(HERE, "out", "02_attached_camera.mp4")
26
27FPS = 30
28
29# Wrist camera pose in the Franka "hand" link frame. The camera sits at
30# (10 cm, 8 cm, 0) in the hand frame and is rotated to look at the gripper
31# fingertip at (0, 0, 10 cm) — the rotation is a standard look-at with
32# hand -Z as the up hint, which is world up during the grasp because the
33# wrist's R_x(180°) pose flips Z between hand and world. That keeps the
34# closing jaws centered as the gripper descends onto the cube.
35WRIST_OFFSET_T = np.array(
36 [
37 [ 0.624695, -0.480604, 0.615457, 0.10],
38 [-0.780869, -0.384483, 0.492366, 0.08],
39 [ 0.000000, -0.788170, -0.615457, 0.00],
40 [ 0.000000, 0.000000, 0.000000, 1.00],
41 ],
42 dtype=np.float64,
43)
44
45
46def main() -> None:
47 gs.init(backend=gs.cpu)
48 os.makedirs(os.path.dirname(OUTPUT_PATH), exist_ok=True)
49
50 scene = gs.Scene(
51 sim_options=gs.options.SimOptions(dt=1.0 / FPS, substeps=3),
52 show_viewer=False,
53 )
54
55 scene.add_entity(gs.morphs.Plane())
56 scene.add_entity(gs.morphs.Box(size=(0.04, 0.04, 0.04), pos=(0.65, 0.0, 0.02)), surface=gs.surfaces.Gold(roughness=0.1))
57 franka = scene.add_entity(gs.morphs.MJCF(file="xml/franka_emika_panda/panda.xml"))
58
59 # Camera intrinsics + attachment to the Franka wrist + lighting. The
60 # ``lights`` list is plain Genesis sensor config; the plugin converts each
61 # dict into an Nyx ``LightAsset`` at build time.
62 cam = scene.add_sensor(NyxCameraOptions(
63 res = (1280, 720),
64 fov = 60.0,
65 near = 0.02,
66 far = 50.0,
67 entity_idx = franka.idx,
68 link_idx_local = franka.get_link("hand").idx_local,
69 offset_T = WRIST_OFFSET_T,
70 spp = 32,
71 render_mode = npr.ERenderMode.FastPathTracer,
72 lights = [{
73 "type": "directional",
74 "dir": (-0.4, -0.4, -0.8),
75 "color": (1.0, 1.0, 1.0),
76 "intensity": 5.0,
77 "shadow": True,
78 }],
79 ))
80
81 # Stream RGB frames to an MP4 file. ``cam.read().rgb`` is shape
82 # (H, W, 3) uint8 in single-env mode.
83 scene.start_recording(
84 data_func = lambda: cam.read().rgb,
85 rec_options = gs.recorders.VideoFile(filename=OUTPUT_PATH, fps=FPS),
86 )
87
88 scene.build()
89
90 # Franka controller gains (from the Genesis IK tutorial)
91 franka.set_dofs_kp(np.array([4500, 4500, 3500, 3500, 2000, 2000, 2000, 100, 100]))
92 franka.set_dofs_kv(np.array([ 450, 450, 350, 350, 200, 200, 200, 10, 10]))
93 franka.set_dofs_force_range(
94 np.array([-87, -87, -87, -87, -12, -12, -12, -100, -100]),
95 np.array([ 87, 87, 87, 87, 12, 12, 12, 100, 100]),
96 )
97 motors_dof = np.arange(7)
98 fingers_dof = np.arange(7, 9)
99 hand = franka.get_link("hand")
100
101 def execute_path(target_pos, gripper=0.04, num_waypoints=60):
102 qpos = franka.inverse_kinematics(
103 link=hand, pos=np.array(target_pos), quat=np.array([0, 1, 0, 0]),
104 )
105 qpos[-2:] = gripper
106 for waypoint in franka.plan_path(qpos_goal=qpos, num_waypoints=num_waypoints):
107 franka.control_dofs_position(waypoint)
108 scene.step()
109
110 def hold(seconds: float):
111 for _ in range(int(round(seconds * FPS))):
112 scene.step()
113
114 # --- Scripted pick task -------------------------------------------------
115 execute_path((0.65, 0.0, 0.25)) # pre-grasp above cube
116 execute_path((0.65, 0.0, 0.13)) # descend onto cube
117 franka.control_dofs_force(np.array([-0.5, -0.5]), fingers_dof) # close gripper
118 hold(0.5)
119
120 qpos_lift = franka.inverse_kinematics(
121 link=hand, pos=np.array([0.4, 0.2, 0.35]), quat=np.array([0, 1, 0, 0]),
122 )
123 franka.control_dofs_position(qpos_lift[:-2], motors_dof) # lift while gripping
124 hold(1.5)
125
126 scene.stop_recording()
127 print(f"Saved {OUTPUT_PATH}")
128
129
130if __name__ == "__main__":
131 main()
Run it:
uv run python examples/02_attached_camera.py
The MP4 is written to examples/out/02_attached_camera.mp4. The Sphinx build copies that file into _static/generated/examples/02_attached_camera.mp4 and embeds it at the top of this page, so the video on the docs site stays in lock-step with whatever the latest run produced.