diff --git a/.github/workflows/build-wasm.yml b/.github/workflows/build-wasm.yml index d6139c6a..2c47b017 100644 --- a/.github/workflows/build-wasm.yml +++ b/.github/workflows/build-wasm.yml @@ -21,14 +21,21 @@ jobs: version: 4.0.15 - name: Rebuild box2d.wasm run: packages/forge2d/tool/build_wasm.sh + # Uploaded before the check so that the fresh build is downloadable + # exactly when the check fails and you need it. + - uses: actions/upload-artifact@v4 + with: + name: box2d-wasm + path: packages/forge2d/lib/src/backend/wasm/box2d.wasm - name: Verify the committed artifact is up to date run: | if ! git diff --exit-code --stat -- packages/forge2d/lib/src/backend/wasm/box2d.wasm; then echo "The committed box2d.wasm differs from a fresh build." - echo "Rebuild it with tool/build_wasm.sh using emsdk 4.0.15." + echo + echo "emcc output is only reproducible on the same host platform," + echo "and this check builds on Linux, so a rebuild on macOS or" + echo "Windows will not match byte for byte even with emsdk 4.0.15." + echo "Download the box2d-wasm artifact from this run and commit it" + echo "instead of the locally built one." exit 1 fi - - uses: actions/upload-artifact@v4 - with: - name: box2d-wasm - path: packages/forge2d/lib/src/backend/wasm/box2d.wasm diff --git a/README.md b/README.md index 0ee85808..5d650b69 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,51 @@ Highlights of the API: - `DebugDraw` can be implemented to render the physics world for debugging. +## Units + +Box2D is tuned for meters, kilograms and seconds, so lay your world out in +meters and aim to keep moving objects roughly between 0.1 and 10 of them, +with 1 being the sweet spot. Rendering scale is a separate concern: decide +how many pixels a meter is worth in your renderer, not in the simulation. + +Some of the tolerances are absolute lengths rather than fractions of the +shapes they apply to, so a world laid out at a much smaller scale behaves +oddly. The most visible one is the speculative distance: Box2D creates +contact points for shapes that are approaching but not yet touching, which +is what stops fast objects from passing through each other, and it means +`beginContact` fires while there is still a gap of up to `0.02` meters. A +shape that is only a couple of centimeters across is therefore permanently +in contact with its neighbors. `Tolerances` exposes these values: + +```dart +Tolerances.linearSlop; // 0.005 +Tolerances.speculativeDistance; // 0.02 +Tolerances.aabbMargin; // 0.05 +``` + +`WorldDef.restitutionThreshold` (1 m/s), `WorldDef.hitEventThreshold` +(1 m/s), `WorldDef.maxContactPushSpeed` (3 m/s), +`WorldDef.maximumLinearSpeed` (400 m/s) and `BodyDef.sleepThreshold` +(0.05 m/s) are absolute in the same way, but they are per world or per body, +so they can simply be set. + +When a world genuinely cannot be laid out at that scale, tell Box2D how many +of your length units make up a meter and every tolerance above moves with it: + +```dart +await initializeForge2D(lengthUnitsPerMeter: 100); +``` + +A good rule of thumb is to pass the height of your player character. You are +then on the hook for gravity, densities and forces being sensible at that +scale. For a length scale factor of `S`, velocities and accelerations scale +by `S`, masses by `S²`, forces and impulses by `S³` and torques by `S⁴`, +while densities, friction, restitution and damping stay as they are. Scaling +lengths and gravity together leaves the timing of the simulation unchanged. + +The length unit is process-wide and cannot change once a `World` exists, +which is why it is set through `initializeForge2D`. + ## Performance The standard [bench2d](https://github.com/joelgwebber/bench2d) benchmark diff --git a/packages/forge2d/dart_test.yaml b/packages/forge2d/dart_test.yaml index f4134dec..c51a6ac3 100644 --- a/packages/forge2d/dart_test.yaml +++ b/packages/forge2d/dart_test.yaml @@ -3,3 +3,11 @@ # dart test -p chrome (dart2js) # dart test -p chrome -c dart2wasm platforms: [vm] + +# The Box2D length unit is a process-wide global, and `dart test` runs suites +# as isolates that share one process and therefore one copy of the library, so +# a suite that changes it would be visible to any suite running alongside it. +# Suites run one at a time instead, and the ones that change the length unit +# put it back when they are done. The whole suite takes a couple of seconds +# either way, so there is nothing to win by relaxing this. +concurrency: 1 diff --git a/packages/forge2d/lib/forge2d.dart b/packages/forge2d/lib/forge2d.dart index f9033e9b..d1061c9e 100644 --- a/packages/forge2d/lib/forge2d.dart +++ b/packages/forge2d/lib/forge2d.dart @@ -21,5 +21,6 @@ export 'src/api/joints/weld_joint.dart'; export 'src/api/joints/wheel_joint.dart'; export 'src/api/math.dart'; export 'src/api/shape.dart'; +export 'src/api/tolerances.dart'; export 'src/api/world.dart'; -export 'src/initialize.dart' show initializeForge2D; +export 'src/initialize.dart' show debugResetLengthUnitLock, initializeForge2D; diff --git a/packages/forge2d/lib/src/api/tolerances.dart b/packages/forge2d/lib/src/api/tolerances.dart new file mode 100644 index 00000000..f02aecec --- /dev/null +++ b/packages/forge2d/lib/src/api/tolerances.dart @@ -0,0 +1,53 @@ +import 'package:forge2d/src/initialize.dart'; + +/// The Box2D tolerances that scale with the length unit. +/// +/// Box2D is tuned for meters, kilograms and seconds, and a handful of its +/// tolerances are absolute lengths rather than fractions of the objects they +/// apply to. If a world is laid out at a much smaller scale than a meter, +/// those tolerances stop being "visually insignificant" and start dominating +/// the simulation: shapes report contacts before they touch, and the +/// broadphase margin grows larger than the shapes themselves. +/// +/// The usual fix is to lay the world out so that moving objects are roughly +/// 0.1 to 10 meters, with 1 meter being the sweet spot. When that is not +/// possible, tell Box2D what a meter means in your units with +/// `initializeForge2D(lengthUnitsPerMeter: ...)`, and every value here moves +/// with it. +/// +/// These mirror the constants in `src/constants.h` of Box2D v3.1.1. There are +/// further absolute thresholds that the length unit scales but that are +/// per-world or per-body rather than global, so they are fields on the +/// definitions instead: `WorldDef.restitutionThreshold`, +/// `WorldDef.hitEventThreshold`, `WorldDef.maxContactPushSpeed`, +/// `WorldDef.maximumLinearSpeed` and `BodyDef.sleepThreshold`. +abstract final class Tolerances { + /// How many length units make up one meter, mirroring + /// `b2GetLengthUnitsPerMeter`. + /// + /// Defaults to 1, meaning that forge2d lengths are meters. Set it through + /// `initializeForge2D(lengthUnitsPerMeter: ...)`. + static double get lengthUnitsPerMeter => rawBox2D.getLengthUnitsPerMeter(); + + /// The collision and constraint tolerance, `0.005` of a meter. + /// + /// Shapes are allowed to overlap by this much so that contacts stay stable. + static double get linearSlop => 0.005 * lengthUnitsPerMeter; + + /// The separation at which shapes start reporting contacts, `0.02` of a + /// meter, or four times the [linearSlop]. + /// + /// Box2D creates contact points for shapes that are approaching but not yet + /// touching, which is what keeps fast objects from passing through each + /// other and removes most collision jitter. It also means that + /// `beginContact` fires while there is still a visible gap, so shapes that + /// are not comfortably larger than this behave as if they were permanently + /// in contact. + static double get speculativeDistance => 4 * linearSlop; + + /// How much the broadphase fattens shape bounds, `0.05` of a meter. + /// + /// Lets a shape move a little without the dynamic tree having to be + /// rebuilt. + static double get aabbMargin => 0.05 * lengthUnitsPerMeter; +} diff --git a/packages/forge2d/lib/src/api/world.dart b/packages/forge2d/lib/src/api/world.dart index 998f8e58..8594b296 100644 --- a/packages/forge2d/lib/src/api/world.dart +++ b/packages/forge2d/lib/src/api/world.dart @@ -46,7 +46,10 @@ class World { maximumLinearSpeed: definition.maximumLinearSpeed, enableSleep: definition.enableSleep, enableContinuous: definition.enableContinuous, - ); + ) { + // Freezes the length unit: it is baked into this world's tolerances. + markWorldCreated(); + } /// The packed native world id. @internal diff --git a/packages/forge2d/lib/src/backend/raw_box2d.dart b/packages/forge2d/lib/src/backend/raw_box2d.dart index 2798f31d..0f94f659 100644 --- a/packages/forge2d/lib/src/backend/raw_box2d.dart +++ b/packages/forge2d/lib/src/backend/raw_box2d.dart @@ -8,6 +8,17 @@ /// The contract is deliberately restricted so that every implementation can /// provide it cheaply. See README.md in this directory before changing it. abstract interface class RawBox2D { + // Global tuning. + + /// Sets the process-wide length unit, mirroring `b2SetLengthUnitsPerMeter`. + /// + /// Box2D must not have been called before this, which the API layer + /// enforces in `initializeForge2D`. + void setLengthUnitsPerMeter(double lengthUnits); + + /// The process-wide length unit, mirroring `b2GetLengthUnitsPerMeter`. + double getLengthUnitsPerMeter(); + // World lifecycle. /// Creates a world and returns its packed id. diff --git a/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart b/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart index f10c6838..daae1da3 100644 --- a/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart +++ b/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart @@ -61,6 +61,15 @@ final class RawBox2DFfi implements RawBox2D { ..c = cos ..s = sin; + // Global tuning. + + @override + void setLengthUnitsPerMeter(double lengthUnits) => + b2.b2SetLengthUnitsPerMeter(lengthUnits); + + @override + double getLengthUnitsPerMeter() => b2.b2GetLengthUnitsPerMeter(); + // World. @override diff --git a/packages/forge2d/lib/src/backend/raw_box2d_wasm.dart b/packages/forge2d/lib/src/backend/raw_box2d_wasm.dart index 56d2e790..134ec2f0 100644 --- a/packages/forge2d/lib/src/backend/raw_box2d_wasm.dart +++ b/packages/forge2d/lib/src/backend/raw_box2d_wasm.dart @@ -110,6 +110,16 @@ final class RawBox2DWasm implements RawBox2D { _unsigned32(_runtime.readI32(_out + 4)), ); + // Global tuning. + + @override + void setLengthUnitsPerMeter(double lengthUnits) => + _call('f2d_set_length_units_per_meter', [lengthUnits]); + + @override + double getLengthUnitsPerMeter() => + _callF('f2d_get_length_units_per_meter', const []); + // World. @override diff --git a/packages/forge2d/lib/src/backend/wasm/box2d.wasm b/packages/forge2d/lib/src/backend/wasm/box2d.wasm index 037e3e46..e2d06b12 100755 Binary files a/packages/forge2d/lib/src/backend/wasm/box2d.wasm and b/packages/forge2d/lib/src/backend/wasm/box2d.wasm differ diff --git a/packages/forge2d/lib/src/initialize.dart b/packages/forge2d/lib/src/initialize.dart index 19e1a6a6..c3898757 100644 --- a/packages/forge2d/lib/src/initialize.dart +++ b/packages/forge2d/lib/src/initialize.dart @@ -1,4 +1,7 @@ +import 'dart:typed_data'; + import 'package:forge2d/src/backend/backend.dart'; +import 'package:meta/meta.dart'; RawBox2D? _rawBox2D; @@ -7,6 +10,17 @@ RawBox2D? _rawBox2D; /// Internal to forge2d; not exported by the package. RawBox2D get rawBox2D => _rawBox2D ??= createRawBox2D(); +bool _worldHasBeenCreated = false; + +/// Records that a world exists, which freezes the length unit. +/// +/// Internal to forge2d; not exported by the package. +void markWorldCreated() => _worldHasBeenCreated = true; + +/// Rounds [value] the way Box2D stores it, so that a length unit that was +/// handed to the native side compares equal when it is read back. +double _toFloat32(double value) => (Float32List(1)..[0] = value)[0]; + /// Initializes forge2d. /// /// On native platforms this completes immediately. On the web it fetches @@ -18,8 +32,62 @@ RawBox2D get rawBox2D => _rawBox2D ??= createRawBox2D(); /// apps, and finally at `box2d.wasm` relative to the page. [wasmUri] /// overrides the lookup for custom hosting setups. /// +/// [lengthUnitsPerMeter] tells Box2D how many of your length units make up +/// one meter, so that its internal tolerances line up with the scale your +/// world is laid out at. See `Tolerances` for what it scales. Prefer laying +/// the world out so that moving objects are roughly 0.1 to 10 meters over +/// reaching for this; it is the escape hatch for worlds that cannot be +/// scaled. Leaving it null keeps whatever value is in effect, which is 1 +/// unless something else has set it. +/// +/// The length unit is process-wide and cannot change once a `World` exists, +/// which is why it lives here rather than in a free-standing setter: this +/// call is the gate that already has to run before Box2D is touched. Passing +/// a value that conflicts with one that is already in effect throws a +/// [StateError] instead of silently corrupting the simulation. Passing the +/// value that is already in effect is always a no-op, so several games that +/// agree on a scale can each ask for it. +/// /// Cross-platform code should always call and await this first. -Future initializeForge2D({Uri? wasmUri}) async { +Future initializeForge2D({ + Uri? wasmUri, + double? lengthUnitsPerMeter, +}) async { await initializeBackend(wasmUri: wasmUri); - _rawBox2D ??= createRawBox2D(); + final backend = _rawBox2D ??= createRawBox2D(); + if (lengthUnitsPerMeter == null) { + return; + } + if (lengthUnitsPerMeter <= 0 || !lengthUnitsPerMeter.isFinite) { + throw ArgumentError.value( + lengthUnitsPerMeter, + 'lengthUnitsPerMeter', + 'must be positive and finite', + ); + } + final current = backend.getLengthUnitsPerMeter(); + if (current == _toFloat32(lengthUnitsPerMeter)) { + return; + } + if (_worldHasBeenCreated) { + throw StateError( + 'The length unit cannot be changed from $current to ' + '$lengthUnitsPerMeter because a World has already been created. Box2D ' + 'bakes the length unit into the defaults of the definitions it hands ' + 'out and into the tolerances of live simulations, so it has to be set ' + 'before the first world exists. Await ' + 'initializeForge2D(lengthUnitsPerMeter: ...) at startup, before any ' + 'world is created.', + ); + } + backend.setLengthUnitsPerMeter(lengthUnitsPerMeter); } + +/// Forgets that a world has been created, so that a test can set a different +/// length unit than an earlier test did. +/// +/// This only clears forge2d's bookkeeping. The value inside Box2D and any +/// world that is still alive are left alone, so destroy the worlds of the +/// previous test first. +@visibleForTesting +void debugResetLengthUnitLock() => _worldHasBeenCreated = false; diff --git a/packages/forge2d/native/wasm/f2d_shim.c b/packages/forge2d/native/wasm/f2d_shim.c index 46c4be1b..30c5ee80 100644 --- a/packages/forge2d/native/wasm/f2d_shim.c +++ b/packages/forge2d/native/wasm/f2d_shim.c @@ -118,6 +118,19 @@ static b2QueryFilter f2d_query_filter(uint32_t category_lo, return filter; } +// Global tuning. +// +// b2SetLengthUnitsPerMeter and b2GetLengthUnitsPerMeter are plain B2_API +// functions, so emcc drops them without a keepalive wrapper. + +F2D_EXPORT void f2d_set_length_units_per_meter(float length_units) { + b2SetLengthUnitsPerMeter(length_units); +} + +F2D_EXPORT float f2d_get_length_units_per_meter(void) { + return b2GetLengthUnitsPerMeter(); +} + // World. F2D_EXPORT uint32_t f2d_create_world( diff --git a/packages/forge2d/test/api/length_unit_test.dart b/packages/forge2d/test/api/length_unit_test.dart new file mode 100644 index 00000000..03e6bbbf --- /dev/null +++ b/packages/forge2d/test/api/length_unit_test.dart @@ -0,0 +1,85 @@ +import 'package:forge2d/forge2d.dart'; +import 'package:test/test.dart'; + +// The length unit is a global inside Box2D that every suite in the process +// shares, so suites run one at a time; see dart_test.yaml. Within this file +// the tests run in declaration order and build on each other. +void main() { + setUpAll(initializeForge2D); + + // Puts the length unit back, so that the suites that run after this one see + // the default whatever order they end up in. + tearDownAll(() async { + debugResetLengthUnitLock(); + await initializeForge2D(lengthUnitsPerMeter: 1); + }); + + group('initializeForge2D(lengthUnitsPerMeter:)', () { + test('rejects values that are not positive and finite', () async { + for (final value in [0.0, -1.0, double.nan, double.infinity]) { + await expectLater( + initializeForge2D(lengthUnitsPerMeter: value), + throwsArgumentError, + reason: '$value should be rejected', + ); + } + expect(Tolerances.lengthUnitsPerMeter, 1); + }); + + test('scales the tolerances', () async { + await initializeForge2D(lengthUnitsPerMeter: 100); + + expect(Tolerances.lengthUnitsPerMeter, 100); + expect(Tolerances.linearSlop, closeTo(0.5, 1e-6)); + expect(Tolerances.speculativeDistance, closeTo(2, 1e-6)); + expect(Tolerances.aabbMargin, closeTo(5, 1e-6)); + }); + + test('accepts the value that is already in effect again', () async { + await initializeForge2D(lengthUnitsPerMeter: 100); + expect(Tolerances.lengthUnitsPerMeter, 100); + }); + + test('accepts a value that only round trips through float32', () async { + // 0.04 is not representable in either float32 or float64, so the + // repeat-request check has to compare the way Box2D stores it. + debugResetLengthUnitLock(); + await initializeForge2D(lengthUnitsPerMeter: 0.04); + await expectLater( + initializeForge2D(lengthUnitsPerMeter: 0.04), + completes, + ); + expect(Tolerances.lengthUnitsPerMeter, closeTo(0.04, 1e-9)); + + debugResetLengthUnitLock(); + await initializeForge2D(lengthUnitsPerMeter: 100); + }); + + test('throws when a world already exists and the value differs', () async { + final world = World(); + addTearDown(world.destroy); + + await expectLater( + initializeForge2D(lengthUnitsPerMeter: 50), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('a World has already been created'), + ), + ), + ); + expect(Tolerances.lengthUnitsPerMeter, 100); + }); + + test('still accepts the unchanged value once a world exists', () async { + final world = World(); + addTearDown(world.destroy); + + await expectLater( + initializeForge2D(lengthUnitsPerMeter: 100), + completes, + ); + }); + }); +} diff --git a/packages/forge2d/test/api/tolerances_test.dart b/packages/forge2d/test/api/tolerances_test.dart new file mode 100644 index 00000000..cbb83220 --- /dev/null +++ b/packages/forge2d/test/api/tolerances_test.dart @@ -0,0 +1,27 @@ +import 'package:forge2d/forge2d.dart'; +import 'package:test/test.dart'; + +// The length unit is process-wide, so this file only reads it. The tests that +// change it live in length_unit_test.dart, which puts it back afterwards. +void main() { + setUpAll(initializeForge2D); + + group('Tolerances', () { + test('defaults to meters', () { + expect(Tolerances.lengthUnitsPerMeter, 1); + }); + + test('has the Box2D defaults', () { + expect(Tolerances.linearSlop, closeTo(0.005, 1e-9)); + expect(Tolerances.speculativeDistance, closeTo(0.02, 1e-9)); + expect(Tolerances.aabbMargin, closeTo(0.05, 1e-9)); + }); + + test('reports the speculative distance as four times the slop', () { + expect( + Tolerances.speculativeDistance, + closeTo(4 * Tolerances.linearSlop, 1e-9), + ); + }); + }); +} diff --git a/packages/forge2d/tool/build_wasm.sh b/packages/forge2d/tool/build_wasm.sh index da5436cc..e4dac360 100755 --- a/packages/forge2d/tool/build_wasm.sh +++ b/packages/forge2d/tool/build_wasm.sh @@ -4,6 +4,12 @@ # Requires an activated emsdk (https://github.com/emscripten-core/emsdk); # CI pins the version, see .github/workflows/build-wasm.yml. # +# Use this to check your shim changes locally, but note that emcc output is +# only reproducible on the same host platform. The build-wasm workflow +# compares the committed artifact against a Linux build, so a rebuild on +# macOS or Windows will not match it byte for byte even on the pinned emsdk. +# Commit the box2d-wasm artifact from that workflow run instead. +# # Usage: tool/build_wasm.sh set -euo pipefail