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

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 mainJuly 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 mainJul 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.
spydon added a commit to flame-engine/flame that referenced this pull request Aug 13, 2026
Migrates `flame_forge2d` (and everything in the monorepo that uses it)
to the new forge2d, the
native Box2D v3.1.1 bindings that shipped in forge2d 0.15.0 through
flame-engine/forge2d#115 (the
native rewrite) and flame-engine/forge2d#116 (web support through a
WebAssembly build).
`flame_forge2d` now depends on the published `forge2d: ^0.15.1`, which
includes
`initializeForge2D(lengthUnitsPerMeter:)` and `Tolerances` from
flame-engine/forge2d#120.
`flame_forge2d` stays in the
`customer_testing.dart` exclusions, because forge2d compiles Box2D from
source through the Dart
build hooks and so needs a C toolchain on the flutter/flutter presubmit
runner; the reasoning is
documented next to the exclusion.
## Core package
- `Forge2DWorld` steps the world with `physicsWorld.step(dt,
subStepCount: subStepCount)` and then
dispatches the polled contact and sensor events through the new
overridable
`ContactEventsDispatcher` (replacing `WorldContactListener`, since
listener interfaces no longer
exist). It keeps a Dart-side `bodies` set (upstream no longer exposes
one) which the gravity
setter uses to wake bodies, and exposes the new query API
(`castRayClosest`, `castRay`,
`castRayAll`, `overlapAabb`) plus forwarding setters for
`preSolveCallback` and
`customFilterCallback`.
- `ContactCallbacks` keeps its familiar `beginContact(Object other,
Contact contact)` shape through
a new lightweight flame-side `Contact` class that wraps both contact and
sensor events.
- `BodyComponent` renders from the new `Shape.geometry` read-back
(`Circle`, `Capsule`, `Segment`,
`Polygon`; chain segments arrive as `Segment`s), with
`renderShape`/`renderSegment` and a new
`renderCapsule`. `fixtureDefs` is replaced by `shapeSpecs` (a list of
`ShapeSpec`, pairing a
`ShapeGeometry` with an optional `ShapeDef`). The default `createBody()`
auto-enables
contact/sensor events on shapes whose body or shape userData is a
`ContactCallbacks`, since the
new engine only generates events for shapes that opted in.
- SDK floors raised to Dart `>=3.12.0` / Flutter `>=3.44.0` (root
workspace, melos bootstrap,
package, and `FLUTTER_MIN_VERSION` in CI).
- `Forge2DGame` awaits `initializeForge2D()` in its `onLoad`, and
`Forge2DWorld` creates its
physics world lazily so that this can happen first. Without it every
`Forge2DGame` throws on the
web, since that call is what loads the Box2D WebAssembly module. Code
that creates a world
outside of a `Forge2DGame` has to await it itself.
- Tests rewritten against real physics worlds (mocked
`Fixture`/`Contact`/`Manifold` are gone) and
all goldens regenerated. `flame`'s `MultiTapDispatcher.handleTapDown`
annotation changed from
`@internal` to `@visibleForTesting` so the tests can use it without
ignores.
## Examples and docs
- The examples stories, `padracing`, and the package example are
migrated. The examples for joints
that no longer exist in Box2D v3 (gear, pulley, rope, friction,
constant-volume) and the blob
example are removed.
- `doc/bridge_packages/flame_forge2d/forge2d.md` and `joints.md` are
rewritten for the new API
(including the new filter and wheel joints).
## Verification
- `flutter test` in `packages/flame_forge2d` (45 tests, compiles native
Box2D through build hooks),
goldens visually inspected.
- `dart analyze` clean across the whole workspace.
- `flutter build web` of the examples app confirms the Box2D wasm module
is bundled automatically
at the package asset path.
## Notes for the forge2d review (found during this migration)
- `Shape.geometry` read-back was added upstream during this work and is
what makes
`BodyComponent` rendering possible without a Dart-side geometry
registry.
- There is no upstream way to enumerate a world's bodies, hence the
Dart-side set in
`Forge2DWorld`.
- Behavior change to be aware of: destroying a body clears its userData
registries, so removed
`BodyComponent`s no longer receive a final `endContact` for contacts
that end due to the
destruction (the old engine fired those synchronously inside destroy).
Sign up for freeto 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

@spydon@erickzanardo