Skip to content

Commit 5696a7b

Browse files
authored
feat: Let the length unit be set through initializeForge2D (#120)
# 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.c` stops generating contact points past `B2_SPECULATIVE_DISTANCE` (`4 * B2_LINEAR_SLOP`, so 0.02 m), and `contact.c` sets `touching = pointCount > 0`, which means `beginContact` fires while there is still 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 cannot enforce, so it is exposed through the `initializeForge2D` gate that already has to run first: ```dart await initializeForge2D(lengthUnitsPerMeter: 100); ``` - Passing the value already in effect is a no-op, so several games that agree on a scale can each ask for it. The comparison round-trips through float32, since that is how Box2D stores it and values like `0.04` are not representable in either float width. - A value that conflicts with the one in effect throws a `StateError` once a `World` exists, rather than silently corrupting live simulations and the defaults Box2D hands out. - Non-positive and non-finite values throw an `ArgumentError`. `Tolerances` exposes the derived constants (`lengthUnitsPerMeter`, `linearSlop`, `speculativeDistance`, `aabbMargin`), so the 0.02 becomes a documented number that callers can reason about and assert against instead of a mystery. The web backend needs a keepalive wrapper: `b2SetLengthUnitsPerMeter` and `b2GetLengthUnitsPerMeter` are plain `B2_API` functions, 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 by `S²`, forces and impulses by `S³`, torques by `S⁴`, with densities, friction, restitution and damping unchanged, which leaves the timing of the simulation unchanged). ## Testing `dart test` runs suites as isolates that share one process, and therefore one copy of the native library, so a suite that changes the length unit would be visible to whichever suites run alongside it. `dart_test.yaml` therefore sets `concurrency: 1`, so suites run one at a time as part 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 test` passes and `melos analyze` is clean. ## Checklist - [x] The title of my PR starts with a [Conventional Commit] prefix (`fix:`, `feat:`, `docs:` etc). - [x] I have read the [Contributor Guide] and followed the process outlined for submitting PRs. - [x] I have updated/added tests for ALL new/updated/fixed functionality. - [x] I have updated/added relevant documentation in `docs` and added dartdoc comments with `///`. - [-] I have updated/added relevant examples in `examples`. ## Breaking Change - [ ] Yes, this is a breaking change. - [x] No, this is *not* a breaking change. Everything is additive: the new parameter is optional and defaults to leaving the length unit alone, and `Tolerances` is 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. <!-- Links --> [issue database]: https://github.com/flame-engine/flame/issues [Contributor Guide]: https://github.com/flame-engine/flame/blob/main/CONTRIBUTING.md [Flame Style Guide]: https://github.com/flame-engine/flame/blob/main/STYLEGUIDE.md [Conventional Commit]: https://conventionalcommits.org
1 parent 49dd404 commit 5696a7b

15 files changed

Lines changed: 355 additions & 9 deletions

File tree

.github/workflows/build-wasm.yml

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,21 @@ jobs:
2121
version: 4.0.15
2222
- name: Rebuild box2d.wasm
2323
run: packages/forge2d/tool/build_wasm.sh
24+
# Uploaded before the check so that the fresh build is downloadable
25+
# exactly when the check fails and you need it.
26+
- uses: actions/upload-artifact@v4
27+
with:
28+
name: box2d-wasm
29+
path: packages/forge2d/lib/src/backend/wasm/box2d.wasm
2430
- name: Verify the committed artifact is up to date
2531
run: |
2632
if ! git diff --exit-code --stat -- packages/forge2d/lib/src/backend/wasm/box2d.wasm; then
2733
echo "The committed box2d.wasm differs from a fresh build."
28-
echo "Rebuild it with tool/build_wasm.sh using emsdk 4.0.15."
34+
echo
35+
echo "emcc output is only reproducible on the same host platform,"
36+
echo "and this check builds on Linux, so a rebuild on macOS or"
37+
echo "Windows will not match byte for byte even with emsdk 4.0.15."
38+
echo "Download the box2d-wasm artifact from this run and commit it"
39+
echo "instead of the locally built one."
2940
exit 1
3041
fi
31-
- uses: actions/upload-artifact@v4
32-
with:
33-
name: box2d-wasm
34-
path: packages/forge2d/lib/src/backend/wasm/box2d.wasm

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,51 @@ Highlights of the API:
8181
- `DebugDraw` can be implemented to render the physics world for
8282
debugging.
8383

84+
## Units
85+
86+
Box2D is tuned for meters, kilograms and seconds, so lay your world out in
87+
meters and aim to keep moving objects roughly between 0.1 and 10 of them,
88+
with 1 being the sweet spot. Rendering scale is a separate concern: decide
89+
how many pixels a meter is worth in your renderer, not in the simulation.
90+
91+
Some of the tolerances are absolute lengths rather than fractions of the
92+
shapes they apply to, so a world laid out at a much smaller scale behaves
93+
oddly. The most visible one is the speculative distance: Box2D creates
94+
contact points for shapes that are approaching but not yet touching, which
95+
is what stops fast objects from passing through each other, and it means
96+
`beginContact` fires while there is still a gap of up to `0.02` meters. A
97+
shape that is only a couple of centimeters across is therefore permanently
98+
in contact with its neighbors. `Tolerances` exposes these values:
99+
100+
```dart
101+
Tolerances.linearSlop; // 0.005
102+
Tolerances.speculativeDistance; // 0.02
103+
Tolerances.aabbMargin; // 0.05
104+
```
105+
106+
`WorldDef.restitutionThreshold` (1 m/s), `WorldDef.hitEventThreshold`
107+
(1 m/s), `WorldDef.maxContactPushSpeed` (3 m/s),
108+
`WorldDef.maximumLinearSpeed` (400 m/s) and `BodyDef.sleepThreshold`
109+
(0.05 m/s) are absolute in the same way, but they are per world or per body,
110+
so they can simply be set.
111+
112+
When a world genuinely cannot be laid out at that scale, tell Box2D how many
113+
of your length units make up a meter and every tolerance above moves with it:
114+
115+
```dart
116+
await initializeForge2D(lengthUnitsPerMeter: 100);
117+
```
118+
119+
A good rule of thumb is to pass the height of your player character. You are
120+
then on the hook for gravity, densities and forces being sensible at that
121+
scale. For a length scale factor of `S`, velocities and accelerations scale
122+
by `S`, masses by ``, forces and impulses by `` and torques by `S⁴`,
123+
while densities, friction, restitution and damping stay as they are. Scaling
124+
lengths and gravity together leaves the timing of the simulation unchanged.
125+
126+
The length unit is process-wide and cannot change once a `World` exists,
127+
which is why it is set through `initializeForge2D`.
128+
84129
## Performance
85130

86131
The standard [bench2d](https://github.com/joelgwebber/bench2d) benchmark

packages/forge2d/dart_test.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,11 @@
33
# dart test -p chrome (dart2js)
44
# dart test -p chrome -c dart2wasm
55
platforms: [vm]
6+
7+
# The Box2D length unit is a process-wide global, and `dart test` runs suites
8+
# as isolates that share one process and therefore one copy of the library, so
9+
# a suite that changes it would be visible to any suite running alongside it.
10+
# Suites run one at a time instead, and the ones that change the length unit
11+
# put it back when they are done. The whole suite takes a couple of seconds
12+
# either way, so there is nothing to win by relaxing this.
13+
concurrency: 1

packages/forge2d/lib/forge2d.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,6 @@ export 'src/api/joints/weld_joint.dart';
2121
export 'src/api/joints/wheel_joint.dart';
2222
export 'src/api/math.dart';
2323
export 'src/api/shape.dart';
24+
export 'src/api/tolerances.dart';
2425
export 'src/api/world.dart';
25-
export 'src/initialize.dart' show initializeForge2D;
26+
export 'src/initialize.dart' show debugResetLengthUnitLock, initializeForge2D;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import 'package:forge2d/src/initialize.dart';
2+
3+
/// The Box2D tolerances that scale with the length unit.
4+
///
5+
/// Box2D is tuned for meters, kilograms and seconds, and a handful of its
6+
/// tolerances are absolute lengths rather than fractions of the objects they
7+
/// apply to. If a world is laid out at a much smaller scale than a meter,
8+
/// those tolerances stop being "visually insignificant" and start dominating
9+
/// the simulation: shapes report contacts before they touch, and the
10+
/// broadphase margin grows larger than the shapes themselves.
11+
///
12+
/// The usual fix is to lay the world out so that moving objects are roughly
13+
/// 0.1 to 10 meters, with 1 meter being the sweet spot. When that is not
14+
/// possible, tell Box2D what a meter means in your units with
15+
/// `initializeForge2D(lengthUnitsPerMeter: ...)`, and every value here moves
16+
/// with it.
17+
///
18+
/// These mirror the constants in `src/constants.h` of Box2D v3.1.1. There are
19+
/// further absolute thresholds that the length unit scales but that are
20+
/// per-world or per-body rather than global, so they are fields on the
21+
/// definitions instead: `WorldDef.restitutionThreshold`,
22+
/// `WorldDef.hitEventThreshold`, `WorldDef.maxContactPushSpeed`,
23+
/// `WorldDef.maximumLinearSpeed` and `BodyDef.sleepThreshold`.
24+
abstract final class Tolerances {
25+
/// How many length units make up one meter, mirroring
26+
/// `b2GetLengthUnitsPerMeter`.
27+
///
28+
/// Defaults to 1, meaning that forge2d lengths are meters. Set it through
29+
/// `initializeForge2D(lengthUnitsPerMeter: ...)`.
30+
static double get lengthUnitsPerMeter => rawBox2D.getLengthUnitsPerMeter();
31+
32+
/// The collision and constraint tolerance, `0.005` of a meter.
33+
///
34+
/// Shapes are allowed to overlap by this much so that contacts stay stable.
35+
static double get linearSlop => 0.005 * lengthUnitsPerMeter;
36+
37+
/// The separation at which shapes start reporting contacts, `0.02` of a
38+
/// meter, or four times the [linearSlop].
39+
///
40+
/// Box2D creates contact points for shapes that are approaching but not yet
41+
/// touching, which is what keeps fast objects from passing through each
42+
/// other and removes most collision jitter. It also means that
43+
/// `beginContact` fires while there is still a visible gap, so shapes that
44+
/// are not comfortably larger than this behave as if they were permanently
45+
/// in contact.
46+
static double get speculativeDistance => 4 * linearSlop;
47+
48+
/// How much the broadphase fattens shape bounds, `0.05` of a meter.
49+
///
50+
/// Lets a shape move a little without the dynamic tree having to be
51+
/// rebuilt.
52+
static double get aabbMargin => 0.05 * lengthUnitsPerMeter;
53+
}

packages/forge2d/lib/src/api/world.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ class World {
4646
maximumLinearSpeed: definition.maximumLinearSpeed,
4747
enableSleep: definition.enableSleep,
4848
enableContinuous: definition.enableContinuous,
49-
);
49+
) {
50+
// Freezes the length unit: it is baked into this world's tolerances.
51+
markWorldCreated();
52+
}
5053

5154
/// The packed native world id.
5255
@internal

packages/forge2d/lib/src/backend/raw_box2d.dart

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,17 @@
88
/// The contract is deliberately restricted so that every implementation can
99
/// provide it cheaply. See README.md in this directory before changing it.
1010
abstract interface class RawBox2D {
11+
// Global tuning.
12+
13+
/// Sets the process-wide length unit, mirroring `b2SetLengthUnitsPerMeter`.
14+
///
15+
/// Box2D must not have been called before this, which the API layer
16+
/// enforces in `initializeForge2D`.
17+
void setLengthUnitsPerMeter(double lengthUnits);
18+
19+
/// The process-wide length unit, mirroring `b2GetLengthUnitsPerMeter`.
20+
double getLengthUnitsPerMeter();
21+
1122
// World lifecycle.
1223

1324
/// Creates a world and returns its packed id.

packages/forge2d/lib/src/backend/raw_box2d_ffi.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ final class RawBox2DFfi implements RawBox2D {
6161
..c = cos
6262
..s = sin;
6363

64+
// Global tuning.
65+
66+
@override
67+
void setLengthUnitsPerMeter(double lengthUnits) =>
68+
b2.b2SetLengthUnitsPerMeter(lengthUnits);
69+
70+
@override
71+
double getLengthUnitsPerMeter() => b2.b2GetLengthUnitsPerMeter();
72+
6473
// World.
6574

6675
@override

packages/forge2d/lib/src/backend/raw_box2d_wasm.dart

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,16 @@ final class RawBox2DWasm implements RawBox2D {
110110
_unsigned32(_runtime.readI32(_out + 4)),
111111
);
112112

113+
// Global tuning.
114+
115+
@override
116+
void setLengthUnitsPerMeter(double lengthUnits) =>
117+
_call('f2d_set_length_units_per_meter', [lengthUnits]);
118+
119+
@override
120+
double getLengthUnitsPerMeter() =>
121+
_callF('f2d_get_length_units_per_meter', const []);
122+
113123
// World.
114124

115125
@override
98 Bytes
Binary file not shown.

0 commit comments

Comments
 (0)