Two-Phase Step

PyBulletFleet’s step_once() is internally a two-phase step. This page explains what each phase does and what it means for callback / plugin authors.

If you only call high-level APIs (Agent.add_action, controller.set_cmd_vel, agent.get_pose), the two-phase split is fully transparent — you do not need to change any code. This page is for callback / plugin / ROS-bridge authors who interact with poses or PyBullet directly inside a step.

Design rationale

Separating pure-Python compute from PyBullet C-API writes lets the simulator:

  • batch pose writes for N agents in one pass (set_poses()),

  • vectorize per-agent compute with NumPy across N agents,

  • guarantee that collision detection always sees a consistent world snapshot (all writes applied, all AABBs refreshed) within a single step.

The three internal phases

step_once()
├── PHASE 1 — UPDATE         (pure Python / NumPy; pose writes are buffered)
│     ├── pre-step events
│     ├── for obj in sim_objects:  obj.update(dt)
│     │       └── controller.compute → agent.set_pose(...)   ← buffered
│     ├── user callbacks               (registered via register_callback)
│     └── plugin on_step hooks
│
├── PHASE 2 — POSE FLUSH     (tight C-API loop)
│     └── for obj_id in _pending_pose_ids:
│             p.resetBasePositionAndOrientation(...)
│
├── stepSimulation()         (only when physics is enabled)
│
├── PHASE 3 — AABB + GRID FLUSH
│     └── for obj_id in _pending_pose_ids:
│             refresh AABB (p.getAABB)
│             update spatial-hash grid
│
├── check_collisions         (frequency-gated; sees up-to-date AABBs/grid)
└── monitor / post-step events

The set of “objects that moved this step” (_pending_pose_ids) is built up during Phase 1 and consumed (and cleared) by Phases 2 and 3.

set_pose() behaviour: buffered vs immediate

Calls to SimObject.set_pose() (and agent.set_pose_raw()) behave differently depending on when they are made:

When

Behaviour

Outside step_once() (test setup, REPL, between steps)

Immediate — writes to PyBullet right away.

Inside step_once() (controllers, callbacks, plugin on_step)

Buffered — only the cache is updated; the actual PyBullet pose is written at Phase 2 (a few microseconds later in the same step).

Buffering applies to kinematic pose writes via set_pose() only. Physics-driven rigid bodies are advanced by stepSimulation() itself between Phase 2 and Phase 3.

The cache is the source of truth mid-step, so:

  • agent.get_pose()always correct (reads the cache).

  • ⚠️ p.getBasePositionAndOrientation(object_id) — returns the previous step’s pose during Phase 1, because PyBullet hasn’t been written yet.

  • ⚠️ p.getAABB(object_id) — returns the previous step’s AABB during Phases 1 and 2. AABBs are refreshed in Phase 3.

Rule of thumb

Inside a callback or plugin on_step, always use the framework getter (agent.get_pose(), sim_object.get_pose()), never the raw PyBullet API.

If you genuinely need PyBullet to reflect the latest pose mid-step (e.g. for a raycast against just-moved agents), do the work in a post-step callback or let it run in the next step — by then Phase 2 has flushed.

Profiling

The per-step profiling dict (returned by MultiRobotSimulationCore.get_profiling_stats()) exposes the phase split:

Key

What it measures

phase1_update

End-to-end Phase 1 wall time (events → agent.update loop → callbacks → plugin on_step). Buffered writes only — no PyBullet pose calls.

phase2_pose_flush

Time spent writing buffered poses to PyBullet (resetBasePositionAndOrientation loop).

phase3_aabb_grid_flush

Time spent refreshing AABBs and spatial-hash grid entries for the objects flushed in Phase 2.

agent_update

Time spent in the per-object update(dt) loop inside Phase 1 (subset of phase1_update).

See also