PiperOrigin-RevId: 897857927 Change-Id: I82ce0a23465429e7025334e83ba00dfbe5085649
31 KiB
name, description
| name | description |
|---|---|
| mujoco-python | Build, manipulate, and simulate MuJoCo physics models using the Python bindings (MjSpec, MjModel, MjData). Covers choosing between compute backends (C++ for full features and noslip, MJWarp for GPU batch RL). Use when constructing scenes programmatically via the spec API, compiling and stepping simulations, reading sensor/body/geom data, attaching sub-models, composing specs with prefixed names, using contact sensors for fixed-size observation spaces, configuring collision filtering, offscreen rendering (context management, cameras, depth/segmentation), or performing spatial math (quaternion, pose, rotation conversions via mju_). Covers gotchas around compilation lifecycle, named indexing vs bind, geom size semantics, camera conventions, and orientation representations. |
MuJoCo Python Bindings
Compilation Lifecycle
MjSpec ──spec.compile()──▶ MjModel ──MjData(model)──▶ MjData
│ │ │
│ (mutable blueprint) │ (compiled, mostly frozen) │ (simulation state)
│ │ │
└── spec.recompile(m, d) ─────┴────────────────────────────┘
- MjSpec — mutable data structure you edit to define the simulation.
spec.compile()— producesMjModel+ you createMjData(model). After this, changing the spec has no effect until you recompile.- Most
MjModelfields are unsafe to mutate. Changing them requiresspec.recompile(model, data), which returns new model and data objects (preserving physics state for existing elements).
import mujoco
spec = mujoco.MjSpec()
body = spec.worldbody.add_body(pos=[0, 0, 1])
geom = body.add_geom(type=mujoco.mjtGeom.mjGEOM_SPHERE, size=[0.1])
body.add_freejoint()
model = spec.compile()
data = mujoco.MjData(model)
mujoco.mj_forward(model, data)
# Later: add another body, recompile keeping state
body2 = spec.worldbody.add_body(pos=[1, 0, 1])
body2.add_geom(size=[0.1])
body2.add_freejoint()
model, data = spec.recompile(model, data) # state preserved
Caution
recompilereturns new objects. Always reassign:model, data = spec.recompile(model, data).
Loading and Serializing
spec = mujoco.MjSpec() # empty
spec = mujoco.MjSpec.from_string(xml_string) # from XML string
spec = mujoco.MjSpec.from_file('/path/to.xml') # from file
model = mujoco.MjModel.from_xml_string(xml) # direct to model (no spec)
xml_out = spec.to_xml() # serialize back
Compile Error Debugging
Use the .info field on spec elements for traceability:
geom = spec.worldbody.add_geom()
geom.info = 'created at my_file.py:42'
spec.compile() # Error: "size 0 must be positive in geom\nElement name '', id 0, created at my_file.py:42"
Compute Backends
MuJoCo has two compute backends. Choose early — the backend determines which features, solvers, and APIs are available.
| C++ (default) | MJWarp (NVIDIA GPU) | |
|---|---|---|
| Import | import mujoco |
import mujoco_warp as mjw |
| Optimized for | Latency (single scene) | Throughput (big batches) |
| Hardware | CPU | NVIDIA GPU |
| Solvers | All (Newton, CG, PGS, noslip) | All except PGS, noslip, islands |
| Plugins | ✅ All | SDF only |
| Precision | float64 | float32 |
| Named access / bind | ✅ | Via wrapper libraries or MJX bind() |
| Contact sensors | ✅ | ✅ |
| Sparse Jacobians | ✅ | ❌ (dense only) |
| Batch rendering | ❌ | ✅ (BVH ray tracing) |
When to use which
-
C++ (default): Real-time control, model predictive control, interactive visualization, any workflow needing full feature support (noslip solver, PGS, islands, plugins, ellipsoidal fluid model, sparse Jacobians). Also the only backend with native
bind(). Use this unless you need massive parallelism. (For MJWarp, named access is available via wrapper libraries or MJXbind().) -
MJWarp: Reinforcement learning with large batch sizes on NVIDIA GPUs. Scales better for contact-rich scenes and large meshes than the legacy MJX-JAX backend. Not differentiable. May degrade for scenes beyond ~60 DoFs.
Important
The
noslipsolver (post-constraint velocity correction for exact zero slip at contacts) is only available in the C++ backend. If your task requires accurate friction modeling without any tangential sliding at contacts, you must use C++.
Warning
MJWarp uses float32, which can cause numerical differences vs C++ (float64). Solver convergence, small friction values, and long rollouts may be sensitive to this. If you see NaNs or instability on GPU, try increasing solver iterations or simplifying the model.
Building Models with MjSpec
Adding elements
Most add_* methods accept keyword arguments matching MJCF XML attributes:
spec = mujoco.MjSpec()
body = spec.worldbody.add_body(name='arm', pos=[0, 0, 1], quat=[1, 0, 0, 0])
geom = body.add_geom(
name='arm_geom',
type=mujoco.mjtGeom.mjGEOM_CAPSULE,
size=[0.05, 0.3],
rgba=[1, 0, 0, 1],
)
joint = body.add_joint(
name='hinge1',
type=mujoco.mjtJoint.mjJNT_HINGE,
axis=[0, 1, 0],
range=[-1.57, 1.57],
)
site = body.add_site(name='sensor_site', pos=[0, 0, 0.3])
cam = body.add_camera(name='arm_cam', pos=[0, -2, 0], xyaxes=[1,0,0, 0,0,1])
Orientation alternatives
In addition to quat, you can specify orientation with euler, axisangle,
xyaxes, or zaxis. Only one can be set at a time:
body.add_geom(euler=[0, 90, 0]) # Euler angles (degrees by default)
body.add_geom(axisangle=[0, 1, 0, 1.57]) # axis + angle
body.add_geom(zaxis=[0, 1, 0]) # minimal rotation to align Z
body.add_geom(xyaxes=[1,0,0, 0,0,1]) # explicit X and Y axes
Top-level elements
Sensors, actuators, tendons, materials, textures, and meshes are added directly to the spec (not to bodies):
spec.add_material(name='red', rgba=[1, 0, 0, 1])
spec.add_actuator(name='motor', joint=joint.name, gear=[1, 0, 0, 0, 0, 0])
spec.add_sensor(
name='joint_pos',
type=mujoco.mjtSensor.mjSENS_JOINTPOS,
objtype=mujoco.mjtObj.mjOBJ_JOINT,
objname=joint.name,
)
Important
Always use
element.name(e.g.,joint.name,geom.name,site.name) instead of hardcoded strings when referencing spec elements. This keeps references correct if the element is renamed or attached with a prefix.
Geom size semantics
| Type | Size params |
|---|---|
| sphere | [radius] |
| capsule | [radius, half_length] or [radius] + fromto |
| cylinder | [radius, half_length] or [radius] + fromto |
| box | [half_x, half_y, half_z] |
| ellipsoid | [radius_x, radius_y, radius_z] |
| plane | [half_x, half_y, grid_spacing] |
Warning
Capsule/cylinder
sizechanges meaning withfromto. Withoutfromto,size=[radius, half_length]. Withfromto,size=[radius]only — the length is computed from the two endpoints.
Accessing Compiled Data: Named Access vs Bind
There are two recommended ways to read/write compiled model and data fields.
Prefer bind when working with spec elements; use named access otherwise.
1. Named Access (on MjModel / MjData)
model.geom('my_geom').size # → numpy view of geom_size for 'my_geom'
data.body('torso').xpos # → numpy view of body_xpos
data.joint('knee').qpos # → shape depends on joint type
data.actuator('motor').ctrl = 1.0 # writable view
Aliases: joint / jnt, camera / cam, tendon / ten, material / mat,
texture / tex, equality / eq, keyframe / key.
Warning
Named access returns views, not copies. After
mj_step, old references reflect new values. Use.copy()when logging:positions.append(data.body('torso').xpos.copy())
2. Bind (bridges MjSpec elements → MjModel / MjData)
bind() connects spec elements (or lists of them) to their compiled
counterparts. Use .set() to write through bind:
geom = spec.worldbody.add_geom(name='ball', size=[0.1], type=mujoco.mjtGeom.mjGEOM_SPHERE)
joint = body.add_joint(name='j1', type=mujoco.mjtJoint.mjJNT_HINGE)
model = spec.compile()
data = mujoco.MjData(model)
mujoco.mj_forward(model, data)
# Reading via bind
model.bind(geom).size # → array([0.1, 0., 0.])
data.bind(geom).xpos # → array([0., 0., 0.])
# Writing via bind — always use .set()
data.bind(joint).set('qpos', 1.5) # sets the joint's qpos
# Bind a list of spec elements
joints = [spec.joint('j1'), spec.joint('j2')]
data.bind(joints).qpos # → concatenated array
data.bind(joints).set('qpos', np.array([0.5, 1.0])) # write to both
Caution
The spec must match the compiled model. If you modify the spec after
compile(), you must recompile before callingbind(), or you get:ValueError: 'The mjSpec does not match mjModel. Please recompile the mjSpec.'
Attachments: Composing Specs
Attach child specs/bodies to parent specs via frames or sites:
parent = mujoco.MjSpec()
child = mujoco.MjSpec()
child_body = child.worldbody.add_body(name='arm')
child_body.add_geom(name='arm_geom', size=[0.05, 0.3], type=mujoco.mjtGeom.mjGEOM_CAPSULE)
child_body.add_joint(name='arm_joint', type=mujoco.mjtJoint.mjJNT_HINGE)
frame = parent.worldbody.add_frame(pos=[0, 0, 1])
frame.attach_body(child_body, prefix='left_')
# 'arm' → 'left_arm', 'arm_geom' → 'left_arm_geom', 'arm_joint' → 'left_arm_joint'
# Or attach entire spec to a site
site = parent.worldbody.add_site(name='attach_point', pos=[0, 0, 2])
parent.attach(child, site=site, prefix='right_', suffix='_v2')
Important
Cross-spec references require a shared parent. If you need to create an element (e.g., an equality constraint) that references elements from two different child specs, you must first attach both children to the same parent, then add the cross-referencing element to the parent spec using the final prefixed/suffixed names:
# Two robot arms, each defined as a separate spec
arm_spec = mujoco.MjSpec()
arm_body = arm_spec.worldbody.add_body(name='hand')
arm_body.add_geom(name='hand_geom', size=[0.05])
wrist_joint = arm_body.add_joint(name='wrist', type=mujoco.mjtJoint.mjJNT_HINGE)
# Attach both to the parent with different prefixes
parent = mujoco.MjSpec()
left_prefix, right_prefix = 'left_', 'right_'
frame_l = parent.worldbody.add_frame(pos=[-0.5, 0, 1])
frame_l.attach_body(arm_body, prefix=left_prefix) # left_wrist, left_hand, ...
frame_r = parent.worldbody.add_frame(pos=[0.5, 0, 1])
frame_r.attach_body(arm_body, prefix=right_prefix) # right_wrist, right_hand, ...
# NOW add a constraint linking both arms — look up the prefixed joints
# from the parent spec, don't hardcode the names
left_wrist = parent.joint(f'{left_prefix}{wrist_joint.name}')
right_wrist = parent.joint(f'{right_prefix}{wrist_joint.name}')
parent.add_equality(type=mujoco.mjtEq.mjEQ_JOINT,
name1=left_wrist.name, name2=right_wrist.name)
model = parent.compile()
Attachment Transforms
When attaching to a site or frame, the child body's position is transformed relative to the parent's attachment point. Attachment also handles unit conversion (degrees vs radians) between parent and child specs automatically.
Assets Get Renamed Too
Prefix/suffix changes apply to asset filenames:
child.assets = {'mesh.obj': data}
parent.attach(child, prefix='robot_')
# Asset key becomes 'robot_mesh.obj' in parent
Cameras
Orientation
MuJoCo cameras look down the negative Z axis. The camera frame is:
- -Z → forward (viewing direction)
- +X → right
- +Y → up
To point a camera downward (looking at the ground), set its Z axis to [0, 0, 1]:
body.add_camera(
name='overhead',
xyaxes=[1, 0, 0, 0, 1, 0], # x=[1,0,0], y=[0,1,0] → z=[0,0,1] → looks DOWN (-z)
pos=[0, 0, 5],
)
Geom Group Visibility
Each camera/viewer has 6 geom groups (0–5). Default visibility:
| Group | Default Visible | Typical Use |
|---|---|---|
| 0 | ✅ Yes | Standard geoms (default group for new geoms) |
| 1 | ✅ Yes | Secondary visual geoms |
| 2 | ✅ Yes | Tertiary visual geoms |
| 3 | ❌ No | Collision-only or debug geoms |
| 4 | ❌ No | Hidden geoms |
| 5 | ❌ No | Hidden geoms |
A newly created geom is in group 0 by default. Toggle visibility at runtime
via mjvOption.geomgroup[i]. The same 3-on/3-off default applies to sites,
joints, tendons, actuators, flexes, and skins.
Contacts: Use Sensors, Not the Contact Array
The problem with data.contact
data.contact is a variable-length array that changes size every timestep
depending on what's colliding. Iterating over it directly is fragile and
incompatible with learning-based agents and fixed-size observation spaces.
# ❌ WRONG — don't iterate data.contact for reward/observation logic
for c in data.contact:
if c.geom1 == target_geom_id:
force = ... # fragile, variable-length, non-deterministic order
Caution
Never iterate
data.contactto build observations or compute rewards. The array's length and ordering can change between timesteps and even between MuJoCo versions. Use contact sensors instead.
Contact sensors: fixed-size, declarative contact queries
A <contact> sensor selects contacts via declarative matching criteria, reduces
them to a fixed number of slots, and extracts requested data fields into
data.sensordata — always the same size, every timestep.
The pipeline has three stages:
- Matching — filter contacts by geom, body, subtree, or site volume
- Reduction — keep the top
numcontacts (by order, min distance, max force, or net force) - Extraction — copy requested fields (
found,force,torque,dist,pos,normal,tangent)
Example: detect contact force between a gripper and an object
import mujoco
import numpy as np
spec = mujoco.MjSpec()
# Build a simple scene: floor + falling object
floor = spec.worldbody.add_geom(
name='floor', type=mujoco.mjtGeom.mjGEOM_PLANE, size=[1, 1, 0.01]
)
obj_body = spec.worldbody.add_body(name='obj', pos=[0, 0, 0.5])
obj_body.add_freejoint()
obj_geom = obj_body.add_geom(
name='obj_geom', type=mujoco.mjtGeom.mjGEOM_SPHERE,
size=[0.05], mass=0.1,
)
# Add a contact sensor: report force for contacts involving obj_geom
contact_sensor = spec.add_sensor(
name='obj_contact',
type=mujoco.mjtSensor.mjSENS_CONTACT,
# Match any contact involving this geom — use .name, not a literal string:
objname=obj_geom.name, objtype=mujoco.mjtObj.mjOBJ_GEOM,
)
model = spec.compile()
data = mujoco.MjData(model)
# Step the simulation until the object lands
mujoco.mj_step(model, data, nstep=500)
mujoco.mj_forward(model, data)
# Read the contact sensor via bind — always fixed-size in data.sensordata
contact_data = data.bind(contact_sensor).sensordata
print(f'Contact sensor output: {contact_data}')
XML-based contact sensor (common pattern)
When loading from XML, contact sensors are even cleaner:
<sensor>
<!-- Is the gripper touching the object? Report force and normal for up to 3 contacts -->
<contact name="grip_contact"
body1="gripper" body2="object"
num="3" data="found force normal"
reduce="maxforce"/>
<!-- Total wrench from all contacts on a body -->
<contact name="object_net"
body1="object"
data="force torque"
reduce="netforce"/>
</sensor>
The output size is deterministic: num × size(data fields). For "found force normal" with num=3, you get 3 × (1+3+3) = 21 numbers every timestep, padded
with zeros if fewer contacts match.
Touch sensor: simpler alternative for scalar normal force
If you only need a scalar "how hard is something pressing on this site", use a
touch sensor instead:
site = body.add_site(name='fingertip', pos=[0, 0, 0.05], size=[0.02])
spec.add_sensor(
name='fingertip_touch',
type=mujoco.mjtSensor.mjSENS_TOUCH,
objname=site.name, objtype=mujoco.mjtObj.mjOBJ_SITE,
)
The touch sensor sums normal contact forces within the site volume — one scalar
output, always present in sensordata.
Spatial Math Utilities (mju_)
MuJoCo ships a library of spatial computation functions under the mju_
namespace — quaternion algebra, rotation conversions, pose composition, and
coordinate transforms. Always check for an existing mju_ function before
implementing spatial math from scratch. For basic vector arithmetic (add,
subtract, dot product, norm), just use NumPy/JAX/Torch directly.
Quaternion Operations
res = np.zeros(3)
mujoco.mju_rotVecQuat(res, vec, quat) # rotate vector by quaternion
quat = np.zeros(4)
mujoco.mju_mat2Quat(quat, mat3x3) # 3x3 rotation matrix → quaternion
mujoco.mju_quat2Mat(mat, quat) # quaternion → 3x3 matrix
mujoco.mju_axisAngle2Quat(quat, axis, angle) # axis-angle → quaternion
mujoco.mju_euler2Quat(quat, euler, 'xyz') # Euler angles → quaternion
mujoco.mju_mulQuat(res, q1, q2) # multiply quaternions
mujoco.mju_negQuat(res, quat) # conjugate
mujoco.mju_quatZ2Vec(quat, vec) # quat that rotates z-axis to vec
mujoco.mju_quatIntegrate(quat, vel, scale) # integrate quat with angular velocity
Tip
mju_quatZ2Vecis particularly useful: given a target direction vector, it returns the quaternion that rotates the Z-axis to point in that direction.
Pose Operations
mujoco.mju_mulPose(pos_res, quat_res, pos1, quat1, pos2, quat2) # compose poses
mujoco.mju_negPose(pos_res, quat_res, pos, quat) # invert pose
mujoco.mju_trnVecPose(res, pos, quat, vec) # transform vector by pose
Common Gotchas
1. Computed fields are read-only
data.xpos, data.xmat, data.xquat, data.geom_xpos are output fields
computed by mj_forward(). You cannot assign to them directly. Instead, modify
input fields (data.qpos, data.qvel, data.ctrl) and call mj_forward() or
mj_step().
2. Duplicate names are forbidden
spec.add_material(name='yellow')
spec.add_material(name='yellow') # ValueError: "repeated name 'yellow' in material"
Names must be unique within each element type.
3. Orientation keywords are mutually exclusive
body.add_geom(axisangle=[1, 0, 0, 1.57], euler=[0, 0, 0])
# ValueError: 'Only one of: axisangle, xyaxes, zaxis, or euler can be set.'
Pick one orientation representation. Quaternion (quat) is the native format.
4. size must be positive for geoms
A geom with size[0] == 0 will fail compilation. Always set at least
size=[radius] for spheres/capsules, or size=[hx, hy, hz] for boxes.
5. mj_step with nstep repeats the same control
mujoco.mj_step(model, data, nstep=100) # 100 steps, same ctrl each step
This is much faster than a Python loop and is fine for passive simulation or
constant-control scenarios. But if you need to update data.ctrl between steps,
you must step one at a time.
6. Euler sequence matters
mju_euler2Quat takes a 3-character sequence string. Lowercase = intrinsic
rotations, uppercase = extrinsic:
mujoco.mju_euler2Quat(quat, [roll, pitch, yaw], 'xyz') # intrinsic x-y-z
mujoco.mju_euler2Quat(quat, [roll, pitch, yaw], 'XYZ') # extrinsic X-Y-Z
The sequence must be exactly 3 characters from xyzXYZ.
7. copy() vs view semantics
NumPy arrays from MjModel/MjData are views into C memory. mj_step changes
them in-place. Always .copy() when storing values for later comparison.
8. Default class handling
main = spec.default # global default class (always named 'main')
child_class = spec.add_default('high_friction', main)
child_class.geom.friction = [1.5, 0.005, 0.0001]
geom = body.add_geom(child_class) # use specific default class
geom = body.add_geom() # uses 'main' class implicitly
9. Gravity is -Z by default
MuJoCo convention: +Z is up, gravity is [0, 0, -9.81]. The viewer and
all built-in models assume this. Don't fight it — orient your scene accordingly.
10. Capsule/cylinder size with and without fromto
# With explicit pos/quat: size = [radius, half_length]
body.add_geom(type=mujoco.mjtGeom.mjGEOM_CAPSULE, size=[0.05, 0.3])
# With fromto: size = [radius] only — length is inferred from endpoints
body.add_geom(
type=mujoco.mjtGeom.mjGEOM_CAPSULE,
size=[0.05],
fromto=[0, 0, 0, 0, 0, 0.6],
)
11. Collision filtering with contype/conaffinity
Two geoms collide only if (g1.contype & g2.conaffinity) || (g2.contype & g1.conaffinity).
By default both are 1, so everything collides with everything.
# Visual-only geom: set contype=0, conaffinity=0 to disable collisions
body.add_geom(size=[0.1], contype=0, conaffinity=0, group=1)
# Separate collision groups using bitmasks:
robot_geom = body.add_geom(size=[0.05], contype=1, conaffinity=2)
tool_geom = body.add_geom(size=[0.03], contype=2, conaffinity=1)
# Robot and tool collide (1&1=0, but 2&2=0… wait):
# contype=1 & conaffinity=1 → collide; contype=2 & conaffinity=2 → collide
Tip
condimandfrictioninteract. Each geom hasfriction=[tangential, torsional, rolling](default[1, 0.005, 0.0001]). Thecondimvalue controls which friction coefficients are active in a contact:
condim Active friction Geom frictionindices used1 None (frictionless, normal force only) — 3 Tangential (opposes sliding) friction[0]4 Tangential + torsional (opposes sliding and twisting around contact normal) friction[0:2]6 Tangential + torsional + rolling (also opposes rolling around tangent axes) friction[0:3]Torsional friction models a surface contact patch resisting twist — useful for soft fingers. Rolling friction dissipates energy from local deformations — useful for stopping balls from rolling forever. Both torsional and rolling coefficients have units of length (roughly the contact patch diameter or deformation depth).
# A soft finger pad: enable torsional friction for stable grasping finger_geom = body.add_geom( type=mujoco.mjtGeom.mjGEOM_CAPSULE, size=[0.01, 0.02], condim=4, friction=[1.0, 0.01, 0.0001], # tangential=1.0, torsional=0.01 ) # A ball that should stop rolling on a surface ball_geom = body.add_geom( type=mujoco.mjtGeom.mjGEOM_SPHERE, size=[0.05], condim=6, friction=[0.8, 0.005, 0.002], # tangential=0.8, torsional=0.005, rolling=0.002 )
Offscreen Rendering
Offscreen rendering produces images (RGB, depth, segmentation) without a display. It requires an OpenGL context — MuJoCo auto-detects the best available backend (EGL on headless Linux, GLFW on desktop, OSMesa as fallback).
The Renderer class
mujoco.Renderer wraps GL context creation, scene management, and buffer
readback. Always use it as a context manager to ensure GPU resources are
freed:
import mujoco
import numpy as np
# Define a camera in the spec and keep a reference
overhead_cam = spec.worldbody.add_camera(
name='overhead',
pos=[0, 0, 3],
quat=[0.707, 0.707, 0, 0], # looking down
fovy=60,
)
model = spec.compile()
data = mujoco.MjData(model)
# Create renderer — width/height must not exceed offscreen buffer (see below)
with mujoco.Renderer(model, height=480, width=640) as renderer:
mujoco.mj_forward(model, data)
# Use the spec element's .name — never a literal string
renderer.update_scene(data, camera=overhead_cam.name)
rgb = renderer.render() # → np.ndarray (H, W, 3), dtype=uint8
# Depth rendering
renderer.enable_depth_rendering()
renderer.update_scene(data, camera=overhead_cam.name)
depth = renderer.render() # → np.ndarray (H, W), dtype=float32 (meters)
renderer.disable_depth_rendering()
# Segmentation rendering
renderer.enable_segmentation_rendering()
renderer.update_scene(data, camera=overhead_cam.name)
seg = renderer.render() # → np.ndarray (H, W, 2), dtype=int32
# seg[:,:,0] = object ID, seg[:,:,1] = object type; background = (-1, -1)
renderer.disable_segmentation_rendering()
Warning
Forgetting to close the renderer (or not using
with) leaks GPU memory and GL contexts. In loops, create the renderer once outside the loop.
What the Renderer holds internally
When you create mujoco.Renderer(model, height, width), it allocates three
internal objects that must be freed together:
GLContext— an offscreen OpenGL context (EGL, GLFW, or OSMesa, auto-detected). Created with the requestedwidth × height.MjrContext— MuJoCo's GPU rendering resources (shaders, textures, framebuffers), bound to the GLContext. Set to the offscreen framebuffer.MjvScene— geometry buffer holding the scene snapshot passed to the GPU each frame.
The context manager (with Renderer(...) as r:) calls r.close() on exit,
which frees the MjrContext first and then the GLContext — order matters.
If you use the renderer without with, call renderer.close() manually.
Caution
Internally,
MjrContext.free()must be called beforeGLContext.free(). Reversing the order leaks GPU resources or segfaults. TheRendererclass handles this automatically — prefer it over manual context management.
Offscreen framebuffer size
The renderer cannot exceed the offscreen buffer dimensions. The defaults are
640×480. Set larger buffers before compilation via spec.visual:
spec.visual.global_.offwidth = 1920
spec.visual.global_.offheight = 1080
model = spec.compile()
# Now you can render up to 1920×1080
with mujoco.Renderer(model, height=1080, width=1920) as renderer:
...
Important
Increasing offscreen buffer size consumes GPU memory. For batch rendering of many cameras, keep the per-frame resolution modest.
Cameras
MuJoCo has two camera systems: fixed cameras defined in the model, and the free camera for interactive viewing.
Defining cameras in MjSpec
Always store the return value of add_camera and use its .name or .id
to reference the camera later — never hardcode literal strings:
# Fixed camera on worldbody — good for evaluation/recording
overhead_cam = spec.worldbody.add_camera(
name='overhead',
pos=[0, 0, 3],
quat=[0.707, 0.707, 0, 0],
fovy=60,
)
# Camera attached to a body — moves with the body
wrist_cam = wrist_body.add_camera(
name='wrist_cam',
pos=[0.05, 0, 0],
xyaxes=[0, -1, 0, 0, 0, -1],
fovy=90,
)
Selecting a camera for rendering
update_scene accepts a camera name (str), id (int), or an
MjvCamera object. Always derive from the spec element:
# By name via spec element (recommended — survives recompilation)
renderer.update_scene(data, camera=overhead_cam.name)
# By id via spec element (after compile; matches model.cam_* arrays)
renderer.update_scene(data, camera=overhead_cam.id)
# Free camera (default) — no camera argument needed
renderer.update_scene(data)
# Custom free camera with explicit lookat/distance/angles
cam = mujoco.MjvCamera()
cam.type = mujoco.mjtCamera.mjCAMERA_FREE
cam.lookat[:] = [0, 0, 0.5]
cam.distance = 3.0
cam.azimuth = 135
cam.elevation = -25
renderer.update_scene(data, camera=cam)
Camera properties reference
| Property | Type | Description |
|---|---|---|
pos |
real(3) |
Position in parent body frame |
quat |
real(4) |
Orientation quaternion (w, x, y, z) |
xyaxes |
real(6) |
Alternative orientation: [x_axis(3), y_axis(3)] |
fovy |
real |
Vertical field of view (degrees, default 45) |
resolution |
int(2) |
Sensor resolution — only for camera-based sensors |
targetbody |
str |
Track this body (camera always looks at it) |
mode |
str |
"fixed", "track", "trackcom", "targetbody", "targetbodycom" |
Scene options
Control what is visualized via MjvOption:
scene_option = mujoco.MjvOption()
# geomgroup is a bool array indexed by group number (0–5).
# Each geom's `group` attribute (default 0) assigns it to a group.
# Toggle visibility of each group:
scene_option.geomgroup[:] = False # hide all groups
scene_option.geomgroup[0] = True # show group 0 (e.g. ground plane)
scene_option.geomgroup[3] = True # show group 3 (e.g. visualization geoms)
# Toggle rendering flags
scene_option.flags[mujoco.mjtVisFlag.mjVIS_CONTACTFORCE] = True
scene_option.flags[mujoco.mjtVisFlag.mjVIS_JOINT] = True
renderer.update_scene(data, camera=overhead_cam.name, scene_option=scene_option)
Filament backend (experimental)
MuJoCo's default renderer uses OpenGL. An alternative Filament backend
(Vulkan-based) is available experimentally and provides higher-quality
rendering. Filament does not require vertical flip (np.flipud is a
no-op). It is selected via build flags — see the MuJoCo Filament
source for details.
Key References
Documentation
| Document | Description |
|---|---|
| XMLreference.rst | Complete MJCF XML element and attribute reference |
| python.rst | Python bindings API: named access, bind, enums, callbacks |
| modeling.rst | MJCF modeling guide: coordinate frames, defaults, attachments |
| simulation.rst | Simulation loop, state, forward/inverse dynamics |
| modeledit.rst | Procedural model editing with MjSpec |
| visualization.rst | Rendering, cameras, scene management |
| APIfunctions.rst | C API function reference (mj_, mju_, mjv_, mjr_) |
| APItypes.rst | All MuJoCo structs and enums |
Test Files (Executable Examples)
| Test file | Key patterns demonstrated |
|---|---|
| specs_test.py | MjSpec API: compile, recompile, attach, bind, defaults, delete, actuator shortcuts |
| bindings_test.py | Named indexing, mju_ functions, copy/pickle, contacts, mj_step |
| support_test.py | MJX bind .set() pattern, JAX functional updates |
Source Code
| File | Description |
|---|---|
| mujoco.h | Main C API header with all mju_ function signatures |
| XMLschema.rst | Schema-level XML structure documentation |