babylon-box3d
v0.7.0
Published
Box3D physics for Babylon.js: Erin Catto's 3D rigid body engine compiled to WebAssembly plus a physics v2 plugin (Box3DPlugin)
Maintainers
Readme
babylon-box3d
Box3D physics for Babylon.js. Box3D is Erin Catto's 3D successor to Box2D v3:
an MIT licensed rigid body engine with a soft step solver, continuous collision, cross platform determinism and
SIMD. This extension ships the engine compiled to WebAssembly plus Box3DPlugin, an implementation of Babylon's
physics v2 plugin interface. It supports most of the Physics V2 API, so scenes written for the Havok plugin generally
run on it unchanged; What is covered lists where it differs.
import { Vector3 } from "@babylonjs/core/Maths/math.vector";
import "@babylonjs/core/Physics/joinedPhysicsEngineComponent"; // adds scene.enablePhysics
import { Box3D, Box3DPlugin } from "babylon-box3d";
const box3d = await Box3D();
scene.enablePhysics(new Vector3(0, -9.81, 0), new Box3DPlugin(true, box3d));Script tags (Playground, plain HTML). A complete Playground scene is in the documentation page:
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://unpkg.com/babylon-box3d/lib/umd/box3d.umd.js"></script>
<script src="https://unpkg.com/babylon-box3d/umd/babylon.box3d.min.js"></script>
<script>
// a classic script cannot use await at the top level, so the setup runs in an async function
(async () => {
const box3d = await Box3D();
scene.enablePhysics(new BABYLON.Vector3(0, -9.81, 0), new BABYLONBOX3D.Box3DPlugin(true, box3d));
// create physics bodies from here on
})();
</script>Without npm or a build step
Like Havok's UMD build, this runs from plain files in a folder. Download these three and put them next to your page:
- box3d.umd.js, the WebAssembly loader (global
Box3D) - box3d.umd.wasm, the engine, found next to the loader
- babylon.box3d.min.js, the plugin (global
BABYLONBOX3D)
Then load them after babylon.js with the same three script tags as above, pointing at the local files. The folder
has to be opened through a web server rather than by double clicking the page: browsers refuse to load a .wasm from
file://, and Havok's wasm has the same limit. Any static server works, VS Code's Live Server or
python -m http.server in that folder among them; nothing runs on Node.
The wasm is fetched next to the loader script. With a bundler pass locateFile, for example with vite:
import { Box3D, Box3DPlugin } from "babylon-box3d";
import wasmUrl from "babylon-box3d/lib/esm/box3d.wasm?url";
const box3d = await Box3D({ locateFile: () => wasmUrl });// vite.config.ts: emscripten loaders find their wasm through import.meta.url, keep them out of the pre-bundle
export default defineConfig({ optimizeDeps: { exclude: ["babylon-box3d"] } });box3d.js and box3d.wasm have to come from the same install. If an app copies the wasm somewhere of its own (a
public folder, a CDN) and points locateFile at the copy, that copy has to be refreshed whenever the package is,
and a bundler's dependency cache (node_modules/.vite) has to be cleared with it. A loader and a wasm from different
builds cannot bind to each other: the loader fails to instantiate, or new Box3DPlugin throws naming the entry points
it could not find. Keeping the wasm out of any copy step, as above, avoids the question entirely.
Peer dependency: @babylonjs/core 8 or 9 (built and tested against 8.56.2 and 9.26.1). Babylon 9 registers
Scene.enablePhysics in @babylonjs/core/Physics/joinedPhysicsEngineComponent, so import that (or the @babylonjs/core
index) somewhere in the app.
Installing a local build
npm run build # dist and umd; lib (the wasm) is committed, rebuild it only after changing the shim
npm pack # babylon-box3d-<version>.tgz
npm install ../babylon-box3d/babylon-box3d-<version>.tgz # in the gameA tarball is the safest route because it carries no node_modules. npm install ../babylon-box3d works too, but npm
links the checkout, so the bundler finds the extension's own copy of @babylonjs/core and ships Babylon twice (the
instanceof checks in the plugin then fail). With a link, dedupe it:
// vite.config.ts
export default defineConfig({
resolve: { dedupe: ["@babylonjs/core"] },
optimizeDeps: { exclude: ["babylon-box3d"] },
});Threads
Box3D can run one world step on several threads. The package ships a second build of the wasm for that, because
threads need SharedArrayBuffer, and a browser only hands that out to a cross origin isolated
page - one served with:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpLoadBox3D picks the build and the plugin asks for the workers:
import { Vector3 } from "@babylonjs/core/Maths/math.vector";
import "@babylonjs/core/Physics/joinedPhysicsEngineComponent";
import { LoadBox3D, Box3DPlugin } from "babylon-box3d";
// "auto" uses the threaded build where the page allows it and the single threaded one everywhere else
const box3d = await LoadBox3D({ threads: "auto" });
scene.enablePhysics(new Vector3(0, -9.81, 0), new Box3DPlugin(true, box3d, { workerCount: "auto" }));workerCount counts the thread the step is called on, so 4 means this thread and 3 others. "auto" asks for half
of navigator.hardwareConcurrency: box3d gains little from the second thread of a core, and the renderer still needs
somewhere to run. The threaded module is imported dynamically, so a page that never asks for threads never downloads
it. CanUseBox3DThreads() reports whether this page can run them.
Everything degrades rather than breaking. On a page that is not isolated, threads: "auto" loads the single threaded
build, workerCount is clamped to 1 and the plugin says so once in the console. threads: true throws instead, for
an app that would rather find out than quietly run on one thread. Isolation is a hosting decision: a static host that
cannot add response headers (GitHub Pages, for one) needs a service worker to add them, the way the hosted demo does
(demo/public/coi-serviceworker.js), and the headers also block cross origin
resources that do not opt in with CORS or Cross-Origin-Resource-Policy, which is worth checking before turning them
on for a whole site. npm run demo sets both headers, so the showcase runs threads with ?workers=4.
What the threads do and do not change:
- Results do not change. The same scene stepped the same number of times lands on bit identical positions at any
worker count, which is what
test/threads.test.tschecks against the single threaded build. - The step still returns when it returns. The calling thread does its share of the work and the step is over when the last worker is done; nothing is deferred to the next frame, so there is no extra latency and no API change.
- Other engines run on one thread. Havok's Babylon plugin is single threaded, so this is cores nothing else in Babylon is using.
- Small scenes gain nothing. Splitting a step costs something; under a few hundred awake bodies it is not worth it. Ask for workers on the scenes that need them.
- There is no threaded script tag build. A Playground or CDN page is not isolated, so it could not start one.
The threads themselves belong to the module, not to a world: they are created once, shared by every world, and never
joined. That is deliberate. Box3D's own scheduler creates threads with a world and joins them when it is destroyed,
and joining is what deadlocks a browser - a worker that has not finished starting cannot finish while the main thread
waits for it in pthread_join, and disposing a scene and building the next one in the same function is enough to
reach that. The shim hands box3d its own task system instead (wasm/box3d_shim.c), so worlds come and go freely.
Tuning
subStepCount(default 4, box3d's own) is the solver's sub steps per step:new Box3DPlugin(true, box3d, { subStepCount: 2 }), orplugin.subStepCountat any time. Tall stacks need the 4; 2 roughly halves solver time for scenes that are mostly loose bodies, 8 buys stiffness in exchange for time.plugin.setSleepingEnabled(false)measures raw throughput but costs a lot in a settled scene: sleeping is why a standing pyramid is nearly free.- Contact events cross into JavaScript one record per contact per step. A body nothing listens to should not be
asking for them:
body.setCollisionCallbackEnabled(false), which is the default.
Double precision
Box3D keeps a body's position in floats, which are 2 mm apart 30 km from the origin, so a large world steps
differently far out than near the origin. Box3D's large world mode (BOX3D_DOUBLE_PRECISION) keeps positions and
transforms in doubles, while velocities, shapes and contacts stay floats, and the package ships it as builds of their
own, single threaded and threaded:
const box3d = await LoadBox3D({ threads: "auto", doublePrecision: true });
// or the modules themselves: babylon-box3d/wasm/f64 and babylon-box3d/wasm/f64-threads, and in node
// babylon-box3d/wasm/node-f64 and babylon-box3d/wasm/node-f64-threadsBoth builds have the same entry points, and both take positions in doubles: a body's position and target transform, the point a force or impulse is applied at, a ray's or shape cast's origin and an explosion's centre. The float build rounds each to the float it keeps, as the call did when it took floats, so its results have not changed. Positions come back in floats where they always did, and in doubles beside them:
| floats | doubles |
| --- | --- |
| bx_Body_GetTransform: the scratch buffer's first three | bx_ScratchF64(): the position, as the build holds it |
| bx_RayHitsPtr: each hit's point | bx_RayHitPointsF64Ptr(): three a hit, for rays and shape casts |
| bx_BodyContactsPtr: each contact point's position | bx_BodyContactPointsF64Ptr(): twelve a manifold, three a point |
bx_IsDoublePrecision() says which build is loaded. Move and contact events stay floats.
What it buys (test/wasm.largeWorld.test.ts): a car settling and coasting along a road 30 km from the origin stays
within 3e-11 m of its run at the origin, where the float build's is 5.5 cm off. What it does not: Box3D's broad phase
keeps its bounds in float world space, rounded outward, so far out a new pair can be made a step earlier or later than
at the origin, and from where the car first meets another shape the two runs part (by 0.18 mm a third of a second
later, in the test). A run at the same coordinates still repeats bit for bit. A mesh or height field block
(bx_ShapeDesc_GetGeometryBytes) is the same bytes in either build, so one built by the float build loads in the
double precision build, to the same contacts.
What it costs: on R3's rate probe cases (a car of 20 to 160 hulls riding, sliding and pushed into a wall over a height field and a road mesh, at 480 and 240 Hz), with the two builds taking turns of 1,000 steps in one process, a step took 6.3% longer in the double precision build summed over the cases with Box3D's defaults, and 6.6% with the car's hulls filtered against each other and the car unswept. Case by case the difference ran from 17% less to 17% more, on a machine half busy with other work, and the median case took 6.6% and 8.5% longer.
What is covered
| Babylon v2 | Box3D |
| --- | --- |
| PhysicsShapeType.SPHERE, CAPSULE | sphere, capsule |
| BOX, CYLINDER, CONVEX_HULL | convex hulls (hulls above box3d's 128 edge limit are simplified) |
| MESH | triangle mesh, static and animated bodies only |
| HEIGHTFIELD | height field, static bodies only, with holes (see below) |
| CONTAINER | multiple Box3D shapes on one body (a mesh child's transform is baked into a copy of its mesh data) |
| BALL_AND_SOCKET | spherical joint (cone and twist limits) |
| HINGE | revolute joint (rotation about axisA) |
| PRISMATIC, SLIDER | prismatic joint |
| LOCK | weld joint |
| DISTANCE | distance joint with a fixed length of maxDistance |
| SIX_DOF (Physics6DoFConstraint) | one Box3D joint chosen from the limits with Havok's axis rules, see below |
| SpringConstraint | distance joint spring (stiffness in N/m like Havok) |
| COLLISION_STARTED / FINISHED | contact begin / end touch events (started carries the manifold point, normal and normal impulse) |
| COLLISION_CONTINUED | contact hit events (point, normal, approach speed as impulse) |
| TRIGGER_ENTERED / EXITED | sensor events |
| thin instances | one Box3D body per instance |
Height field holes. Box3D height fields take a material index per cell, and 255 cuts the cell out: nothing
collides with it and rays pass through. Pass them as heightFieldMaterials, one per cell in the same row order as
heightFieldData:
import { PhysicsShape } from "@babylonjs/core/Physics/v2/physicsShape";
import { PhysicsShapeType } from "@babylonjs/core/Physics/v2/IPhysicsEnginePlugin";
import { BOX3D_HEIGHT_FIELD_HOLE } from "babylon-box3d";
const materials = new Uint8Array((samplesX - 1) * (samplesZ - 1));
materials[row * (samplesX - 1) + column] = BOX3D_HEIGHT_FIELD_HOLE;
const terrain = new PhysicsShape(
{
type: PhysicsShapeType.HEIGHTFIELD,
parameters: {
heightFieldSizeX: sizeX,
heightFieldSizeZ: sizeZ,
numHeightFieldSamplesX: samplesX,
numHeightFieldSamplesZ: samplesZ,
heightFieldData: heights,
heightFieldMaterials: materials,
},
},
scene,
);A ray's triangleIndex is the height field triangle it hit, as it is for a mesh.
Shapes from points. CONVEX_HULL and MESH take their points straight from positions, as x, y, z triplets in the
body's space, when no mesh is given, so code with no meshes to read them from, like a headless simulation, can still
build them. A mesh also takes positionIndices, three per triangle, wound so the plain cross product of (b - a) and
(c - a) points out of the surface. That is Box3D's own winding, so nothing is flipped for a left handed scene:
const hull = new PhysicsShape({ type: PhysicsShapeType.CONVEX_HULL, parameters: { positions } }, scene);
const road = new PhysicsShape({ type: PhysicsShapeType.MESH, parameters: { positions, positionIndices } }, scene);A CYLINDER runs from pointA to pointB. Before 0.3.0 it was built half its height further along its axis.
PhysicsMassProperties follows Havok: inertia is the principal moments per unit mass (so setting only mass
scales the shape's inertia with it), inertiaOrientation rotates those principal axes into body space, and a zero
component means infinite inertia about that axis. Box3D needs an invertible tensor, so a locked axis gets a moment
100000 times the largest free one, and when the remaining free axis is world aligned (the usual inertia (0, 1, 0)
upright character) Box3D's own motion locks are added on top, which makes it exact.
Event masks use Havok's bits (1 started, 2 continued, 4 finished) and only enable what they ask for: started and
finished map to Box3D's begin/end touch events, continued to its hit events, so bodies that only want one kind (every
body of a Babylon Ragdoll, for instance) do not pay for the others.
SIX_DOF limits follow Havok: an axis that is not listed is free, minLimit === maxLimit === 0 locks it, anything
else limits it (one sided ranges such as 0..140 degrees work). The frame is [axis, perpAxis, axis x perpAxis] on each
body; ANGULAR_X is the rotation of the child frame about axis, ANGULAR_Y about perpAxis and ANGULAR_Z about
axis x perpAxis, right handed, in the same directions as Havok (the tests check this against Havok). Box3D has a fixed
set of joints, so the limits pick one:
| linear axes | angular axes | Box3D joint |
| --- | --- | --- |
| all locked | all locked | weld |
| all locked | one limited or free | revolute about that axis with its limits |
| all locked | two or three open | spherical: twist limits from ANGULAR_X, one symmetric cone from ANGULAR_Y/ANGULAR_Z |
| two locked | all locked | prismatic along the open axis |
| only LINEAR_DISTANCE | free | distance joint (a spring when min == max and a stiffness is set, a rope otherwise) |
| free | free | filter joint (only disables collision between the pair) |
Box3D's cone is symmetric: when the ANGULAR_Y and ANGULAR_Z ranges differ or are asymmetric the larger one is used
and a warning is logged once; cones are capped at 90 degrees. Limit stiffness/damping become Box3D's per joint
constraint softness, an approximation that applies to the whole joint. Other combinations warn once and use the closest
joint above.
setActivationControl follows Havok: ALWAYS_ACTIVE turns Box3D sleeping off for the body, ALWAYS_INACTIVE parks it
(it ignores impulses and velocity changes, is not woken by anything touching it and still blocks other bodies) and
SIMULATION_CONTROLLED hands it back to the solver, asleep until something wakes it.
Box3D extras on the plugin: explode, createWheelJoint (suspension, steering, spin motor), createParallelJoint,
setShapeFilterGroup, setShapeRollingResistance, setAllowFastRotation, getStats. PhysicsCharacterController
is not supported yet, it depends on Havok internals.
Beyond Babylon's API. A simulation that drives the module directly, without Babylon, also has these raw exports
(typed in lib/*/box3d.d.ts, tested in test/wasm.*.test.ts):
| exports | what they do |
| --- | --- |
| bx_Body_GetContacts, bx_BodyContactsPtr | every touching manifold on a body as the body feels it: the shapes on both sides by index, the normal it is pushed along, friction and twist, and per point the position, separation, impulses, approach speed, and the triangle and material met on a mesh or height field |
| bx_Body_SetShapeHull, bx_Body_TranslateShape, bx_Body_SetShapeFilter, bx_Body_RemoveShape, bx_Body_HasShape, bx_Body_GetShapeInfo | per-shape handles: change one of a body's shapes and reset only its contacts, where bx_Body_SetShape rebuilds them all. A shape keeps its index for the body's lifetime, removed or not, and contact events (two floats appended) and ray hits (the last float) carry it |
| bx_Body_SetShapeCrush, bx_Body_GetShapeCrush | crushable contacts, see Patches to Box3D |
| bx_World_CastShape, bx_World_OverlapShape, bx_OverlapsPtr | a convex point cloud with a radius, from an origin given in doubles, cast along a translation or tested for overlap. The cast's fraction is where the surfaces touch: Box3D stops its own casts a linear slop short of that, and the shim moves the fraction on |
| bx_Body_GetShapeGeometry, bx_GeometryPtr | a shape's sphere, capsule or hull points, in the body's frame |
| bx_Body_SetFeatureKey, bx_Body_GetFeatureKey, bx_Body_GetShapeFeatureKey | canonical pairs, see Patches to Box3D: a body's key (1 to 2^44 - 1, as a double) gives each of its shapes the key (key << 20) \| index, by which new contacts are made, continuous collision sweeps, and a closest ray or shape cast breaks a tie (then by body slot and shape index) |
| bx_ShapeDesc_GetGeometryBytes, bx_ShapeDesc_GetGeometryByteCount, bx_ShapeDesc_CreateMeshFromBytes, bx_ShapeDesc_CreateHeightFieldFromBytes | a mesh's or height field's data as bytes, and a description made from a copy of them. Box3D keeps either as one block addressed by offsets, with no pointer in it, so an instance with no world (a worker's) can build the geometry and another instance of the same build copies the block in as it is: nothing in it needs fixing up. The import checks the version, the byte count and that every array lies inside the block, and costs a copy |
| bx_IsDoublePrecision, bx_ScratchF64, bx_RayHitPointsF64Ptr, bx_BodyContactPointsF64Ptr | positions in doubles, see Double precision |
| bx_ShapeDesc_SetInvokeContactCreation, bx_ShapeDesc_GetInvokeContactCreation, bx_World_GetStackUsed | Box3D's invokeContactCreation per description, on by default. Off, a static shape never enters the broad phase's move buffer: the first step reserves no pair slots for it (486 bytes a static, which the step stack then keeps for good) and it is paired when a moving shape finds it, so a static made late pairs on the same step as one made at the start. Off on a container, it is off for every shape made from its children. bx_World_GetStackUsed says the most the step stack has held |
Box3D creates shapes on bodies while Babylon creates shapes standalone, so a PhysicsShape is a description that
is instantiated on every body it is set on. Changing its filter masks, material or density is applied to the live
Box3D shapes in place; only geometry, children and the trigger flag rebuild them. Hull data is copied into Box3D's world database, mesh and height field
data are shared and reference counted. Only bodies reported by Box3D's move events are synced each step. Box3D is
right handed and Babylon left handed by default; no conversion is done, the simulation runs in the mirrored frame
and mesh winding is flipped, exactly like the Havok plugin.
Layout
| path | what |
| --- | --- |
| src/box3dPlugin.ts | the plugin (IPhysicsEnginePluginV2) |
| src/index.ts | package entry: Box3DPlugin, Box3DWheelJoint, Box3D (wasm factory), LoadBox3D |
| src/loadBox3D.ts | picks the single threaded or threaded wasm build for the page |
| wasm/box3d_shim.c | C shim: flat, handle based API over box3d (bx_* functions) |
| wasm/build.mjs | emcc build for lib/esm, lib/umd (global Box3D), lib/node, the threaded pair and the double precision builds |
| lib/ | committed wasm builds and typings, UPSTREAM_COMMIT is the box3d commit |
| wasm/patches/ | changes to Box3D itself, applied in name order to a copy of the box3d checkout at build time, see Patches to Box3D |
| lib/esm-threads, lib/node-threads | the same module built with pthreads, see Threads |
| lib/esm-f64, lib/node-f64 and their -threads twins | the same modules built with BOX3D_DOUBLE_PRECISION, see Double precision |
| umd/ | plugin bundle for script tags (global BABYLONBOX3D), built by npm run build:umd |
| demo/ | vite showcase: npm run demo, then http://localhost:5178/?demo=pyramid |
| docs/ | the community extension page for the Babylon.js documentation |
| test/ | vitest unit tests with a mocked wasm module, tests against the real wasm, node smoke test, and packaged.test.ts over the built dist and every loader in lib |
| bench/ | Box3D vs Havok vs Oimo benchmark: npm run bench (node) or demo/bench.html (browser), results in bench/results |
The demo is published to GitHub Pages at https://pryme8.github.io/babylon-box3d/ by .github/workflows/pages.yml.
Pages cannot send the isolation headers threads need, so demo/public/coi-serviceworker.js adds them from a service
worker; that is only needed because of the host, and an app that controls its own headers does not want it.
Demos: pyramid (Box3D's Large Pyramid benchmark, thin instances, &rows=100 for 5050 boxes, click to explode),
ragdolls (Erin's human ragdoll sliding down a chute), car (wheel joints, WASD), destruction (brick tower and
wrecking ball), plus stack, joints, terrain, compound feature tests. Add &ui=0 to hide the overlay.
Benchmarks
Box3D, Havok and Oimo build the same scenes through Babylon's regular physics API (v2 for Box3D and Havok, v1 for Oimo), with engine defaults, a fixed 1/60 s step and nothing rendered. "Step" is Babylon's whole physics step, "engine" is only the engine's own world step. Median of 3 interleaved runs after a warm up, AMD Ryzen 9 5900X, Babylon.js 9.26.1, @babylonjs/havok 1.3.14, oimo 1.0.9, node 24. Mean milliseconds per step, sleep on, every engine on one thread (2026-09-17):
| Scene | Box3D | Havok | Oimo | | --- | ---: | ---: | ---: | | Pyramid, 20 rows (210 boxes) | 0.09 | 0.51 | 6.3 | | Pyramid, 50 rows (1275 boxes) | 0.98 | 7.45, collapses | 68.2, collapses | | Pyramid, 100 rows (5050 boxes) | 28.2 | 27.5, collapses | 174, collapses | | Pile, 1000 boxes and spheres | 3.44 | 4.48 | 31.3 | | Pile, 4000 boxes and spheres | 20.9 | 22.0 | 183 |
The same Box3D scenes on the threaded build. Havok and Oimo have no equivalent, so this is time the other two cannot take back:
| Scene | 1 thread | 4 workers | 8 workers | | --- | ---: | ---: | ---: | | Pyramid, 20 rows (210 boxes) | 0.09 | 0.05 | 0.11 | | Pyramid, 50 rows (1275 boxes) | 0.98 | 0.42 | 0.34 | | Pyramid, 100 rows (5050 boxes) | 28.2 | 7.55 | 5.72 | | Pile, 1000 boxes and spheres | 3.44 | 1.38 | 1.65 | | Pile, 4000 boxes and spheres | 20.9 | 7.21 | 6.31 |
- Box3D keeps every pyramid standing for 30 s of simulated time, up to 100 rows. With Babylon's default Havok setup
a 30 row pyramid is flat within 30 s and a 50 row one within 10 s (
bench/results/pyramid-stability-*.md). - In the piles Havok's own world step is faster on one thread (15.7 ms vs 19.5 ms at 4000 bodies). Box3D's full Babylon step is faster because the plugin only syncs bodies that Box3D reports as moved.
- The 210 box pyramid is slower with 8 workers than with none, and the 1000 body pile is better at 4 than at 8: splitting a step is not free, and the scenes that pay for it are the big ones.
- Every threaded run ends in the same state as the single threaded one. The result column in all three tables is identical, drift and pile height included, which is the determinism claim holding at 5050 bodies.
- Chrome tells the same story, threads included: 23.6 ms to 7.5 ms on the 100 row pyramid, 23.6 ms to 6.8 ms on the
4000 body pile at 8 workers. The one place it differs is that pile on one thread, where Chrome puts Havok's full
step ahead of Box3D's (20.6 ms against 23.6 ms) while node has them the other way round (
bench/results/chrome-*.md). - Full tables, including sleep off, p95 and max step times, are in
bench/results. Runnpm run bench,npm run bench -- --workers 4, or openbench.htmlfromnpm run demoand set the worker count there.
Building
npm install
npm run build # dist (ESM + d.ts) and umd bundles
npm test # wasm smoke test + unit and real wasm tests
npm run build:wasm # rebuild the wasm, needs the Emscripten SDK (EMSDK or ../emsdk) and a box3d checkout (BOX3D_DIR or ../box3d)npm test runs the node smoke test against the shim, unit tests with a mocked wasm module, and tests that drive the
plugin through Babylon's own classes against the real wasm: constraints, events, shapes, filtering, mass properties,
activation and a zombie ragdoll (18 jointed boxes dropped and stepped for 10 s). Several of them build the same scene
with Havok and compare, which is what pins the Havok compatible behaviour down.
npm run build:wasm produces both builds: the single threaded one in lib/esm, lib/umd and lib/node, and the
threaded one in lib/esm-threads and lib/node-threads. Add --no-threads to skip the second while iterating on the
shim. Both are wasm SIMD128; the threaded one adds -pthread and a pool of 8 workers, which is the cap the shim
clamps workerCount to. npm run bench -- --workers 4 runs the benchmark on it. Each is built again with
BOX3D_DOUBLE_PRECISION from objects of its own, into lib/esm-f64, lib/node-f64 and their -threads twins:
--no-f64 skips them and --f64-only builds only them. BOX3D_FLAVOUR=f64 npx vitest run runs the tests on the
double precision builds (vitest.config.ts sends every import of a node loader to its twin).
Patches to Box3D
The wasm is built from the box3d commit in UPSTREAM_COMMIT plus the patches in wasm/patches. npm run build:wasm
copies the checkout's src and include into build/box3d-src and applies the patches there, so the checkout itself
stays exactly upstream's commit, and a change to the patch set rebuilds every object.
| patch | what it changes |
| --- | --- |
| 0001-broad-phase-prunes-by-mask.patch | A shape that moved queries the broad phase with its own mask bits instead of every category (unless it is in a positive group, which overrides masks). A body whose shapes are filtered against each other then skips its own subtree instead of visiting every sibling shape before rejecting it. Which pairs collide does not change. |
| 0002-continuous-per-body.patch | b3Body_EnableContinuous and b3Body_IsContinuousEnabled (shim: bx_Body_EnableContinuous, bx_Body_IsContinuousEnabled): continuous collision switched for one body. The world's switch still applies. A body made of many small shapes is swept shape by shape whenever its smallest shape moves fast enough, which can cost more than the rest of the step. |
| 0003-crushable-contacts.patch | b3Shape_SetCrushLimit(shape, maxForce, plastic), b3Shape_GetCrushLimit and b3Shape_IsCrushPlastic (shim: bx_Body_SetShapeCrush(body, i, maxForce, plastic), bx_Body_GetShapeCrush): each contact of a crushable shape pushes with at most maxForce newtons, the smaller of its two shapes' limits, shared between its points, so a body meeting something rigid decelerates over a distance instead of stopping within a step. A plastic contact gets no push-out and no restitution, so it keeps the overlap it reaches. Crushable contacts are routed to the scalar solver path, where each point's accumulated impulse is clamped; a point still apart pushes only for the part of the substep after the surfaces meet. Rigid contacts are untouched, bit for bit. The limit may change every step, and a shape turning crushable or rigid keeps its contacts. |
| 0004-hull-builder-fails-instead-of-spinning.patch | b3CreateHull returns NULL where it used to loop forever. A flat or nearly flat point set a few metres from the origin (a crushed panel, a slab) can leave the builder's half-edge mesh inconsistent: a merge leaves two faces that share every edge and b3HullBuilder_ConnectFaces walks round them without end, or a face's ring stops closing and b3NewellPlane does. Such a call never returned and locked whatever made it. Now every walk over the mesh counts its steps against the edge pool, the merge loops count merges against the face pool, and the horizon's stack and edge list check their capacity, which only asserts guarded before. None of these limits can be reached by a consistent mesh, and a build that reaches one returns NULL (shim: bx_ShapeDesc_CreateHull and bx_Body_SetShapeHull return 0, and the plugin throws for a CONVEX_HULL shape). Hulls that were built before are built the same, bit for bit. |
| 0005-canonical-pairs.patch | b3ShapeDef::featureKey, b3Shape_SetFeatureKey and b3Shape_GetFeatureKey (shim: bx_Body_SetFeatureKey): a moved proxy's new pairs are sorted by the other shape's feature key, then child index, then shape id, before their contacts are made. Contact ids, and with them the order the solver meets contacts, came from the order the static tree gave pairs, which is a function of every insertion and removal it has seen, so the same statics in reach could collide differently depending on how and when the world was built. A moved proxy's pairs are no longer dropped past sixteen: if the step's pair slots run out, the pairs are found again with room for all of them. Continuous collision gathers its candidates, sorts them by key and sweeps them in that order, since each sweep is clipped by the ones before it. Shapes without a key fall back to their ids, so a world without keys behaves as before except where a step's new pairs came out of the tree in another order. |
test/wasm.patches.test.ts covers 0001 and 0002, test/wasm.crush.test.ts covers 0003, test/wasm.hulls.test.ts covers 0004
and test/wasm.canonicalPairs.test.ts covers 0005.
None has been offered upstream.
Crush accuracy. Against a constant limit the crush depth lands within 1.5% of the continuous answer at 480 Hz with two substeps, and within 2% on a height field. What remains is Box3D's, not the patch's: its semi-implicit integrator travels v·h/2 less under any constant force, and it makes contact points only within its 2 cm speculative distance, so a body that covers more than that in a step (above 9.6 m/s at 480 Hz) can start up to v·dt − 2 cm inside. Mesh and height field contacts also carry a 5 mm skin.
License
MIT. Box3D is MIT licensed by Erin Catto, see LICENSE-box3d.txt. The ragdoll data in the demo is ported from
box3d's shared/human.c.
