C# bindings and a Unity integration layer for Box3D, Erin Catto's 3D physics engine. Unofficial — not affiliated with the upstream project.
- Package:
com.luthien.box3d-unity(0.2.0) - Upstream: box3d v0.1.0, built with the double-precision (large world) ABI
- Platforms: Windows x64 and Linux x64 (shipped, glibc ≥ 2.35). Others welcome via contribution.
- License: MIT (this package and Box3D itself)
- Open Window > Package Manager
- Click the + button in the top-left
- Select Add package from git URL...
- Enter:
To pin a specific release, append a tag — e.g.
https://github.com/TomMoore515/Box3DUnity.git...Box3DUnity.git#v0.2.0.
Copy this folder into your project's Assets (or Packages) directory.
| Assembly | Engine refs | What it is |
|---|---|---|
Box3D.Interop |
none | Raw P/Invoke: blittable structs mirroring the C ABI, function names verbatim from box3d.h — diffs directly against upstream headers. |
Box3D |
none | Idiomatic API: Box3DWorld, Box3DBody/Box3DShape handles, Box3DMesh, the Box3DMover character-mover solver, allocation-free queries over Span<T>. |
Box3D.Unity |
UnityEngine | Thin adapter: Vector3/Quaternion conversions, and the debug-draw renderer (Box3DDebugLineMesh + a vertex-color line shader). |
The engine-free core means the same assemblies drive headless servers, dotnet test
suites, and Unity clients identically.
using Box3D;
using Box3D.Interop;
using var world = new Box3DWorld(gravity: new B3Vec3(0f, -9.8f, 0f));
world.CreateStaticBody(B3Pos.Zero).AddBox(50f, 1f, 50f); // ground, top at y=1
var filter = Box3DWorld.DefaultQueryFilter();
// Raycast
var ray = world.CastRayClosest(new B3Pos(0, 10, 0), new B3Vec3(0, -20, 0), filter);
// Kinematic character mover (Quake-style collide & slide primitives)
var capsule = new B3Capsule { Center1 = new B3Vec3(0, 0.4f, 0), Center2 = new B3Vec3(0, 1.2f, 0), Radius = 0.4f };
float fraction = world.CastMover(new B3Pos(0, 5, 0), in capsule, new B3Vec3(0, -10, 0), filter);
Span<B3PlaneResult> contacts = stackalloc B3PlaneResult[8];
int n = world.CollideMover(new B3Pos(0, 0.9, 0), in capsule, filter, contacts);
Span<B3CollisionPlane> planes = stackalloc B3CollisionPlane[8];
n = Box3DMover.ToCollisionPlanes(contacts.Slice(0, n), planes);
var push = Box3DMover.SolvePlanes(default, planes.Slice(0, n)); // depenetration
var vel = Box3DMover.ClipVector(new B3Vec3(1, -1, 0), planes.Slice(0, n)); // ClipVelocity
// Dynamics
var ball = world.CreateBody(B3BodyType.Dynamic, new B3Pos(0, 3, 0));
ball.AddSphere(new B3Sphere { Radius = 0.5f });
world.Step(1f / 60f);Bound: world lifecycle/stepping; world queries (closest ray, raw ray/shape cast with
closest-hit helper, overlap, AABB overlap, capsule mover cast + collide); the plane solver
(b3SolvePlanes / b3ClipVector); bodies (create/destroy, transforms, target transforms,
velocities, forces/impulses, wake/sleep); shapes (sphere, capsule, box hull, triangle mesh);
mesh building (from triangle soup, box, grid); world debug draw (b3World_Draw /
b3DefaultDebugDraw — see below).
Not yet bound: joints, contact/sensor/body events, compound shapes, height fields,
convex hulls from point clouds, the external task-system hooks, world tuning setters, the
recorder/replay API (b3RecPlayer_*). The raw layer is additive — contributions only need
a B3Api entry and, where useful, a core wrapper.
box3d draws shape geometry only through the user debug-shape mechanism: hooks installed in
b3WorldDef at world creation bake a drawable handle per shape (lazily, on the first
b3World_Draw), and every draw hands those handles back through b3DebugDraw.DrawShapeFcn.
The engine never decomposes shapes into the primitive callbacks itself — those serve
contacts/joints/bounds on stepped worlds. This package wires the whole pipeline:
- Create the world with
new Box3DWorld(gravity, debugShapes: true)(orBox3DWorld.EnableDebugShapes(ref def)on a caller-built def). Costless until the first draw; a world created without the hooks draws no shapes. world.Draw(snapshot, options)captures into an engine-freeBox3DDebugSnapshot: baked shape wireframes posed by their body transforms, tessellated to world-space line segments (double-precision endpoints). With no explicitDrawingBounds, everything draws (upstream's default would cull beyond ±100 m).Box3DDebugLineMesh(Box3D.Unity) uploads the snapshot as one vertex-colored line mesh and submits it to all cameras (Scene view, Game view, player builds) viaGraphics.RenderMesh— one draw call per pass, so it scales to very large worlds where per-line gizmo drawing would not.
using var world = new Box3DWorld(default, debugShapes: true);
// ... add bodies/shapes ...
var snapshot = new Box3DDebugSnapshot();
world.Draw(snapshot, Box3DDebugDrawOptions.Default); // capture — allocates; do it on demand, not per frame
var lines = new Box3DDebugLineMesh();
lines.Build(snapshot); // double→float; pass an origin shift far from 0
var material = Box3DDebugLineMesh.CreateLineMaterial(); // depthTest:false for an x-ray pass
// each frame while visible:
lines.Render(material);Threading: treat Draw like Step, not like a query — the first draw mutates the world
(lazy per-shape handle baking), so it needs exclusive access.
Bake coverage: spheres and capsules tessellate exactly; hulls bake their local-space AABB — exact for box hulls, an envelope for arbitrary hulls (the half-edge walk needs hull internals the public headers don't define); mesh / compound / height-field shapes draw nothing yet.
- The shipped native library is built with
BOX3D_DOUBLE_PRECISION: world positions (B3Pos) are doubles, everything else floats. This is a compile-time ABI choice — the structs inBox3D.Interopmatch only this build. Box3D renamesb3CreateWorldtob3CreateWorldDoublePrecisionspecifically so a mismatched binary fails at load instead of corrupting memory. - All
b3*Defstructs carry an internal validation cookie: always start fromBox3DWorld.Default*Def()(nevernew/default). - C
boolis 1 byte; the interop layer usesbyteto stay blittable. - Every interop struct is covered by an ABI size-parity test (
Tests/Editor/InteropLayoutTests) against a checker compiled from the real headers.
World queries are safe from any thread while no thread is stepping that world; Step
requires exclusive access. Queries are re-entrant and allocation-free (results gather
into caller-provided spans through stack-based contexts).
box3d is used here as a collision-query service (rays, casts, overlaps, the mover), not a
dynamics engine, so the numbers that matter are query throughput and — the reason this binding
exists — where those queries can run. The Samples/PhysicsBenchmark sample loads an identical
field of 10,000 static boxes into a box3d world and a Unity PhysX collider scene and hits both with
the same seeded query batches; every row carries a hit-count validity gate (identical geometry +
queries must return identical hits). Figures below are from a standalone IL2CPP player on an
i9-13900K (8 P-cores / 32 threads) — indicative: absolute throughput scales with the CPU, but the
shape holds.
Single thread — PhysX's mature C++ broadphase leads every single-thread query; world build strongly favours box3d (from data vs instantiating colliders):
| Query | box3d | PhysX |
|---|---|---|
| Raycast | 0.99M/s | 1.62M/s |
| CapsuleCast | 0.58M/s | 1.11M/s |
Overlap (CheckCapsule) |
2.14M/s | 2.71M/s |
| World build (10k boxes) | 13 ms (from data) | 29 ms (GameObject colliders) |
Multi thread — the real reason to reach for box3d. Its queries are thread-safe from any thread
while nothing steps the world, so they fan across cores. PhysX's only parallel query path is the
batched job API (RaycastCommand / CapsulecastCommand):
| Query | box3d (best) | PhysX parallel | Note |
|---|---|---|---|
| Raycast | 12M/s | 30M/s | PhysX RaycastCommand wins independent casts |
| CapsuleCast | 4.7M/s | 20M/s | PhysX CapsulecastCommand wins |
| Overlap | 7.7M/s | 2.7M/s | PhysX has no parallel overlap — main thread only |
| Depenetrate | 8.0M/s | 2.5M/s | PhysX has no parallel form — main thread only |
Read honestly:
- Where Unity ships a batched job command (ray, capsule cast), PhysX's parallel path is faster — a tuned SIMD C++ batch. Don't claim a blanket multithread win.
- box3d's edge is that every query type is thread-safe, including overlap and depenetration, which
have no PhysX parallel form at all — and that box3d needs no Unity job system and no main thread,
so the same queries run on a headless server, a
dotnetprocess, or your own thread pool. Batched commands also cannot express a sequential collide-and-slide loop (each sweep depends on the last), so independent-query throughput is the wrong metric for a character controller. - Cross-platform determinism is a capability, not a speed: box3d's Windows and Linux builds produce bit-identical results, which Unity/PhysX does not guarantee — decisive for client-predicted / server-authoritative netcode. IL2CPP cannot buy this back for PhysX.
- Scaling caveat (measured): the light, callback-free raycast scales cleanly to all cores, but the
heavier queries (capsule cast, overlap, mover) are memory-bandwidth-bound — they peak around the
physical core count (8 P-cores on this CPU) and decline as SMT threads and E-cores add contention
rather than throughput, so the
box3d (best)column is the peak, not the 32-thread figure. It's a hardware/topology effect, not a binding artifact — it holds identically in Mono and IL2CPP.
Bottom line: choose box3d when you need collision off the main thread across all query types, from data with no scene, or deterministic across platforms. Choose PhysX when you need the fastest single-thread or job-batched casts and don't need those three properties.
Samples/DominoSpiral — a spiral domino train simulated by box3d with Unity as pure
presentation. Add DominoSpiralDemo to an empty GameObject in any scene with a camera
and press Play; it builds the world, dominoes, floor, and camera motion in code and
topples the chain after a short delay (Restart via the component context menu).
Click-drag any domino to shove things around — picking uses box3d's raycast and the
grabbed body is driven kinematically via SetTargetTransform, so it pushes with real
velocity and can be flung. Works with both input backends (Input System or legacy).
Samples/PhysicsBenchmark — the box3d-vs-PhysX query benchmark behind the Performance
section. Self-contained and seeded: it builds one box field into both engines and times ray / capsule
cast / overlap / depenetrate, single-thread and across worker threads, writing a Markdown report.
Gated on a BOX3D_BENCHMARK scripting define (so it compiles into nothing until you opt in) and
gated, on the PhysX side, on the built-in Physics module being enabled — see the sample's own README.
Add PhysicsBenchmark to a GameObject in an empty scene and press Play.
Note: samples are a regular runtime assembly (MonoBehaviours cannot live in
editor-only assemblies), so exclude Samples/ from production builds — delete the
folder, or gate the asmdef with a project-specific define constraint (as PhysicsBenchmark does).
See Native~/UPSTREAM.md for the pinned upstream commit, CMake flags, and per-platform
build recipes (build-windows.bat, build-linux.sh).
Tests/Editor contains the ABI layout parity suite and functional tests (queries,
mover solver loop, mesh shapes, far-from-origin precision, dynamics, bitwise determinism,
debug-draw capture).
Run via the Unity Test Runner (EditMode). Natives ship for Windows x64 and Linux x64, so
the functional tests run in a Windows or Linux editor.
MIT License - see LICENSE for details.