Newton design proposal · draft for review

Cameras in the Model

Declare cameras in ModelBuilder, import them from USD and MJCF, and render them with SensorCamera without hand-maintained pose, ray and world bookkeeping.

Status Draft, prototyped Tracking issue newton#4899 Date 2026-10-22 Updated 2026-10-23 Base main @ c81f4f332 Builds on newton#3276, newton#3627 and an earlier prototype PR

Summary

1. Problem

SensorCamera is now Newton's camera sensor (SensorTiledCamera was deprecated in #3913). It is a good renderer, but Newton has no concept of a camera. Every caller passes in three things on every update() call (sensor_camera.py:900):

camera.update(
    state,
    camera_transforms,      # (view_count,) world-space poses, computed by the caller every frame
    camera_rays,            # (H, W, S, 2) one lens + resolution, shared by every view
    world_indices=...,      # (view_count,) which world each view renders
    color_image=color,
)

Everything that describes the camera itself (where it is mounted, which world it belongs to, its lens, its clip range) therefore lives in user code. The consequences show up in Newton's own example and, more seriously, in Isaac Lab:

2. Requirements

These requirements come from reading Isaac Lab main (8c9b995) and its issue tracker, from the prior Newton PRs, and from how mujoco_warp handles cameras. "Source" names the evidence.

IDRequirementSource
R1Declare cameras on the per-env prototype before replication, and let cloning create the per-world copies with stable ordering.Isaac Lab spawns camera prims before replication; IsaacLab #8000 "Cloning owns ... declared cameras"; #8545, #7688 ordering bugs; only env_0 camera prims exist in USD (#6928, #7036)
R2Parse cameras from USD and MJCF, resolving the parent rigid body even through non-body Xform ancestors.Isaac Lab and MJCF-based workflows; IsaacLab #8211 (camera under a non-body articulation Xform failed to resolve)
R3Attach to a body with a local offset, or fix to the world or env frame; poses evaluated from State each step.Isaac Lab NewtonSiteFrameView; Newton example kernel
R4Precomputed rays for speed and for arbitrary custom lenses.Rendering cost of per-pixel ray generation; calibrated lens models that no built-in helper covers
R5Per-environment, runtime-changeable intrinsics (domain randomization) that stay CUDA-graph safe.mujoco_warp use_precomputed_rays=False, documented for camera-intrinsics randomization; IsaacLab #7916; the "identical intrinsics" error above
R6Render a subset: choose cameras statically, and mask environments or views per call.Multi-camera scenes rarely need every camera every step; newton#3276 ("disable cameras ... once they don't need further training"); Isaac Lab env_mask and update periods
R7Several cameras per world, with different resolutions, sharing one scene representation.Isaac Lab: one CameraCfg per camera, each with its own size; the renderer shares one Newton sensor across them
R8Per-camera clip range (near and far).Isaac Lab clipping_range; max_distance overwrite
R9Tiled batching: all worlds for a camera in one launch, and heterogeneous worlds allowed.Tiled rendering throughput; Newton supports heterogeneous worlds
R10Runtime pose writes (set_world_poses, resets, mount-offset randomization).Isaac Lab Camera.set_world_poses; #8664, #8665, #8682 stale or misplaced poses
R11Lens distortion (OpenCV pinhole and fisheye, F-theta, Kannala-Brandt).Isaac Lab OpenCv*DistortionCfg; newton#3578; existing compute_camera_rays_* helpers
R12Zero cost when unused: a camera in the model costs nothing until a sensor renders it.Newton convention; USD stages often carry viewport cameras
R13User-defined camera models (calibrated, exotic or research lenses) without changing Newton, including per-environment parameters.Lens models no built-in helper covers; newton#3578; Isaac Lab distortion cfgs
R14Range sensing (lidar-style) on the same ray machinery, without a new ray-sensor class.Review of newton#3347 (consolidate ray sensing around the camera sensor and intersect_ray); Isaac Lab ray caster on sites
R15Custom imaging modalities (thermal, material or semantic channels) computed from per-hit data and user attributes, without changing the renderer.Prototype thermal camera (section 14); Isaac Lab segmentation remapping from shape_index_image

3. Design principles

P1. The Model describes and the sensor renders.
Anything an asset author writes down (mount frame, parent body, world, focal length, aperture, clip range) goes in the Model. Anything that depends on how you choose to render (resolution, sample count, ray buffers, output images, lighting) goes in SensorCamera. USD makes the same cut: UsdGeom.Camera has no resolution, and resolution lives on UsdRender.Product, which points at a camera. This settles a recurring design question: rays never go in the Model, because they depend on resolution, but cameras do.
P2. A camera is a per-world instance.
Each camera row has a world index, like bodies and shapes, so heterogeneous worlds work by construction. A camera added in the global scope (world -1) is a single declaration that renders every world. This is the "observer camera" case that the example and #3627 both had to special-case.
P3. Reuse existing Newton vocabulary.
add_camera(body, *, xform, label, custom_attributes) follows add_site. SensorCamera(model, cameras=...) follows SensorIMU(model, sites=...) and label matching. Model arrays use the camera_* prefix and the existing AttributeSpec registry, so replicate, add_builder, custom attributes and model views all handle them.
P4. The explicit path stays.
SensorCamera(model) with no cameras= behaves exactly as it does today. A bound sensor can still be given explicit camera_transforms or camera_rays as per-call overrides, which keeps custom lenses and externally driven poses simple.

4. Architecture

ASSETS BUILD MODEL · describes SENSOR · renders USD UsdGeom.Camera MJCF <camera> Python / cfg add_camera() ModelBuilder camera rows body, xform, world, label, intrinsics, clip range add_builder / replicate remap body, set world, offset fixed cameras, prefix labels finalize Model camera_world camera_body camera_transform camera_focal_length camera_aperture(_offset) camera_clipping_range camera_resolution (hint) camera_label + AttributeFrequency.CAMERA State body_q (every step) SensorCamera cameras="*/wrist", W, H view table (init) view_camera, view_world one row per (camera, world) rays PRECOMPUTED: bundle per lens ANALYTIC: none stored update(state, ...) 1. pose kernel body_q[body] · camera_transform 2. ray trace via RenderContext (shared by sensors of a model) select by label mounts, intrinsics body_q images (view_count, H, W) each
Figure 1. Camera data flow. Blue is scene description: importers and the builder create it, and it is replicated with the scene. Purple is render state, which the sensor creates. The green edges are the only links between them: the sensor selects cameras by label once, then reads their mounts and intrinsics from the Model and body_q from the State on every update.

What changes compared to today

Today Isaac Lab Newton Warp renderer USD camera prim env_0 only own ray kernel (K from USD) own site view + pose kernel global max_distance override SensorTiledCamera.update rays, transforms per call; all worlds ✗ intrinsics must match across envs ✗ no near clip, far clip global ✗ stage needed at runtime Proposed USD camera prim or add_camera() add_usd + replicate model.camera_* SensorCamera(cameras=...) update(state, view_mask=...) ✓ per-env intrinsics (ANALYTIC) ✓ per-camera near/far ✓ render only due views
Figure 2. The three red side channels that Isaac Lab maintains today (ray generation, a second site system for poses, and a global clip override) become model data that the bound sensor consumes.

5. Builder and Model API

5.1 ModelBuilder.add_camera

The signature mirrors add_site (a positional body, then keyword-only xform, label and custom_attributes). The intrinsics parameters reuse the names of the existing SensorCamera.compute_camera_rays_pinhole helper, so the same words mean the same thing everywhere.

def add_camera(
    self,
    body: int,
    *,
    xform: Transform | None = None,
    lens: CameraLens = CameraLens.PINHOLE,
    fov: float | None = None,
    focal_length: float | None = None,
    horizontal_aperture: float | None = None,
    vertical_aperture: float | None = None,
    horizontal_aperture_offset: float = 0.0,
    vertical_aperture_offset: float = 0.0,
    clipping_range: tuple[float, float] = (0.01, 1.0e6),
    resolution: tuple[int, int] | None = None,
    label: str | None = None,
    custom_attributes: dict[str, Any] | None = None,
) -> int:
    """Add a pinhole camera rigidly attached to a body or to the world.

    The camera looks along its local -Z axis with +Y up and +X right
    (USD, OpenGL and MuJoCo convention).

    Args:
        body: Parent body index, or -1 for a camera fixed in the world
            frame of the current world context.
        xform: Camera frame relative to the parent body [m, rad]. For
            ``body == -1`` it is the world-frame pose. Defaults to identity.
        lens: Lens model. ``PINHOLE`` uses the intrinsics below.
            ``CUSTOM`` means the rays are supplied to the sensor (custom
            rays or a ray function) and the intrinsics are ignored.
        fov: Vertical field of view [rad]. Mutually exclusive with
            ``focal_length``. The horizontal extent then follows the image
            aspect ratio (square pixels), which matches MJCF ``fovy``.
        focal_length: Lens focal length [m]. Only ratios with the aperture
            affect the projection.
        horizontal_aperture: Sensor width [m]. If ``None`` or 0, it is
            derived from ``vertical_aperture`` and the image aspect ratio.
        vertical_aperture: Sensor height [m]. If ``None`` or 0, it is
            derived from ``horizontal_aperture`` and the image aspect ratio.
        horizontal_aperture_offset: Principal-point offset [m].
        vertical_aperture_offset: Principal-point offset [m].
        clipping_range: (near, far) distances [m]. Hits outside the range
            are reported as misses.
        resolution: Nominal (width, height) [px] authored by the asset.
            Only a default for sensors; it does not allocate anything.
        label: Unique label, e.g. a USD prim path. Defaults to
            ``camera_{index}``.
        custom_attributes: Values for CAMERA-frequency custom attributes.

    Returns:
        The camera index.
    """

If no intrinsics are given, the camera gets fov = radians(45), the MuJoCo default. newton.CameraLens starts with PINHOLE and CUSTOM. Built-in distortion models (OpenCV, fisheye, F-theta, Kannala-Brandt) are added in phase 2; until then a distorted lens is a CUSTOM camera with rays from the existing helpers (section 7.2).

5.2 New Model fields

AttributeType, shapeNotes
camera_countint
camera_labellist[str]Label matching target for SensorCamera(cameras=...).
camera_worldarray[int32] (camera_count,)-1 = global (renders every world). Spec references WORLD.
camera_world_startarray[int32] (world_count + 2,)Same layout as shape_world_start.
camera_bodyarray[int32] (camera_count,)-1 = fixed. Spec references BODY, so coupled-model views remap it.
camera_transformarray[transform] (camera_count,)Body-local mount (or world pose for body == -1). Writable at runtime (R10).
camera_lensarray[int32] (camera_count,)CameraLens value. CUSTOM cameras take their rays from the sensor.
camera_focal_lengtharray[float32] (camera_count,)[m]. Writable at runtime (R5).
camera_aperturearray[vec2] (camera_count,)(horizontal, vertical) [m]; a 0 component is derived from the image aspect.
camera_aperture_offsetarray[vec2] (camera_count,)Principal-point offset [m].
camera_clipping_rangearray[vec2] (camera_count,)(near, far) [m]. Replaces the global max_distance for bound sensors (R8).
camera_resolutionarray[vec2i] (camera_count,)Nominal (width, height) [px]; (0, 0) = none.

A new Model.AttributeFrequency.CAMERA lets importers and users attach per-camera data such as semantic tags, ISP settings, randomization ranges or the parameters of a user camera model (7.3), using the same mechanism as every other entity. There is deliberately no per-camera ray array, sensor object or enabled flag in the Model: those are render state (P1), and enabling is a sensor concern (view_mask).

Why intrinsics are device arrays and not frozen lens objects. An earlier prototype stored deduplicated CameraPinhole(...) Python objects plus an index. That is tidy for import, but it cannot express per-world randomized intrinsics without a host round trip. Flat arrays can be written by a randomization kernel inside a CUDA graph and read directly by the analytic ray kernel (R5), the same way mujoco_warp batches cam_fovy/cam_intrinsic.

6. SensorCamera API

SensorCamera is the established camera sensor, so this extends it rather than adding a new class. The constructor gains a frame source (model cameras or sites) and a choice of ray source. Everything new is keyword-only and defaults to today's behavior.

class SensorCamera:
    class RayMode(IntEnum):
        PRECOMPUTED = 0   # cache built-in lens rays, one bundle per distinct lens (default)
        ANALYTIC = 1      # evaluate built-in lens rays in the kernel from model intrinsics

    @wp.struct
    class RayQuery:       # input to a user ray function (section 7.3), experimental
        view: int
        camera: int       # model camera index, or site index for site-bound sensors
        world: int
        x: int
        y: int
        sample: int
        width: int
        height: int
        sample_count: int

    def __init__(
        self,
        model: Model,
        *,
        cameras: str | list[str] | re.Pattern[str] | list[int] | None = None,
        sites: str | list[str] | re.Pattern[str] | list[int] | None = None,
        width: int | None = None,
        height: int | None = None,
        sample_count: int = 1,
        ray_mode: RayMode = RayMode.PRECOMPUTED,
        ray_function: wp.Function | None = None,   # experimental
        ray_params: wp.array2d[wp.float32] | None = None,  # experimental
        clipping_range: tuple[float, float] | None = None,
        default_clear_data: ClearData | None = None,
        default_render_config: RenderConfig | None = None,
        load_textures: bool = True,
    ): ...

    # Available when constructed with ``cameras`` or ``sites``:
    view_count: int                  # number of rendered views
    width: int
    height: int
    view_camera: wp.array[wp.int32]  # model camera (or site) index per view
    view_world: wp.array[wp.int32]   # world index per view

    HIT_TRIANGLE_MESH, HIT_PARTICLES, HIT_NONE  # shape_index_image sentinels (section 7.5)

    def compute_view_transforms(self, state, out=None) -> wp.array[wp.transformf]: ...
    def set_camera_rays(self, rays, *, cameras=None) -> None: ...  # custom rays (section 7.2)
    def sync_camera_rays(self) -> None: ...   # PRECOMPUTED: rebuild after intrinsics change

    def update(
        self,
        state: State,
        camera_transforms: wp.array[wp.transformf] | None = None,  # now optional
        camera_rays: wp.array4d[wp.vec3f] | None = None,           # now optional
        *,
        color_image=None, depth_image=None, forward_depth_image=None,
        shape_index_image=None, normal_image=None, albedo_image=None,
        hdr_color_image=None,
        view_mask: wp.array[wp.bool] | None = None,                 # new
        face_index_image: wp.array3d[wp.int32] | None = None,       # new, per-hit data
        barycentric_image: wp.array3d[wp.vec2f] | None = None,      # new, per-hit data
        world_indices: wp.array[wp.int32] | None = None,
        clear_data=None, render_config=None, sync_deformables=True,
    ) -> None: ...

6.1 Semantics

6.2 Minimal example

import warp as wp
import newton
from newton.sensors import SensorCamera

robot = newton.ModelBuilder()
robot.add_usd("franka.usda")  # UsdGeom.Camera prims in the asset are imported as well
hand = robot.body_label.index("/franka/panda_hand")
robot.add_camera(hand, xform=wp.transform((0.0, 0.0, 0.05), wp.quat_identity()),
                 fov=1.0, clipping_range=(0.02, 3.0), label="wrist_cam")

builder = newton.ModelBuilder()
builder.add_ground_plane()
builder.add_camera(-1, xform=overview_pose, fov=0.8, label="overview")  # global: every world
builder.replicate(robot, copy_count=1024, label_prefixes=[f"env_{i}" for i in range(1024)])
model = builder.finalize()

wrist = SensorCamera(model, cameras="env_*/wrist_cam", width=128, height=128)
overview = SensorCamera(model, cameras="overview", width=640, height=480)
wrist_rgb = wrist.allocate_image_color()   # (1024, 128, 128); view_count/size default from the sensor
overview_rgb = overview.allocate_image_color()

for _ in range(steps):
    solver.step(state_0, state_1, control, contacts, dt)
    model.bvh_refit_shapes(state_1)
    wrist.update(state_1, color_image=wrist_rgb)
    overview.update(state_1, color_image=overview_rgb)

This removes the two transform kernels, the two hand-built ray bundles and the world_indices array that example_sensor_camera.py needs today.

7. Camera models and rays

A camera model is a mapping from (pixel, sample) to a ray in the sensor frame: an origin and a direction. Everything in this section is about who defines that mapping and whether the sensor caches its output. Rays never live in the Model, because they depend on resolution. Lens parameters may live in the Model, either as the built-in intrinsic arrays or as CAMERA-frequency custom attributes.

Ray contract, shared by every source below:

LENS DEFINED BY SENSOR HOLDS EVERY UPDATE 1. Built-in, PRECOMPUTED camera_lens = PINHOLE bundle per lens group sync_camera_rays() 2. Built-in, ANALYTIC model intrinsics, may differ per env no stored rays pinhole() inlined in the kernel 3. Custom rays (data) camera_lens = CUSTOM, or sites= bundle per custom group set_camera_rays(rays) 4. Custom model (code) ray_function + ray_params no stored rays user @wp.func inlined in the kernel render kernel for each (view, pixel, sample): 1. ray from source 1–4 2. transform by view pose 3. clip to near / far of the view 4. trace the shared scene BVH 5. write requested outputs only Depth-only renders skip shading, which makes range sensing cheap.
Figure 3. Four ray sources feed one kernel. Sources 1 and 2 are built-in lens models that read the Model's intrinsic arrays. Sources 3 and 4 are how users bring their own camera model, as data or as code. The kernel steps after "ray from source" are identical for all four.
SourceLens defined byRays live inVaries per envUse for
1. PRECOMPUTEDModel intrinsics (built-in lens)Sensor, one bundle per distinct lensPossible, at one bundle per envDefault; fast; MSAA/SSAA
2. ANALYTICModel intrinsics (built-in lens)NowhereYes, freeIntrinsics randomization (R5)
3. Custom raysAny user kernel or calibration tableSensor, one bundle per set_camera_rays groupAt one bundle per envCalibrated or exotic lenses, scan patterns (R13, R14)
4. Ray function experimentalUser @wp.func + parametersNowhereYes, freeParametric custom models with randomization, time-varying scans

7.1 Built-in lenses: precomputed vs analytic

Both modes read the same Model arrays; they differ only in whether the sensor caches the derived rays. PRECOMPUTED groups the selected cameras by identical intrinsics and stores one bundle per group. After writing model.camera_*, sync_camera_rays() rebuilds the bundles in place at a stable address, and raises if the grouping of equal lenses changes. ANALYTIC stores nothing and evaluates the pinhole projection per pixel from camera_*[view_camera[v]], so per-environment intrinsics cost no memory (a per-env bundle would be 4096 × 128² × 2 × 12 B ≈ 1.6 GB).

Kernel change. The render kernel currently reads camera_rays[py, px, s, k] from a single bundle (render.py:589, render.py:690). It gains a wp.static switch between a bundle indexed by view_ray_bundle[view] and an inline ray generator. newton#3276 (camera_indices[:, 1]) and mujoco_warp (ray_offset) both used a per-view bundle index, so that half is already proven.

# Per-env focal-length randomization, captured in the same CUDA graph as the render.
wrist = SensorCamera(model, cameras="env_*/wrist_cam", width=128, height=128,
                     ray_mode=SensorCamera.RayMode.ANALYTIC)

@wp.kernel
def randomize_focal(seed: int, nominal: float, focal: wp.array[float]):
    c = wp.tid()
    focal[c] = nominal * (1.0 + 0.1 * (wp.randf(wp.rand_init(seed, c)) - 0.5))

wp.launch(randomize_focal, dim=model.camera_count, inputs=[seed, 0.024, model.camera_focal_length])
wrist.update(state, color_image=rgb)   # each env renders with its own focal length

7.2 Custom rays as data

A camera declared with lens=CameraLens.CUSTOM has no analytic model, and its intrinsic fields are ignored. Its rays come from the sensor:

sensor.set_camera_rays(rays, cameras=None)
#   rays:    (H, W, S, 2) vec3f, shared by the matched cameras, or
#            (N, H, W, S, 2) vec3f, one bundle per matched camera (e.g. per-env calibrations)
#   cameras: label pattern selecting a subset of this sensor's cameras; None = all of them
proto.add_camera(hand, xform=mount, lens=newton.CameraLens.CUSTOM,
                 resolution=(640, 480), label="wrist_cam")
...
wrist = SensorCamera(model, cameras="env_*/wrist_cam", width=640, height=480)
rays = my_calibrated_lens_rays(640, 480, device=model.device)  # (480, 640, 1, 2), any kernel
wrist.set_camera_rays(rays)
wrist.update(state, color_image=rgb)

7.3 Custom camera models as code experimental

Data alone cannot express a custom model whose parameters change per environment without one bundle per environment, or a scan pattern that changes every step without re-uploading rays. For those cases a sensor accepts a user @wp.func that the render kernel inlines in place of the built-in pinhole. The render kernel is already specialized per configuration with wp.static, so the function is captured the same way and compiled once per distinct function.

# A barrel-distorted lens whose field of view and distortion are per-camera custom attributes.
for name in ("lens_fov", "lens_k1"):
    builder.add_custom_attribute(
        ModelBuilder.CustomAttribute(
            name=name, namespace="thermal", frequency=Model.AttributeFrequency.CAMERA, dtype=wp.float32
        )
    )  # USD: newton:thermal:lens_fov / newton:thermal:lens_k1 on the camera prim
builder.add_camera(-1, xform=pose, lens=newton.CameraLens.CUSTOM, resolution=(192, 144), label="thermal_cam",
                   custom_attributes={"thermal:lens_fov": 0.73, "thermal:lens_k1": 0.12})

@wp.func
def barrel_lens(params: wp.array2d[wp.float32], q: SensorCamera.RayQuery):
    half = wp.tan(0.5 * params[q.camera, 0])
    k1 = params[q.camera, 1]
    x = ((float(q.x) + 0.5) / float(q.width) - 0.5) * 2.0 * half * float(q.width) / float(q.height)
    y = (0.5 - (float(q.y) + 0.5) / float(q.height)) * 2.0 * half
    scale = 1.0 + k1 * (x * x + y * y) / (half * half)
    return wp.vec3f(0.0), wp.normalize(wp.vec3f(x * scale, y * scale, -1.0))

@wp.kernel
def gather(fov: wp.array[float], k1: wp.array[float], out: wp.array2d[float]):
    c = wp.tid()
    out[c, 0] = fov[c]
    out[c, 1] = k1[c]

params = wp.zeros((model.camera_count, 2), dtype=wp.float32)
wp.launch(gather, model.camera_count, inputs=[model.thermal.lens_fov, model.thermal.lens_k1], outputs=[params])
thermal = SensorCamera(model, cameras="thermal_cam", ray_function=barrel_lens, ray_params=params)
# Randomize model.thermal.lens_k1 per camera and gather again; the next update() uses the new values.

7.4 Comparison with mujoco_warp

mujoco_warp's camera model is what MJCF can describe: cam_projection (perspective or orthographic), cam_fovy, cam_sensorsize and cam_intrinsic, with no lens type and no distortion. With use_precomputed_rays=True, create_render_context fills flat rc.ray/rc.ray_offset arrays from the unbatched intrinsics, one block per active camera, shared by all worlds. With False, the kernel calls the same compute_ray with per-world cam_fovy/cam_intrinsic, which is the documented path for intrinsics randomization. Both paths are the same analytic model, so "precomputed" is a cache, not an extension point. Custom lenses are only possible by overwriting rc.ray in place, with an implicit layout and per-ray origins honored only if the model also has an orthographic camera. Sources 1 and 2 above match mujoco_warp; sources 3 and 4 are where this design goes further.

7.5 Custom imaging from per-hit outputs

Some sensors need a different quantity per pixel rather than different rays: temperature for a thermal camera, a material or semantic ID, an emissivity-weighted radiance. Compiling user shading into the renderer would couple every such sensor to renderer internals. Instead the sensor reports what each ray hit, and a user kernel computes the quantity:

With these three images, any per-vertex, per-particle or per-shape attribute (including custom attributes) can be interpolated at the hit point. Requesting barycentrics makes the kernel use its full hit path, because the depth-only path skips them. The prototype's thermal camera (section 14) is built this way:

@wp.kernel
def compose_thermal(shape_index: wp.array3d[wp.uint32], face: wp.array3d[wp.int32], bary: wp.array3d[wp.vec2f],
                    tri_indices: wp.array2d[wp.int32], vertex_temp: wp.array[float], shape_temp: wp.array[float],
                    background: float, out_temp: wp.array2d[float]):
    y, x = wp.tid()
    s = shape_index[0, y, x]
    t = background
    if s == wp.uint32(wp.static(SensorCamera.HIT_TRIANGLE_MESH)):
        f = face[0, y, x]
        b = bary[0, y, x]
        t = (b[0] * vertex_temp[tri_indices[f, 0]] + b[1] * vertex_temp[tri_indices[f, 1]]
             + (1.0 - b[0] - b[1]) * vertex_temp[tri_indices[f, 2]])
    elif s < wp.uint32(0xFFFFFFF0):
        t = shape_temp[wp.int32(s)]
    out_temp[y, x] = t

thermal.update(state, shape_index_image=si, face_index_image=fi, barycentric_image=bc)
wp.launch(compose_thermal, (thermal.height, thermal.width),
          inputs=[si, fi, bc, model.tri_indices, model.thermal.temperature, model.thermal.surface_temperature, 12.0],
          outputs=[temperature_image])

8. USD and MJCF import

Both importers gain a flag named after their existing site flags: add_usd(..., load_cameras=True) (next to load_sites) and add_mjcf(..., parse_cameras=True) (next to parse_sites). Body resolution uses the same "nearest rigid-body ancestor, fold intermediate Xforms into the local transform" rule as sites and visual shapes, which fixes the IsaacLab #8211 class of failures by construction.

8.1 USD (UsdGeom.Camera)

USDModelNotes
prim pathcamera_labelMatches Isaac Lab prim_path patterns.
xformOps up to the nearest RigidBodyAPI ancestorcamera_body, camera_transformNo body ancestor: body = -1, world pose with up-axis conversion and import xform, as compute_camera_transforms_usd does today.
focalLengthcamera_focal_lengthTenths of a scene unit, converted to [m] with metersPerUnit.
horizontalAperture, verticalAperturecamera_apertureSame unit conversion.
horizontalApertureOffset, verticalApertureOffsetcamera_aperture_offset
clippingRangecamera_clipping_rangeScene units to [m]. Neither prior PR read this; Isaac Lab needs it.
projection = "orthographic"—Warn and skip (phase 1).
UsdRender.Product whose camera rel targets the primcamera_resolutionOptional hint from standard OpenUSD; no Newton schema needed.
Custom attributes on the primCAMERA-frequency custom attributesExisting custom-attribute parsing.
Lens distortion attributes (as authored by Isaac Lab's distortion cfgs)—Phase 2; read as plain attributes, with no Omniverse dependency.

Schema disposition (required by CODING_GUIDELINES "USD schema and importer support"): supported through standard UsdGeom.Camera and UsdRender.Product, with no new schema in phase 1. Lens distortion is deferred to phase 2 and needs an owner and an issue (it can track newton#3578). Tests must check that a USD-authored camera and an equivalent add_camera call produce identical Model rows, including defaults, units, frames and invalid inputs.

8.2 MJCF (<camera>)

MJCFModelNotes
enclosing <body> / <worldbody> / <frame>camera_bodyUses class/childclass defaults.
namecamera_label
pos, quat / axisangle / xyaxes / zaxis / eulercamera_transformThe MuJoCo camera frame is already -Z forward and +Y up, so no conversion is needed.
fovy [deg]focal length + vertical aperture; aperture.x = 0The horizontal extent follows the image aspect, as in MuJoCo.
focal + sensorsize (+ principal)focal length, aperture (+ offset)Physical units.
focalpixel / principalpixel + resolutionconverted to the aboveUses sensorsize/resolution as the pixel pitch.
resolutioncamera_resolution
mode ≠ fixed, targetimported as fixedWarn. The original mode is kept in a CAMERA-frequency custom attribute for a later tracking-camera phase.
orthographic="true"—Warn and skip.

9. Worlds, replication and views

Cameras follow the rules shapes already follow in add_builder/replicate: the body index is remapped, the world becomes the destination world, world-fixed cameras (body == -1) inside the source builder get the per-copy xform/spacing applied, and label_prefixes are prepended. Global cameras are only ever declared at the global scope of the outer builder.

MODEL CAMERAS idx label world body 0overview-1-1 1env_0/wrist_cam0hand₀ 2env_0/head_cam0head₀ 3env_1/wrist_cam1hand₁ 4env_1/head_cam1head₁ SENSOR VIEWS cameras="env_*/wrist_cam" 128×128 view 0 → camera 1, world 0 view 1 → camera 3, world 1 cameras="overview" 640×480 view 0 → camera 0, world 0 view 1 → camera 0, world 1 global camera → one view per world
Figure 4. View expansion. Per-world cameras produce exactly one view each, and a global camera fans out to every world. Views are ordered by (camera, world), so a per-environment pattern yields views in environment order.

Heterogeneous worlds need no special handling. If world 7 has no wrist camera, the pattern simply matches one fewer camera, and view_world tells the caller which environments are present. This is the case that "one SensorCamera per camera replicated like meshes" (#3627) could not express.

10. Isaac Lab integration

Isaac Lab already registers IMU, frame-transformer and ray-caster sites on the prototype builder before replication (NewtonManager.cl_register_site → builder.add_site). Cameras would follow the same path, either from the env_0 USD prim it already spawns or directly from CameraCfg:

# Prototype builder, before replication (R1)
proto.add_usd(stage, root_path="/World/envs/env_0", load_cameras=True)
# ...or straight from the config, like cl_register_site does for sites:
proto.add_camera(
    body=hand_body,
    xform=offset_in_opengl_convention,          # CameraCfg.OffsetCfg converted once
    focal_length=lens_m(cfg.spawn.focal_length),         # cfg lens units -> [m]
    horizontal_aperture=lens_m(cfg.spawn.horizontal_aperture),
    clipping_range=cfg.spawn.clipping_range,
    resolution=(cfg.width, cfg.height),
    label="Robot/ee_link/palm_link/Camera",
)
builder.replicate(proto, copy_count=num_envs,
                  label_prefixes=[f"/World/envs/env_{i}" for i in range(num_envs)])
model = builder.finalize()

# One SensorCamera per CameraCfg (R7); all share the model's RenderContext.
cam = SensorCamera(model, cameras="/World/envs/env_*/Robot/ee_link/palm_link/Camera",
                   width=cfg.width, height=cfg.height,
                   ray_mode=SensorCamera.RayMode.ANALYTIC)   # if intrinsics are randomized (R5)
rgb = cam.allocate_image_color()        # (num_envs, H, W), aliased into the torch buffer
depth = cam.allocate_image_depth_forward()

# Inside the physics CUDA graph
cam.update(state, color_image=rgb, forward_depth_image=depth, view_mask=envs_due)  # R6

Isaac Lab code this replaces

  • The camera branch of NewtonSiteFrameView (body-ancestor search, per-world expansion, pose kernel).
  • The K-matrix and ray kernels and the "identical intrinsics" restriction.
  • The default_render_config.max_distance overwrite before each render.
  • World-to-OpenGL pose packing per camera.
  • Reading camera intrinsics from the stage at runtime.

What stays in Isaac Lab

  • Config conventions (ros/world offsets → OpenGL, at declaration time).
  • Segmentation remapping from shape_index_image (unchanged).
  • set_world_poses: converts the desired world pose to a mount offset and writes model.camera_transform (R10).
  • Moving from deprecated SensorTiledCamera to SensorCamera, which it has to do anyway.

11. Alternatives considered

The useful way to compare the options is to ask where each piece of camera data lives. Model = replicated scene description, Sensor = render state, User = recomputed by the caller.

OptionFrame / body / worldIntrinsicsResolution & raysVerdict
A. Status quo + import helpers
importers return camera descriptors
UserUserUser Reject Does not solve R1, R3 or R9; Isaac Lab keeps its parallel site system.
B. Camera as a shape
add_shape_camera, #3627 description
Model in shape_*Sensor obj in ModelSensor obj in Model Reject A camera is not geometry. It grows shape_count, which sizes collision and BVH data, so every shape consumer needs another skip. It also puts render state inside the Model.
C. Camera bound to a site
add_site(camera=SensorCamera), #3627 final
Model site shapeSensor obj in ModelSensor obj in Model Partial Reuses site plumbing, but the sensor must exist before finalize, there is one lens and resolution per sensor and at most one camera per world per sensor, and importers return specs that users must turn into sensors. UsdGeom.Camera is not a site prim.
D. Generic add_sensor()
sensor_* arrays for all kinds
Modeluntyped param bag? Reject Sensors share only a frame, and sites already provide that. The rest diverges (optics, contact pairs, lidar patterns). This is the vague "Entity" abstraction the guidelines warn against.
E. Model entity with deduped lens objects
earlier prototype PR
ModelPython objectsSensor custom rays in Model Close The right structure, but host-side lens objects block per-env randomization (R5), and custom rays in the Model contradict P1.
F. Model entity, device intrinsics
this proposal
ModelModel arraysSensor Proposed Meets R1–R10 and R12–R14; R11 in phase 2.

Why not "one SensorCamera per camera, replicated like meshes"? Newton has no shared-asset-to-instance mapping for shapes either: meshes are shared, but shape instances are per world. Cameras need the same per-instance treatment for heterogeneous worlds and per-env intrinsics. The underlying goal of that design, one array of camera records instead of loose index arrays, is met by the view table, which the sensor owns, not the user.

12. Lidar and other sensors

12.1 Lidar is a site-bound SensorCamera with custom rays

A scanning range sensor is a set of rays from a mounted frame plus the hit distance along each ray. SensorCamera already traces arbitrary per-ray origins and directions and writes depth_image, so with sites as a frame source (6.1) and sensor-owned custom rays (7.2, 7.3), a lidar needs no new sensor class. That also matches the direction from the review of newton#3347 (the closed SensorLidar request newton#3346): consolidate ray sensing around the camera sensor and newton.intersect_ray, and keep models of specific scanners downstream. Newton provides the mechanism, and a scanner model is a ray pattern or ray function.

# Mount: an ordinary site; no new Model concept.
robot.add_site(base, xform=wp.transform((0.0, 0.0, 0.35), wp.quat_identity()), label="lidar")
...
channels, steps = 32, 1024
lidar = SensorCamera(model, sites="env_*/lidar", width=steps, height=channels,
                     clipping_range=(0.3, 100.0))          # min / max range [m]
lidar.set_camera_rays(spinning_pattern(channels, steps))   # (32, 1024, 1, 2), unit dirs in the site frame
ranges = lidar.allocate_image_depth()                       # (num_envs, 32, 1024) [m]
no_return = SensorCamera.ClearData(clear_depth=-1.0)        # miss value

lidar.update(state, depth_image=ranges, clear_data=no_return)
# Optional: shape_index_image for per-point semantics, normal_image for incidence angle.

Irregular patterns (rosette or solid-state scanners, sparse fans) use a (1, N) "image", or a grid with zero directions in unused slots. Non-repeating patterns either call set_camera_rays each step (a graph-safe copy) or use a ray function that reads a step counter from its parameters.

12.2 How close to trivial is it?

Most of it is already there. These are the gaps, and what closes them:

AspectTodayWith this proposal
Range outputWorks depth_image is the hit parameter along each ray: metric range for unit directionsUnchanged; the ray contract (7) requires unit directions
Cost of depth-onlyWorks the kernel skips shading when no color, albedo or HDR output is requestedUnchanged
Arbitrary patternsWorks rays are independent per pixel; zero directions skipSensor-owned, graph-safe updates via set_camera_rays
Miss valueWorks ClearData.clear_depth (default 0)Unchanged
Mounting and per-env posesManual caller composes body_q and offsets every framesites= frame source and view table
Min / max rangeGlobal far only, baked into the kernel (max_distance); no minPer-view clipping_range
Hitting its own housingNear clip onlyNear clip; per-view shape or body exclusion is an open question
Point cloudManual origin + range · directionA small helper, or downstream
What rays hitThe render scene (visible shapes, particles, cloth, Gaussians), not collision geometry. Collision-geometry queries remain newton.intersect_ray's job.
Not modeledScan timing and motion distortion (all rays fire at one instant per update), multiple returns, and reflectivity (approximate from normal_image and albedo_image). With sample_count > 1, depth is the nearest sample, which approximates beam divergence as a first return.

12.3 Declaring other sensors

Lidar fits the rule that this proposal uses for every sensor kind:

Sites declare frames, and sensors are configured where they are constructed. A sensor kind gets its own Model entity only when its description is authored in assets (USD or MJCF) and has to survive import and replication.

SensorAsset-authored description beyond a frame?Declaration
SensorIMU, SensorFrameTransformNoSites (unchanged)
SensorContactNo (bodies and shapes)Bodies/shapes (unchanged)
Lidar / range scannersNo standard schema; the pattern is configurationSites + SensorCamera(sites=...) with custom rays
CameraYes: UsdGeom.Camera, MJCF <camera> opticsCamera entity
Lights (future)Yes: UsdLux, MJCF <light>Likely the next entity, with the same pattern

An entity that follows the pattern provides add_<kind>(body, *, xform, ..., label, custom_attributes), <kind>_body/_transform/_world/_label arrays, an AttributeFrequency and a <kind>s= selector on its sensor. Sites stay; nothing is deprecated. If a standard lidar schema appears in USD later, lidar can move to an entity through the same steps cameras take here.

13. Compatibility

14. Prototype

To check that the design holds together, phase 1 and parts of phase 2 were prototyped against Newton main @ c81f4f332. The prototype is exploratory and unpublished; it is meant to answer design questions, not to be merged as is. All images below were rendered by the prototype's SensorCamera.

14.1 What was built

AreaPrototypeNot done
Model and builderadd_camera, camera_* arrays, AttributeFrequency.CAMERA, CameraLens (PINHOLE, CUSTOM). add_builder/replicate copy cameras through the existing attribute-spec merge; fixed cameras move with each copy. collapse_fixed_joints remaps cameras like shapes.camera_world_start
ImportersUsdGeom.Camera: units, clippingRange, UsdRender.Product resolution, parents found through non-body Xforms. MJCF <camera>: fovy, focal/sensorsize/principal and their pixel variants.Tracking modes (imported as fixed); lens-distortion attributes
SensorCameracameras=/sites=, view table, pose kernel, view_mask, per-view near/far, deduplicated precomputed bundles, ANALYTIC, set_camera_rays, ray_function, face-index and barycentric outputs, a shared render context.Built-in distortion lenses
RendererOne ray generator selected with wp.static: shared bundle, stacked bundles indexed per view, inline pinhole, or user function. The explicit update(state, transforms, rays) path is unchanged.Benchmark of analytic vs precomputed rays
ToolingFive examples covering every use case, each with a headless self-check.Unit tests for the new API; viewer frustums; MuJoCo export

The existing camera, tiled-camera, USD-camera, builder replication, custom-attribute, model, coupled-solver and tendon-builder tests (562 tests, 109 skipped) and the MJCF and USD importer suites pass with the prototype.

14.2 Examples

Thermal camera on a squeezed soft block (R5, R13, R15). A tetrahedral block simulated with VBD rests on the ground and is squeezed in a loop by keyframed parallel jaws. Temperatures are a PARTICLE-frequency custom attribute; the jaws' and ground's surface temperatures are a SHAPE-frequency attribute. Each frame, every tetrahedron releases heat q = L · ‖ΔE‖F · V, where ΔE is the change of its Green strain E = ½(FᵀF − I) since the last frame, V its rest volume and L a hysteresis loss per unit strain change [J/m³]. The heat is split over the four vertices, and each vertex's temperature rises by its share divided by its lumped heat capacity (density × specific heat × the vertex's share of tetrahedron volume). Heat then diffuses along tetrahedron edges and surface vertices relax toward the air, jaw and ground temperatures. Loss, diffusivity and cooling rates are exaggerated so that a single squeeze is visible. The thermal camera is a CUSTOM camera declared in the builder; its barrel-distorted lens is the ray function from section 7.3, and the thermal image is composed with the kernel from section 7.5.

Top row: a regular camera view of the soft block between two jaws, settled, squeezed, just after release, and after cooling. Bottom row: the thermal camera at the same moments; the block is uniformly cool when settled, warms while squeezed, is hottest just after release, and has cooled toward ambient later. The jaws are cooler than the block.
Figure 5. Prototype renders, left to right: settled, squeezed, just after release, and cooled. Top: pinhole camera. Bottom: thermal camera with the same pose, a barrel-distorted lens, and an ironbow color map from 16 to 36 °C. Simulated with exaggerated thermal parameters.
20 25 30 35 40 0 2 4 6 8 10 12 14 16 18 time [s] temperature [°C] max over particles mean
Figure 6. Mean and maximum block temperature in the thermal example. Shaded intervals are squeezes (closing, holding, opening). Each squeeze heats the block, mostly on release, and the block cools toward ambient before the next one, so the temperature settles into a periodic pattern.

Lens zoo (R4, R11, R13). One sensor renders a PINHOLE camera and two CUSTOM cameras on a rotating rig in a single launch, with four samples per pixel and SSAA. The custom cameras receive a Kannala-Brandt fisheye bundle and an OpenCV-distorted bundle through one set_camera_rays call.

Three images from the same rig: a pinhole view, a 190-degree circular fisheye view, and a barrel-distorted OpenCV view of colored primitives arranged around the rig.
Figure 7. Pinhole, Kannala-Brandt fisheye (190°), and OpenCV-distorted lenses rendered by one sensor in one launch.

Site-mounted lidar (R14). Two worlds with different obstacle layouts each contain a robot driving in a circle, with a lidar site inside a small housing. SensorCamera(sites=...) uses a 16 × 360 spinning pattern updated in place every frame; a near clip of 0.12 m keeps the beams from hitting the housing, and the ranges are converted to a world-space point cloud.

Two lidar range images, one per world. Rows are elevation channels; the lower channels hit the ground, and obstacles appear as vertical streaks that differ between the two worlds.
Figure 8. Range images of the two worlds (rows: 16 channels from −15° to +15°, stretched; columns: 360 azimuth steps; brighter is closer, black is no return).

Replicated cameras and USD import (R1–R3, R5–R8). The MJCF humanoid's two <camera> elements and a head camera added before replicate give per-world body-mounted cameras. A global overview camera renders alternate halves of the worlds via view_mask, and the side cameras use ANALYTIC rays with per-world randomized focal lengths. A separate example imports a UsdGeom.Camera nested under a non-body Xform of a rigid body, with its clip range and a UsdRender.Product resolution, and selects the cameras by prim path.

Four images, one per world, from the humanoid's imported side camera. The same scene appears at four different zoom levels because each world has a different randomized focal length.
Figure 9. The imported MJCF side camera in four worlds, rendered with analytic rays and a different randomized focal length per world.

14.3 Findings

15. Phasing

PhaseScopeUnblocks
1 Model camera entity + add_camera; add_builder/replicate/model views; USD and MJCF pinhole import with tests; SensorCamera(cameras=..., sites=...), view table, pose kernel, view_mask, per-view clip range; PRECOMPUTED with per-view bundle index; ANALYTIC pinhole; CameraLens.CUSTOM + set_camera_rays; shared RenderContext; rewrite example_sensor_camera; a site-mounted lidar example; changelog fragment. Prototyped (section 14) except unit tests and the changelog. R1–R10, R12, R14, R13 (as data). Isaac Lab can drop its camera site view and its per-env intrinsics restriction.
2 Experimental ray_function; built-in distortion lenses as more CameraLens values with coefficient rows, implemented as built-in ray functions and filled from Isaac Lab-authored distortion attributes; viewer frustum drawing and "look through camera"; export to MuJoCo cameras in SolverMuJoCo; MJCF tracking modes. R11, R13 (as code); debugging; MuJoCo parity.
3Lights as Model entities (UsdLux, MJCF <light>), which makes shared RenderContext lighting well defined.Scene fidelity from assets.

16. Open questions

  1. Lens length units. SI meters (guideline-consistent, MJCF-native) or USD-style "consistent units, only ratios matter" (what the existing compute_camera_rays_pinhole documents)? This proposal says meters, with conversion in the USD importer.
  2. Default ray mode. PRECOMPUTED for speed, or ANALYTIC so randomization "just works"? The analytic pinhole is a handful of flops per pixel next to a BVH traversal, so a benchmark (#3627's asv camera bench) should decide.
  3. Global camera semantics. Is "a world -1 camera renders every world" the right default, or should it require an explicit worlds= on the sensor?
  4. Runtime poses in State? Writing model.camera_transform follows the shape_transform precedent. Externally animated cameras (teleop, cinematics) could argue for a State field instead.
  5. Lighting when RenderContext is shared. Lights are scene state. Until phase 3, should create_default_light on one sensor affect all sensors on the model (honest), or should each sensor keep a light override?
  6. Keep camera_resolution? It is only a hint (MJCF resolution, UsdRender.Product). Dropping it makes width/height always required.
  7. Lens representation for phase 2. An enum plus fixed-width coefficient rows (device side, randomizable, mujoco_warp-like), or frozen lens dataclasses (as in the earlier prototype)?
  8. Exposing view poses. Is SensorCamera.compute_view_transforms enough for viewers and Isaac Lab's FrameView, or do we want a model-level eval_camera_transforms(model, state, out)?
  9. Ray function parameters. The prototype uses (params: wp.array2d[float32], RayQuery) -> (origin, direction). Is a fixed float table enough, or should arbitrary user structs be supported? Should the function also get the view pose, for rolling-shutter-style models?
  10. Per-hit outputs vs a shading hook. Shape index, face index and barycentrics covered the thermal camera. Are they enough for other modalities, or should the renderer also accept a user shading function, for example for lighting-dependent channels?
  11. Sites as a frame source. SensorCamera(sites=...) keeps lidar out of the Model. The alternative is declaring lidars as CUSTOM cameras, which gives one selection path but puts meaningless intrinsics on them.
  12. What ray sensors hit. The render scene includes visual-only shapes, particles and Gaussians, and it has no per-view shape or body exclusion. Does range sensing need a collision-geometry mode or an exclusion list, or is that intersect_ray's domain?

17. References