Skip to content

Beta: components are the MuJoCo model - #85

Open
jonathanembleyriches wants to merge 32 commits into
mainfrom
feat/protospec
Open

Beta: components are the MuJoCo model#85
jonathanembleyriches wants to merge 32 commits into
mainfrom
feat/protospec

Conversation

@jonathanembleyriches

@jonathanembleyrichesjonathanembleyriches commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Summary

  • MJCF becomes an object model generated from MuJoCo's own mjcf.schema, and the UE components are the MuJoCo model rather than a mirror of it.
  • MuJoCo 3.11.1, engine plugins, convex decomposition finished, ROS 2 as an optional module over a unified state IR.
  • An imported model compiles to what mj_loadXML produces, checked field by field against every robot in a Menagerie checkout.

Motivation

The previous element tree was hand-followed: every MuJoCo version bump meant chasing the schema by hand, and the components mirrored the model rather than being it, so the two could disagree. Generating the tree from mjcf.schema makes a version bump a regeneration, and storing every attribute as TOptional — where unset means the document did not author it — is what lets the default class answer instead.

Breaking changes

MjArticulation assets from any alpha will not open. Their components are the previous generation's classes and nothing migrates them. Re-import the MJCF, or stay on the alpha branch / v0.5.0-alpha tag, which are frozen for exactly this.

RPC: the control-owner override field is control_owner, not source. set_control_source already spent source on "zmq" | "ui", so that op could never pass its own ownership check. Requests that do not set it are unaffected; the owner still falls back to session_id.

Notable fixes

  • multiccd follows MuJoCo again. The scene manager disabled it on every scene, which mirrored MuJoCo's default when the flag was opt-in; MuJoCo has since moved it to the disable family where it defaults on. Convex-convex pairs were generating one contact point where MuJoCo generates several.
  • An imported model's <option> reaches the scene. The scene's <compiler conflict> is merge rather than MuJoCo's warning default, because the attach target is a scene nobody authored and under warning every field an import brought lost to a default.
  • Control values keep their precision. The step RPC narrowed ctrl to float, so a float64 setpoint arrived rounded and diverged from stock MuJoCo given identical input. The path is double end to end, which is what mjtNum is.
  • Reimporting a model no longer crashes the tick. The articulation's element index held its components without a UPROPERTY, so a Blueprint recompile reinstanced them and left the index pointing at the objects it replaced.
  • A mesh geom's authored rgba is drawn; previously a colour only applied when a material was also named.
  • The PD control law computes in mjtNum, and now has tests.
  • An articulation carries every top-level section exactly once: MJCF admits them repeatedly and MuJoCo merges them, and a section the document never authored is created unset rather than left to be conjured by hand.

What the diff size actually is

+212,904 / -85,544 across 992 files. Broken into disjoint buckets that reconcile to that total:

addeddeletedwhat it is
Generated88,5980The element tree emitted from mjcf.schema, plus ProtoSpec's reflection / visit / XML binding. Nobody writes or reviews these; a drift gate fails the build if they go stale.
Plugin C++ (Source/)50,55434,448Net ~16k. This is the review surface.
Tests + fixtures39,1933,245
Codegen tooling14,3430The Python that reads mjcf.schema, plus its JSON tables.
ProtoSpec library8,3260Hand-written SDK and MJCF reader/writer.
Vendored5,7685tinyxml2 and third-party install glue.
Docs3,186605
ROS workspace2,3210
Old codegen, removed61547,241Scripts/codegen/ and its snapshots, replaced by the above.
212,90485,544

Two things make the gross number large without making the review large:

  • The old codegen is deleted, not amended. 47k of the deletions are Scripts/codegen/ and its JSON snapshots — a single 27k-line introspection snapshot accounts for most of it.
  • The element layer is replaced wholesale. The plugin's own C++ is +50.5k / -34.4k, a net ~16k, because the hand-maintained tree comes out as the generated one goes in. Several files also moved (MuJoCo/Components/… to MuJoCo/Elements/…) and were rewritten enough that git scores them as add-plus-delete rather than renames.

The reviewable change is concentrated in Source/URLab/Private/MuJoCo/Spec/ (reader, writer, spec build), Source/URLab/Private/Bridge/ (RPC), and Source/URLabRos/ (the new optional ROS module).

The whole-tree clang-format pass and LF normalisation is isolated in 3381d85 (1,214 lines) and listed in .git-blame-ignore-revs, so it can be skipped when reading history.

Test evidence

Full URLab.* automation suite, clean build with the editor closed:

Build : Succeeded
Tests : 458 / 458 passed (0 failed)

Also verified: 65 Menagerie robots matching stock mj_loadXML field by field, the game target builds (packaging works), and the Python bridge's live integration suite green against a running editor.

Manual verification

  1. Import an MJCF from Menagerie; the Blueprint's component tree is the spec.
  2. PIE and confirm contacts and actuation behave as MuJoCo does.
  3. Right-click a mesh geom in the Blueprint editor's component tree to run convex decomposition; hulls become real <mesh> elements and assets beside the articulation's own.
  4. Existing alpha assets will not open — that is the breaking change above.

Checklist

  • Builds locally against UE 5.7+
  • Full URLab.* automation suite passes
  • Docs updated for user-facing changes

jonathanembleyrichesand others added 29 commits August 11, 2026 13:37
A per-mode strategy replaces the branching that had grown across live, direct
and puppet stepping. The worker reads shadow atomics rather than UPROPERTYs,
joins before teardown, publishes only when it actually advanced, and paces the
live loop with a hybrid sleep instead of a full spin.
Capture became the bottleneck: a synchronous readback stalled the stream. It is
now an async pipelined GPU readback with a one-deep queue and drain-to-latest,
a frame history addressable by frame id, and a render-on-demand fast path for
callers that need the frame the step produced. Camera identity is canonical and
filename-safe, and fovy converts from MuJoCo's vertical to Unreal's horizontal.
Per-domain handler files instead of one dispatcher, long editor operations
report through a job poll rather than blocking, and control is claimed and
released per articulation so two clients cannot fight over one robot.
An oversize reply falls back to ZMQ instead of stalling, and the shared-memory
RPC contract is advertised in the handshake rather than assumed.
Two serialization paths had drifted. There is now a single intermediate
representation carrying raw MuJoCo SI values, one msgpack encoder over it, and
user-authored payload channels that are transport-neutral by construction.
URLabRos links the rcl C API directly -- no rclcpp, no shim -- and registers
providers that read the same IR the other transports do. JointState, IMU, twist,
tf2, clock, camera image, odometry, point cloud, occupancy grid and octomap.
The core never references ROS, so the plugin builds and runs without it.
URDF export with an auto-generated SRDF, the simulated world published as a
planning scene, a trajectory execution bridge, gripper control and a pick
pipeline.
Class-inherited actuator gains survived neither import nor export. Geom type
and size did not resolve through defaults. MJCF <include> fragments were not
resolved at all, and exported meshes carried no normals.
The schema this rebuild generates from is MuJoCo's own, so the engine and
the grammar move together.
MuJoCo ships `src/xml/mjcf.schema` and drives its own reader and writer from it.
This is the same idea carried into a library: the schema is the single source of
truth, and the object model, the reader, the writer and their tables are emitted
from it rather than written by hand.
What is here is the library and its generator, standalone and with its own build
and test suite. Nothing in the plugin uses it yet.
The point is the cost of the next MuJoCo release. Hand-written binding code is
what makes a version bump expensive, so anything derivable from the schema is
derived, and a schema change that our tables do not account for fails generation
by name rather than compiling into something subtly wrong.
A robot is authored as Unreal components in a Blueprint, and those components
ARE the MuJoCo model: there is no mirrored document and no translation layer.
The components write straight into MuJoCo's own in-memory model description,
scenes are composed with `mjs_attach`, and each component holds a handle to its
element so binding needs no names.
What that replaces: building MJCF text from the components, assembling a scene by
string surgery and a regex over asset paths, loading the text back through the
parser, and finding each component's simulation id by name afterwards.
The reader and writer stay, and only at the file boundary. mjSpec cannot express
which shorthand an actuator was authored as, so a `<position>` loaded through it
comes back a `<general>`; importing and exporting through our own reader and
writer is what keeps an authored element the element the user wrote. The three
macro elements are expanded by MuJoCo's parser through one contained text hop,
because nothing else can expand them.
Everything the schema can describe is generated. What is left by hand is the
walk over the components, and the seams where MuJoCo's C API has no field to
write to.
Mount assets under the names the spec references, so a model whose visual
and collision meshes share basenames stops resolving both to the visual
one through MuJoCo's basename fallback. Read an explicit top-level
<default class="main"> as the root class it already is.
Plugins: install and load the engine plugin libraries, create the element
a <plugin> actuator or sensor stands for, and carry <config> through
MjShim, since mjs_setPluginAttributes takes ownership of a std::map the
caller allocates and Unreal replaces operator new per module.
clang-format over the hand-written sources, and LF in the repository and
on checkout on every platform. CRLF was reaching the commits, so a file
touched on Windows read as changed in its entirety anywhere else.
The formatters now skip *.gen.h and *.gen.cpp: the emitter owns those
bytes and the codegen drift gate compares them exactly, so formatting
them here and regenerating each undid the other.
The details-panel buttons were unreachable: they asked the geom's own
`type`, which reads `sphere` when unset, and a menagerie model puts
`type="mesh"` on a default class. `EffectiveType()` answers what the geom
compiles as, which is what decides whether it can be decomposed.
Decomposition is an action, not an attribute, so it lives on the Blueprint
editor's component tree now rather than beside the geom's real attributes,
with threshold and extrude offered where it is invoked. Extrude reaches
CoACD at last -- it was hardcoded off -- and both parameters are part of
the mesh cache hash, or changing them would reuse stale hulls.
A hull also now gets a `<mesh>` element and the geom's `mesh` reference,
which it never had: the OBJ and the UStaticMesh existed, so the hulls drew
in the editor and compiled to a mesh geom naming nothing. The assets land
beside the articulation's own, under its Decomposed folder.
`RootOfBlueprint` reads a template graph through FMjScsScope, and both are
editor-only, but it was gated on URLAB_MJ_GEN alone. `OverBlueprint`
already compiles its own body away without the editor; its helper did not,
so the game target failed to compile and the plugin could not be packaged.
The component model changed underneath anyone updating from the previous
generation, and nothing migrates their Blueprints. Say so at the top of
the README, and point at the alpha branch and its frozen tag for users
who would rather not re-import yet.
The plugin still called itself 0.1-alpha five releases in, which is what
the preserved alpha snapshot was nearly tagged as. It is 0.6.0-beta now,
and the alpha tag reads v0.5.0-alpha to match the last released milestone.
On Linux with Clang the spec template code instantiated far more than it
needed and used tens of GB per file, which caused out of memory during
the build. Two changes fix it with no runtime or behavior change:
- ParentMap now walks the tree over base node pointers and dispatches to
the concrete type once per node, instead of instantiating the record
step for the whole element containment closure.
- DispatchChild now dispatches only among the child types a parent
admits, instead of the full element set, which removes the O(types
squared) fan out in the MJCF reader and writer.
Also make the four basic shape mesh path constants constexpr so Clang
accepts them in the constexpr shape table (MSVC allowed the plain const
form).
Pass UE's target and sysroot into the third party compiler and linker
flags so the static libs target UE's glibc, not the host's newer one.
Without this, on a host with a newer glibc the deps reference symbols
UE's glibc lacks and the plugin link fails.
Also build protospec from build_all.sh so it inherits the same
toolchain, and ignore the per dep build scratch dirs that sit beside src.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant

@jonathanembleyriches