Skip to content

feat: Run on the web via a WebAssembly build of Box2D#116

Merged
spydon merged 5 commits into
mainfrom
box2d-wasm
Jul 20, 2026
Merged

feat: Run on the web via a WebAssembly build of Box2D#116
spydon merged 5 commits into
mainfrom
box2d-wasm

Conversation

@spydon

@spydon spydon commented Jul 19, 2026

Copy link
Copy Markdown
Member

Stacked on #115.

What

Web support for the new native-backed forge2d, as designed in the migration plan: the same public API now runs in the browser under both dart2js and dart2wasm, against a bundled WebAssembly build of Box2D v3.1.1.

How

  • C shim (native/wasm/f2d_shim.c): the wasm C ABI passes structs indirectly, so raw Box2D exports are impractical to call from JS. The shim wraps every function the backend needs with flat scalar/pointer signatures (242 exports), copies polled events into float64 record buffers, and routes the synchronous callbacks (ray casts, overlap queries, custom filter, pre-solve, debug draw) through 13 fixed host imports supplied at instantiation, so no function tables or runtime table growth are needed.
  • Module build (tool/build_wasm.sh): Emscripten standalone wasm (no JS glue), -O3 -msimd128 -msse2 -DNDEBUG, ~220 KB, committed at lib/src/backend/wasm/box2d.wasm and shipped in the package. build-wasm.yml rebuilds it with a pinned emsdk (4.0.15) and fails when the committed artifact drifts.
  • Dart backend (raw_box2d_wasm.dart + wasm/wasm_runtime.dart): implements the backend seam over dart:js_interop only (works on both web compilers), with a fixed scratch arena for arguments/results, a growable bulk buffer for events and vertex lists, WASI stubs, and memory-growth-safe heap views. The conditional export in backend.dart selects it automatically on the web.
  • Loading: initializeForge2D() fetches the module from the package asset path (Dart web tooling serves it automatically), falling back to box2d.wasm next to the page. Flutter web apps copy it there once with dart run forge2d:setup_web, or pass wasmUri: explicitly.

Notable fixes found by the web tests

  • Chain creation now validates Box2D's requirements (at least four points, one material or one per point). The native release build compiles asserts out and silently accepted a three-point open chain; the assert-enabled wasm build hung on it.
  • The simulation callback dispatch had a 1-based/0-based world index mismatch (b2WorldId.index1 vs b2ShapeId.world0).

Testing

  • The full API suite runs on three platforms now: dart test (VM/FFI), dart test -p chrome (dart2js), dart test -p chrome -c dart2wasm; all 84 tests pass on each locally, and cicd.yml gains the two web jobs.
  • build-wasm.yml verifies artifact reproducibility.

Base automatically changed from box2d-bindings to main July 20, 2026 19:10
spydon added 5 commits July 20, 2026 21:12
The same public API now works in the browser under both dart2js and
dart2wasm. Box2D and a pointer-based C shim (native/wasm/f2d_shim.c) are
compiled with Emscripten into a standalone ~220 KB box2d.wasm, shipped
inside the package and loaded by initializeForge2D(). The shim flattens
every struct-passing call into scalars and pointers (the wasm C ABI cannot
pass structs by value across the JS boundary), copies events into float64
record buffers, and routes the synchronous callbacks (queries, custom
filter, pre-solve, debug draw) through fixed host imports supplied at
instantiation, so no function tables are needed.

On the Dart side, RawBox2DWasm implements the backend seam over
dart:js_interop with a scratch arena for arguments and results, and the
conditional export picks it up automatically on the web. Dart web apps
need no setup; Flutter web apps copy the module once with
dart run forge2d:setup_web, or pass wasmUri to initializeForge2D.

The API test suite now runs on vm, chrome/dart2js, and chrome/dart2wasm
(new CI jobs), and build-wasm.yml rebuilds the module with a pinned emsdk
to verify the committed artifact. Along the way chain creation gained
proper validation: Box2D requires at least four points, which the native
release build silently ignored with asserts compiled out.
Flutter web apps needed a manual setup step to host box2d.wasm. Declaring
the module as a package asset makes Flutter bundle it into every consuming
app, and the loader now checks the bundled asset path, so the same zero
setup applies to Dart web and Flutter web alike. Verified with a real
Flutter web app depending on forge2d: it builds, bundles the module, and
runs a simulation with no configuration.

Also loosens the build hook dependency minimums (hooks ^2.0.0,
native_toolchain_c ^0.19.0): the newest patch releases require meta 1.19
while the Flutter SDK pins meta 1.18, which made forge2d unresolvable in
Flutter apps altogether.
Shape.geometry returns the sealed ShapeGeometry read from the simulation:
circles, capsules, segments, polygons with their convex hull vertices and
rounding radius, and chain segments as the segment they collide with.
Renderers like flame_forge2d's BodyComponent need this to draw fixtures.

Implemented across both backends (new f2d shim exports for the wasm side)
and covered by a round-trip test for every kind on vm, dart2js, and
dart2wasm. Also finishes the abbreviation cleanup in the wasm backend and
the C shim, which the earlier rename missed because positional parameter
names are not checked against the interface.
- Mutation safety during step: destroying bodies, shapes, chains, and
  joints from inside a step (collision callbacks) is deferred until the
  step returns, restoring the old engine's semantics (compare 0704959);
  destroy is idempotent, and mid-step creates throw a catchable
  StateError instead of aborting the process in native code.
- World.destroy unregisters the custom filter and pre-solve callbacks,
  so their closures no longer leak in the backend registries or fire for
  a recycled world slot.
- Body.destroy releases the user data of chains that die with the body,
  tracked through a chain-to-owner registry.
- The wasm backend reads ids as unsigned consistently, keeping handles
  from creation, queries, and events equal even after 32768 generations
  of slot reuse.
- 64-bit filter masks with the top bit set now split exactly on the wasm
  seam instead of collapsing to all-ones; -1 remains the all-categories
  sentinel.
- Strings cross into the wasm module through a growable buffer instead
  of a fixed 4 KB scratch slice; body names are documented as truncated
  to 31 bytes by Box2D itself.
- The C shim heap-allocates its id arrays instead of using caller-sized
  stack VLAs that could overflow the 64 KB wasm stack.
- createBody and createShape validate finiteness, damping, density, and
  radius in debug mode, restoring the old Dart-side NaN safety net.
- The README migration guide documents the (0, -10) default gravity of a
  bare World() and the new mid-step mutation semantics.
Every public getter and setter on Body, Shape, Chain, and all eight joint
types now round-trips through both backends, together with the DebugDraw
default no-ops and the math conveniences. Line coverage of the VM-runnable
library code rises from 77% to 96%, and the same suite runs on
chrome/dart2js and chrome/dart2wasm.

The tests immediately caught a real Box2D behavior worth knowing: setting
fixedRotation zeroes the body's angular velocity.
@spydon
spydon enabled auto-merge (squash) July 20, 2026 19:15
@spydon
spydon merged commit 409b275 into main Jul 20, 2026
9 checks passed
@spydon
spydon deleted the box2d-wasm branch July 20, 2026 19:17
spydon added a commit that referenced this pull request Jul 20, 2026
…#117)

Stacked on #116 (which is stacked on #115).

## What

The old browser examples return as a modern, single-page demo gallery
running on the WebAssembly backend, with a GitHub Pages deployment.

**Scenes** (all draggable with the pointer via a mouse joint):
- **Pyramid** and **Domino tower** (a bullet ball topples it)
- **Ball cage** (zero gravity, kinematic spinning paddle, chain-loop
cage)
- **Circle stress** (320 balls in a container)
- **Blob** (a soft body of distance-joint springs)
- **Bridge** (revolute-joint planks under load)
- **Racer** (wheel-joint car on rolling chain terrain, arrow keys to
drive, following camera)

**Presentation**: dark theme, palette-tinted shapes through Box2D v3's
per-shape custom colors, pill navigation with URL-hash deep links, hint
bubble per scene, device-pixel-ratio aware canvas, fixed-timestep loop
with an accumulator. Rendering goes through the public `DebugDraw`
interface, so the gallery doubles as a demonstration of it.

**Deploy**: `deploy-examples.yml` builds with `build_runner --release`
and publishes to GitHub Pages on pushes to main. One-time repo setup:
enable the GitHub Actions source under Settings > Pages.

## Testing

Built and verified locally in headless Chrome: all scenes render and
simulate on the wasm backend (screenshots in the PR conversation),
`melos run format-check` and `dart analyze --fatal-infos` green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants