2.13 · Robot / MotionGraph
Two composing subsystems for robot behaviour: motion graphs for designed behaviour (IK, blending, state machines) and per-entity ONNX policies for learned behaviour. The graph always runs; policies override it on the joints they claim.
Overview
LuckyEngine pairs two control philosophies in the same robot. Motion graphs are visual, authored, deterministic — good for IK reach, blending, state machines, and anything you want to step through and inspect. Per-entity ONNX policies are learned controllers loaded from disk and ticked every physics substep. The two compose by joint: the motion graph always runs, and any active policy slot that whitelists a joint takes that joint over for the substep.
There is no global override switch and no "policy-mode vs graph-mode" flag. Authority is per-joint, decided
from PolicySlot::DrivenJoints, every substep.
Motion graphs
MotionGraph::Graph (MotionGraph.h) is the base class — it owns nodes, value and event
connections, input/output streams, and local variables. The concrete MotionGraph::MotionGraph
specialises it for robots: it holds a non-owning mjModel* and mjData* pair, and
Process(timestep) evaluates the graph to produce JointStates.
| Type | Header | Role |
|---|---|---|
MotionGraph::Graph | MotionGraph.h | Base. Owns nodes, value/event connections, I/O streams, local variables. |
MotionGraph::MotionGraph | MotionGraph.h | Robot-specialised graph. Non-owning mjModel* / mjData*. Process(timestep) produces JointStates. |
NodeProcessor | NodeProcessor.h | Abstract node base. All concrete nodes derive from this. |
JointStates | Motion.h | MotionDuration, MotionTimePos, Nq positions, Nv velocities. Data allocated contiguously after the struct. |
MotionGraphFactory | MotionGraphFactory.h | Registers node types; exposes the editor-diagnostics hook for multi-error/warning reporting. |
Concrete nodes live in Hazel/src/Hazel/Robots/Nodes/:
IKNodes
Limb IK using Mujoco::InitIKChain on the read-only compiled model.
MathNodes
Scalar / vector arithmetic, clamps, lerps.
BlendNodes
Pose blending and weighted mixers.
TriggerNodes
Event-driven gates.
LogicNodes
Boolean / conditional flow.
MotionNodes
Source poses, motion clips.
StateMachineNodes
State graphs with transitions.
ArrayNodes
Array slicing, indexing, fan-out.
Editor-time validation
Nodes can return multiple errors and warnings via MotionGraphFactory's editor-diagnostics hook
— the editor surfaces all of them, not just the first. LimbIK reuses
Mujoco::InitIKChain on MujocoSceneAsset's read-only compiled model to flag
overconstrained chains before the user enters play mode.
Multi-agent / RL training
For RL workflows, RobotManager (RobotManager.h) owns a RobotEnv and one
or more RobotAgents. The split lets a single environment drive N agents through batched ONNX
inference.
| Type | Header | Role |
|---|---|---|
RobotManager | RobotManager.h | Owns RobotEnv + RobotAgents. |
RobotAgent | RobotAgent.h | Observation / action / control buffers, joint mappings (m_ActuatorIds, m_JointQposAdrs, m_JointQvelAdrs, m_DefaultJointPositions), policy reference. |
RobotEnv | RobotEnv.h | Multi-agent Reset, ResetAgent, ApplyControls. |
PolicyNetwork | PolicyNetwork.h | ONNX Runtime integration. Static cache (Load, Get, ClearCache) and ForwardBatch(obs, actions, batchSize) for zero-copy batch inference. |
Per-entity ONNX policies
Issue #587. Policies live as PolicySlot entries on RobotControllerComponent;
they tick from MujocoSceneInstance, not from inside the graph. Anything that depends on policy
output (PD gains, joint targets, action history) belongs on the PolicyRuntime, not on a node.
A per-MujocoSceneInstance PolicyApplier owns one PolicyRuntime per active
slot and runs inference every physics substep, then merges the result with the motion graph's
JointStates by per-joint priority.
Types under Hazel/src/Hazel/Robots/Policy/
| Type | Fields / API | Role |
|---|---|---|
PolicySlot | Id, Name, DescriptorPath, Active, Priority, DrivenJoints, ClampObservationForUnclaimedJoints | Declarative entry on RobotControllerComponent. Id is the 1-based script id. Empty DrivenJoints = all actuated joints in the descriptor. |
PolicyRuntime | ONNX session, PD gains, joint mappings, CommandStore, target joint positions, last action | Per-slot live state. Allocated by PolicyApplier when a slot goes active. |
PolicyApplier | (Entity UUID, slotId) → SlotRuntime | Per-scene-instance dispatcher. Called from MujocoSceneInstance::SimulateControl and SimulateWithSubsteps. |
RobotController (C#) | SetPolicyActive, SetFloat/SetBool, GetFloat/GetBool, SetMotionGraphActive, IsMotionGraphActive | Entity-scoped script wrapper. Accepts raw uint ids or unmanaged, Enum via Unsafe.As<TEnum, uint> — no boxing. |
Per-substep merge rule (per joint)
- Walk active slots in priority-ascending order; the first slot whose
DrivenJointsclaims the joint wins. - If no policy claims the joint, the motion graph's
JointStatesvalue drives it.
Routing after a claim
| Case | Channel |
|---|---|
| Policy-claimed | PD → qfrc_applied |
| Graph-claimed, joint has a position / general actuator | ctrl[] |
| Graph-claimed, joint has no actuator | PD → qfrc_applied |
| Unclaimed actuated joint (no graph, no policy) | Gravity compensation |
PolicyApplier; per-joint claim picks which MuJoCo channel each joint uses this substep.Motion-graph gating
Graph::SetEnabled(bool) / IsEnabled control whether a graph runs.
Scene::UpdateRobotControllers short-circuits to continue when a graph is disabled —
it skips SetSceneTransform, Process, and SetPendingJointStates, so no
stale graph output leaks into the next substep. Exposed in C# via
RobotControllerComponent.SetMotionGraphActive(bool) and
IsMotionGraphActive.
Descriptor generation
scripts/GeneratePolicyDescriptor.py produces one policy_descriptor.<key>.json
per ONNX model. The editor's Add Policy to Entity flow
(PolicyDescriptorGenerator::AddPolicyToEntity) appends a PolicySlot that references
the descriptor and stamps a C# scaffold next to the ONNX file so script code can wire up
SetFloat / SetBool inputs immediately.
Removed — do not re-introduce
The following symbols were deleted with issue #587 and the per-joint authority model. Re-introducing any of them re-creates the bypass paths that the merge rule was designed to eliminate:
PolicyInferenceNode— inference is no longer a graph node.Command.*script API — replaced byRobotController.ShouldBypassMotionGraphForPolicy— gating is now per-joint, not per-graph.- Global IK-overlay path:
SetGlobalIKOverlayEnabled,AddGlobalIKActuator, etc. IsJointIKOwned.IsTorquePolicyAuthoritative.
Pitfalls
Priority is walked low-to-high; the first active slot whose
DrivenJoints claims a joint wins. A second slot with higher Priority on the same
joint will never run for that joint — this is intentional but easy to mis-set.
An empty DrivenJoints list whitelists all actuated joints in the descriptor.
Leaving it empty on a high-priority slot will lock the motion graph out of every joint.
Per-node editor diagnostics belong on MotionGraphFactory's hook (it supports multiple errors
and warnings per node). Pushing knowledge into the editor model couples the UI to specific node types.
Extending
- New motion-graph node — derive
NodeProcessorinRobots/Nodes/, register inMotionGraphFactory. - New editor-time node diagnostics — extend
MotionGraphFactory's diagnostics hook; return multiple errors/warnings if you have them. - New policy type — extend
PolicyNetworkor add a new inference backend alongside the ONNX path. Wire it intoPolicyRuntime; do not add a graph node.