feat: Let the length unit be set through initializeForge2D - #120
Merged
Conversation
Box2D has a handful of tolerances that are absolute lengths rather than fractions of the shapes they apply to, most visibly the speculative distance of 0.02 m at which contacts start being reported. A world laid out at a much smaller scale than a meter is dominated by them. b2SetLengthUnitsPerMeter scales all of them, but it has to be called before Box2D is touched at all, so it is exposed through the initializeForge2D gate that already has to run first rather than as a free-standing setter. Conflicting values throw instead of silently corrupting the simulation; repeating the value in effect is a no-op, so several games that agree on a scale can each ask for it. Tolerances exposes the derived constants so that the 0.02 is discoverable rather than mysterious. The web backend needs a keepalive wrapper, since b2SetLengthUnitsPerMeter is a plain B2_API function that emcc would otherwise drop. The suites that change the length unit are tagged out of the normal run, because dart test shares one process, and therefore one copy of the native library, between suites.
emcc output is only reproducible per host platform, so a rebuild on macOS or Windows does not match the Linux build byte for byte even on the pinned emsdk. Uploading first makes the Linux build downloadable exactly when the check fails and you need it to commit.
The build-wasm check compares against a Linux build, and emcc output is only reproducible per host platform, so the macOS rebuild differed from it despite being the same size and functionally identical.
They were tagged out into their own job so that they could not run alongside suites that would see the length unit they change. Serializing the suites achieves the same thing, and the whole suite takes about two seconds either way, so the tag, the preset, the melos script and the extra job were not buying anything.
spydon
added a commit
to flame-engine/flame
that referenced
this pull request
Aug 11, 2026
flame_forge2d now needs initializeForge2D(lengthUnitsPerMeter:) and Tolerances, which are in flame-engine/forge2d#120 and not in the published 0.15.0. The constraint is raised to the ^0.16.0 that melos will cut from that PR, with a git override so that the workspace resolves in the meantime. Remove the override once 0.16.0 is out.
6 tasks
spydon
added a commit
to flame-engine/flame
that referenced
this pull request
Aug 12, 2026
The length unit support in flame-engine/forge2d#120 was released as 0.15.1, so the git dependency override is no longer needed.
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Box2D has a handful of tolerances that are absolute lengths rather than fractions of the shapes
they apply to. The most visible one is the speculative distance:
manifold.cstops generatingcontact points past
B2_SPECULATIVE_DISTANCE(4 * B2_LINEAR_SLOP, so 0.02 m), andcontact.csetstouching = pointCount > 0, which meansbeginContactfires while there isstill a gap of up to 2 cm. A world laid out at a much smaller scale than a meter is dominated by
this: shapes that are only a couple of centimeters across are permanently in contact with their
neighbors.
This came up while migrating flame_forge2d (flame-engine/flame#3952), where a reporter's ball had
a radius of exactly 0.02, and it took a week to track down because the 0.02 is not discoverable
from Dart.
Box2D's answer is
b2SetLengthUnitsPerMeter, which scales all of them. Its contract is@warning This must be modified before any calls to Box2D, which a free-standing setter cannotenforce, so it is exposed through the
initializeForge2Dgate that already has to run first:ask for it. The comparison round-trips through float32, since that is how Box2D stores it and
values like
0.04are not representable in either float width.StateErroronce aWorldexists,rather than silently corrupting live simulations and the defaults Box2D hands out.
ArgumentError.Tolerancesexposes the derived constants (lengthUnitsPerMeter,linearSlop,speculativeDistance,aabbMargin), so the 0.02 becomes a documented number that callers canreason about and assert against instead of a mystery.
The web backend needs a keepalive wrapper:
b2SetLengthUnitsPerMeterandb2GetLengthUnitsPerMeterare plainB2_APIfunctions, so emcc drops them without one.The README gains a "Units" section covering the scale to lay a world out at, the absolute
tolerances that bite when you do not, and how the other quantities scale when you rescale a world
(lengths, velocities and gravity by
S, masses byS², forces and impulses byS³, torques byS⁴, with densities, friction, restitution and damping unchanged, which leaves the timing of thesimulation unchanged).
Testing
dart testruns suites as isolates that share one process, and therefore one copy of the nativelibrary, so a suite that changes the length unit would be visible to whichever suites run
alongside it.
dart_test.yamltherefore setsconcurrency: 1, so suites run one at a time aspart of the normal test run, and the mutating suite puts the length unit back when it is done.
The whole suite takes a couple of seconds either way.
melos testpasses andmelos analyzeis clean.Checklist
fix:,feat:,docs:etc).docsand added dartdoc comments with///.examples.Breaking Change
Everything is additive: the new parameter is optional and defaults to leaving the length unit
alone, and
Tolerancesis a new class.Related Issues
Needed by flame-engine/flame#3952, which uses it for worlds that cannot be laid out at a
realistic scale, and reports the underlying problem as a debug-mode warning.