Packages

MuJoCo physics from Elixir: models, data, the state and every field of them, through a compile-time generated Fine NIF. PyTorch-free, Python-free.

Current section

Files

Jump to
ex_mujoco README.md
Raw

README.md

# ExMujoco

MuJoCo physics from Elixir, through a NIF generated from MuJoCo's own headers.

```elixir
{:ok, model} = ExMujoco.load_xml("model/scene.xml")
data = ExMujoco.data(model)

for _ <- 1..1000, do: ExMujoco.step(data)

ExMujoco.Data.qpos(data) |> ExMujoco.Tensor.to_list()
ExMujoco.Model.geom_size(model) |> ExMujoco.Tensor.to_list()
```

No Python, no `pythonx`, no wheel: a C++ NIF built with [Fine](https://github.com/elixir-nx/fine),
linked against the MuJoCo library and compiled for the machine it runs on.

## Why

[Monty](https://github.com/thousandbrainsproject/tbp.monty) — the Thousand Brains Project's
sensorimotor framework — reaches MuJoCo through the Python bindings, and in this house that
means a `pythonx` interpreter inside the BEAM. That works, and it costs: JSON between the
layers, a thread that owns the GL context, and a second runtime to ship. The physics is a C
library. This package calls it.

The plan is phased — physics first, then scene building, then rendering — and
[`docs/plan.md`](docs/plan.md) says what each phase is for and what it is waiting on.

## Install

```elixir
def deps do
  [{:ex_mujoco, "~> 0.1"}]
end
```

Building it wants a C++17 compiler and `make` — the same thing Fine wants, and the toolchain
an Elixir install already has (Xcode command line tools on macOS, `build-essential` on Linux,
MSVC on Windows). MuJoCo itself is fetched for you: the official release artifact for the
platform, checked against the checksum published beside it, in about ten seconds.

```console
$ mix compile
ex_mujoco: Fetching MuJoCo 4.0.1 for darwin-universal
ex_mujoco: MuJoCo 4.0.1 is in _build/mujoco/4.0.1-darwin-arm64
```

Three ways to point it at a MuJoCo, in the order they are tried:

| how                     | command                                         | when                                                |
| ----------------------- | ----------------------------------------------- | --------------------------------------------------- |
| your own install        | `MUJOCO_DIR=/opt/mujoco mix compile`            | Conan, vcpkg, a build of your own, a system package |
| from source, at any ref | `EX_MUJOCO_SOURCE_REF=main mix compile`         | the latest MuJoCo, or a commit that fixes something |
| from a checkout         | `EX_MUJOCO_SOURCE_DIR=~/src/mujoco mix compile` | you are working on MuJoCo                           |
| the pinned release      | `mix compile`                                   | everything else                                     |

The source build runs CMake and takes a few minutes; the release fetch takes seconds.
[`docs/building.md`](docs/building.md) has the detail, including what happens to the shared
library afterwards and why.

Windows needs nothing set up: the library MuJoCo ships is loaded from `priv/` by a second,
small library, because Windows will not look beside a library for the libraries it imports.
The one prerequisite is MuJoCo's own — the Visual C++ runtime, which ships with Visual Studio.

CI builds the tarball Hex would ship, unpacks it, and installs it in an empty directory on
Linux and Windows — compiling MuJoCo from scratch there — because a package that only builds
in its own checkout is a package nobody can use.

## What you get

`ExMujoco` is the curated surface — a model, its data, stepping, state:

|                                           |                                          |
| ----------------------------------------- | ---------------------------------------- |
| `ExMujoco.load_xml/1`, `load_string/1`    | compile MJCF, from a file or from memory |
| `ExMujoco.data/1`                         | an `mjData` for a model                  |
| `ExMujoco.forward/1`, `step/1`, `reset/1` | the three things you do to data          |
| `ExMujoco.state/1,2`, `put_state/3`       | save and restore the integration state   |
| `ExMujoco.contact_force/2`                | one contact's six numbers                |

Underneath, generated from the headers:

- **`ExMujoco.Model`** — every field of `mjModel`, one function each: `nq/1`, `geom_size/1`,
  `body_mass/1`, `jnt_axis/1` … A scalar answers a number; an array answers an
  `ExMujoco.Tensor` carrying the element type and the shape.
- **`ExMujoco.Data`** — the same for `mjData`: `qpos/1`, `qvel/1`, `xpos/1`, `efc_force/1` …
- **`ExMujoco.Api`** — MuJoCo's own functions, bound one for one, under MuJoCo's names:
  `mj_name2id/3`, `mj_step1/2`, `mj_kinematics/2` …
- **`ExMujoco.Types`** — every enum, as numbers, with names that read like Monty's Python:
  `ExMujoco.Types.Geom.box()`, `ExMujoco.Types.Obj.body()`.

Reading a field copies it, so the value you hold does not change when the next step runs.

```elixir
iex> ExMujoco.Model.geom_size(model)
#ExMujoco.Tensor<{2, 3} :f64 6 values>

iex> ExMujoco.Api.mj_name2id(model, ExMujoco.Types.Obj.geom(), "floor")
0
```

## What is not here yet

Both are in [`docs/plan.md`](docs/plan.md) with the reason they are their own phase:

- **`mjSpec`** — building a model in memory (`mjs_addBody` and the rest). Compiling XML, from
  a path or from a string, covers everything that can be expressed in MJCF.
- **Rendering** — `mjrContext` and offscreen frames. A GL context is per platform (EGL,
  OSMesa, CGL, WGL) and belongs to a thread; that is a different job from stepping physics.

`docs/coverage.md` lists every MuJoCo function this package does _not_ bind, and why. Nothing
is dropped in silence.

## Errors

MuJoCo reports a fatal error by not returning — inside a NIF that would abort the VM. The NIF
intercepts it (`c_src/mj_call.hpp`), and it arrives as `ExMujoco.Error` with MuJoCo's own
message:

```elixir
iex> ExMujoco.load_string("<mujoco></mujoco>")
{:error, %ExMujoco.Error{message: "mj_parseXMLString: ..."}}
```

## How the bindings are made

MuJoCo generates its own Python bindings by reading its headers. This package does the same:
`mix ex_mujoco.gen` parses `MUJOCO_DIR/include/mujoco/*.h` — functions, struct members with the
extents from `mjxmacro.h`, and enums — and writes the C++ that Fine registers and the Elixir
that calls it. The output is committed, so a package build needs neither the generator nor a
network, and `mix ex_mujoco.gen --check` (which CI runs) fails when the two disagree.

`c_src/manifest.exs` is the one hand-edited decision: which C functions are bound 1:1, and
which are bound by hand because each call needs an answer no signature gives.

## Releasing

`VERSION` is the one place the version lives; `mix.exs` reads it. To publish:

```console
$ ./scripts/tag_and_push_version.sh --dry-run   # what it would do
$ ./scripts/tag_and_push_version.sh             # v0.1.0, pushed
```

It refuses to tag a commit whose CI is not green, and a commit CI never saw —
a docs-only push, since the workflow ignores markdown — is compared against the
last run that did succeed. The tag is what `.github/workflows/release.yml`
publishes from: it builds the package, publishes it to Hex and creates the
GitHub release.

## Licence

MPL-2.0 for this package. MuJoCo is Apache-2.0 and is linked, not modified; see `NOTICE`.