Skip to content

Scene, Robot, Prop & World

Every task starts with build(self, scene: simulo.Scene), and this page is what goes inside it.

You describe a scene: what exists. You put a robot in it, add movable props, give it terrain to stand on, or load a whole prebuilt world such as a warehouse. Simulo then builds that scene as many times as you ask for, one environment per copy, and runs them all at once.

A scene declares what exists. You write build() once, however many environments you run:

def build(self, scene: simulo.Scene) -> None:
scene.add(simulo.Light.dome(name="light", intensity=2000.0), per_environment=False)
scene.add(warehouse, at="/World/Warehouse", per_environment=False)
scene.add(self.robot, at="/World/Robot") # per_environment=True

per_environment decides whether each environment gets its own copy of an item or all of them share one:

per_environment Behavior Use it for
True (default) Every environment gets its own copy, which moves independently. A robot: each environment trains its own instance.
False One copy, shared by every environment. Terrain, lights, or a world. 512 identical warehouses would cost 512 times the memory for nothing.

Robots and movable Prop objects can’t be shared by a scene with more than one environment yet. Add them directly with the default per_environment=True, or run the scene with one environment. A robot or prop inside a Transform isn’t replicated; add it directly for a multi-environment scene. Scene building reports the item’s name and how to fix it before the first episode starts.

The thing you train or control:

self.robot = simulo.Robot(
asset=simulo.Asset.from_registry("robot/so-arm-100:v3"),
initial_pose=simulo.Pose.identity(),
)
scene.add(self.robot, at="/World/Robot")

initial_pose is where the robot spawns. Where it is right now is robot.state, batched across every environment:

q = self.robot.state.joint_positions
qd = self.robot.state.joint_velocities
tau = self.robot.state.joint_efforts
p = self.robot.state.pose # where it is now
v_b = self.robot.state.linear_velocity_in_base_frame
w_b = self.robot.state.angular_velocity_in_base_frame

To start an episode from the robot’s resting pose, use the defaults on the robot rather than live state:

self.robot.reset(env_ids)
joint_pos = self.robot.default_joint_positions[env_ids]
joint_vel = self.robot.default_joint_velocities[env_ids]
base_pose = self.robot.default_pose[env_ids]

These values are what reset() writes. They have one row per environment, and every read gives you a new tensor, so you can perturb the selected rows before writing them back with set_joint_state() or set_root_pose() without changing a later reset.

The live-state tensors need different handling:

  • Treat them as read-only. Writing into one (q[:] = 0) changes the simulation directly. Use the robot’s methods, such as set_joint_state, to move it on purpose.
  • Re-read motion state every time you use it. Simulo replaces pose, linear_velocity, and angular_velocity each step. It computes linear_velocity_in_base_frame and angular_velocity_in_base_frame from the current base orientation on every read, returning a new tensor. A reference to any of those values from an earlier step can be stale. joint_positions, joint_velocities, and joint_efforts are updated in place, so keeping a reference to them, for example from on_start, is safe.

robot.internals is an unstable escape hatch for anything the documented robot and state properties do not cover. Its attributes can change between releases, so prefer the documented properties whenever they fit the task.

Use a Prop for a rigid object whose live pose matters to the task, such as a block to lift, a crate to push, or a ball to catch:

self.block = simulo.Prop(
simulo.Cuboid(
name="block",
size=(0.05, 0.05, 0.05),
pose=simulo.Pose(position=[0.5, 0.0, 0.025]),
physics=simulo.Physics.rigid(mass=0.05),
)
)
scene.add(self.block, at="/World/Block")

Read block.state.pose to find the object in every environment. When episodes reset, block.set_pose(..., env_ids=env_ids) and block.set_velocity(...) move it in just the environments that finished. If an object never moves and you never need its pose, add the shape to the scene directly instead.

Franka manipulation measures success from a block’s height this way, rather than guessing from the gripper’s position.

scene.add(simulo.Terrain.plane(name="ground"), per_environment=False)

Terrain.plane(...) is the common case: a flat, endless ground plane, one shared copy for the whole scene.

A world is a prebuilt space such as a warehouse, a factory floor, or a room. Publish it once, then use it by reference like any other asset:

Terminal window
simulo asset publish ./my-warehouse --kind world
warehouse = simulo.Asset.from_registry("world/my-warehouse:v1") # module level
def build(self, scene: simulo.Scene) -> None:
scene.add(warehouse, at="/World/Warehouse", per_environment=False)

simulo.World.usd(name=..., path=...) also builds a world straight from a USD file path. It skips the catalog, so the file is neither validated nor version-pinned with your job.

Terrain is ground you generate; a world is a place you bring. A world almost always wants per_environment=False, since it is usually large and every copy costs memory. Unlike an endless plane, though, a world has edges, so choose an env_spacing that keeps every environment’s robot inside it.

A scene is the blueprint; an environment is one live copy of it. You get num_envs of them:

@app.job(type="train", system=simulo.SystemType.TIER_2)
def train(num_envs: int = 512) -> dict:
...
Scene declared once: robot + terrain or world + lights
│
│ built num_envs times, env_spacing apart
▼
┌──────────┬──────────┬──────────┐
│ env 0 │ env 1 │ env 2 │ ... num_envs
│ robot │ robot │ robot │ (per_environment=True)
└──────────┴──────────┴──────────┘
└─────── one shared warehouse ───────┘
(per_environment=False)

env_spacing sets how far apart the environments sit. Everything you read back, from observations and rewards to robot.state, comes batched with one row per environment.