Skip to content

feat!: Replace the pure-Dart engine with native Box2D v3 bindings - #115

Merged
spydon merged 11 commits into
mainfrom
box2d-bindings
Jul 20, 2026
Merged

feat!: Replace the pure-Dart engine with native Box2D v3 bindings#115
spydon merged 11 commits into
mainfrom
box2d-bindings

Conversation

@spydon

Copy link
Copy Markdown
Member

What

Rewrites forge2d from a pure-Dart port of Box2D 2.x into Dart bindings for the native Box2D v3.1.1 C library, using Dart FFI and native assets (build hooks).

One commit per stage:

  1. Native skeleton: vendored Box2D v3.1.1 snapshot (third_party/box2d, refreshable with tool/vendor_box2d.sh), hook/build.dart compiling the 35 C sources with native_toolchain_c (no CMake), ffigen-generated @Native bindings (committed; regenerate via melos ffigen), and an end-to-end smoke test. Workspace SDK bumped to ^3.12.0.
  2. CI: the test job now runs on Linux, macOS, and Windows (exercising gcc/clang, Xcode, and MSVC), plus an ffigen drift check.
  3. Great deletion + foundations: the old engine (21k lines), particle system, and mock-based tests are removed. A primitives-only RawBox2D backend seam (dart2js-safe, no 64-bit ints, ids as two int32s, polling instead of callbacks where possible) is selected by conditional export, so a WebAssembly backend can implement the same interface in a follow-up without public API changes. initializeForge2D() is async from day one for the same reason.
  4. Core API: World, Body, Shape, Chain as cheap value-like handles over native ids, def classes mirroring the native defaults, Dart-side user data registries, explicit destroy() semantics.
  5. Joints: the eight Box2D v3 joints (distance, filter, motor, mouse, prismatic, revolute, weld, wheel) with typed accessors and def classes.
  6. Events and queries: polled contact/sensor/body-move events, ray casts (closest/all/callback-controlled), AABB overlaps, explosions, and custom-filter/pre-solve callbacks via static isolate-local NativeCallables.
  7. Debug draw: DebugDraw abstract class bridged to b2DebugDraw, plus ported car and domino-tower console examples.
  8. Benchmark + docs: bench2d as a native CLI and a rewritten README with a migration guide.

Performance

bench2d (40-high pyramid, 256 frames), same machine:

Enginems/frame (mean)
forge2d 0.14 (pure Dart)9.37
this PR (native Box2D v3)0.51

Breaking changes

The entire 0.14 API is replaced (Fixture is gone, events are polled, 13 joints become 8, particles removed). Web support is temporarily unavailable and returns via a WASM backend behind the unchanged public API. See the README migration section.

Testing

  • melos test: 80+ tests including physics behavior tests per feature area, green locally on Linux; CI matrix covers macOS and Windows toolchains.
  • dart run bin/hello_world.dart / car.dart / domino_tower.dart in packages/forge2d/example exercise the build hook through dart run.
  • melos benchmark runs the native bench2d.

spydon added 11 commits July 19, 2026 21:03
…ndings
Vendors the Box2D v3.1.1 C sources under packages/forge2d/third_party/box2d
(refreshable with tool/vendor_box2d.sh), compiles them with a native assets
build hook (hook/build.dart, native_toolchain_c), and generates dart:ffi
@Native bindings with ffigen (standalone tool package in tool/ffigen since
ffigen and melos have conflicting cli_util constraints). A smoke test proves
the end-to-end pipeline: build hook, dynamic library, bindings, simulation.
Workspace SDK constraint is bumped to ^3.12.0.
The native assets build now runs in the test job, so the matrix covers the
three desktop toolchains (gcc/clang, Xcode clang, MSVC). A new ffigen-check
job regenerates the FFI bindings and fails if the committed file drifts from
the vendored headers.
Deletes the ported Box2D 2.x implementation (collision, dynamics, particle
system, callbacks, browser helpers) along with the old examples, benchmark
harness, and mock-based tests.
Lands the platform backend seam: a primitives-only RawBox2D interface with a
dart:ffi implementation and an UnsupportedError stub selected by conditional
export, plus initializeForge2D() (async, a no-op on native) so the same public
API can later be backed by a WebAssembly build on the web.
Adds the first slice of the new v3-shaped public API: Rot, Transform, Aabb,
BodyType/ShapeType/JointType, shape geometry classes, and def classes
mirroring the native defaults.
BREAKING CHANGE: the entire forge2d 0.14 API is replaced. The particle
system is removed. Web support is temporarily unavailable and returns in an
upcoming release via a WebAssembly backend.
Idiomatic wrappers over the native handles: worlds are created from
WorldDef/gravity, bodies and shapes from their defs with native default
values, and all wrappers are cheap value-like handles with equality over
their ids. User data lives in Dart-side registries keyed by id, so the
native userData pointers stay untouched. Explicit destroy() everywhere,
matching Box2D v3 semantics.
Includes a runnable console example (example/bin/hello_world.dart) and
tests covering body/shape/chain lifecycle, def round-trips, native
defaults, mass properties, forces and impulses.
Distance, filter, motor, mouse, prismatic, revolute, weld, and wheel
joints, each with a def class carrying the native defaults and typed
accessors on the wrapper. The Joint base class exposes the shared API
(bodies, anchors, collideConnected, constraint force/torque, user data,
destroy) and Body gains a joints getter with typed wrappers.
The old gear, pulley, rope, friction, and constant-volume joints have no
Box2D v3 counterpart and are gone for good.
Contact, sensor, and body-move events are polled from the world after each
step as Dart-side copies, matching Box2D v3's event-driven design. Ray
casts come in closest, collect-all, and callback-controlled variants, plus
AABB overlap queries and radial explosions. Custom filter and pre-solve
callbacks bridge into Dart through static isolate-local NativeCallables.
Also renames abbreviated identifiers across the API and backend seam:
definition instead of def, worldAndGeneration instead of wg.
DebugDraw is an abstract class with the b2DebugDraw toggles and one method
per primitive; World.draw bridges it through static isolate-local
NativeCallables. A recording implementation drives the tests.
The car and domino tower demos return as runnable console examples, and
ChainDef now documents Box2D v3's one-sided chain winding rule (solid side
to the right of the winding direction), which the old two-sided edge
shapes did not have.
…ation
bench2d keeps its classic configuration (40-high pyramid, 256 warm-up plus
256 measured frames) but now runs natively: 0.51 ms/frame against 9.37
ms/frame for the pure-Dart engine on the same machine, an 18x speedup.
The README describes the new bindings architecture, platform requirements,
and a migration guide from forge2d 0.14. Melos scripts drop the webdev
harnesses until the WebAssembly backend lands and gain an ffigen script.
MSVC only exports symbols marked dllexport, so the library built on
Windows contained no visible functions. Defining box2d_EXPORTS makes
B2_API expand to dllexport on MSVC and default visibility elsewhere,
matching Box2D's own shared library build.
ffigen formats its output differently depending on the running SDK, which
made the drift check fail on CI. Running dart format over the generated
file after generation (locally, in the melos ffigen script, and in the
check itself) keys the style to the package language version instead.
@spydon
spydon merged commit 3ea1528 into mainJul 20, 2026
6 checks passed
@spydon
spydon deleted the box2d-bindings branch July 20, 2026 19:11
spydon added a commit that referenced this pull request Jul 20, 2026
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.
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.
@drrnbrns

Copy link
Copy Markdown
Contributor

Yikes!! 🥲 Where is the migration guide? The change log for 0.15 has this comment and link:

Upgrading from 0.14 requires code changes throughout: see the Forge2D migration guide That redirects to https://docs.flame-engine.org/latest/ which doesn't contain any such guide. Maybe the URL will be there soon?

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.

3 participants

@spydon@drrnbrns@erickzanardo