Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions .github/workflows/build-wasm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/forge2d/dart_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion packages/forge2d/lib/forge2d.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
53 changes: 53 additions & 0 deletions packages/forge2d/lib/src/api/tolerances.dart
Original file line number Diff line number Diff line change
@@ -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;
}
5 changes: 4 additions & 1 deletion packages/forge2d/lib/src/api/world.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/forge2d/lib/src/backend/raw_box2d.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 9 additions & 0 deletions packages/forge2d/lib/src/backend/raw_box2d_ffi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions packages/forge2d/lib/src/backend/raw_box2d_wasm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified packages/forge2d/lib/src/backend/wasm/box2d.wasm
Binary file not shown.
72 changes: 70 additions & 2 deletions packages/forge2d/lib/src/initialize.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import 'dart:typed_data';

import 'package:forge2d/src/backend/backend.dart';
import 'package:meta/meta.dart';

RawBox2D? _rawBox2D;

Expand All @@ -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
Expand All @@ -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<void> initializeForge2D({Uri? wasmUri}) async {
Future<void> 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;
13 changes: 13 additions & 0 deletions packages/forge2d/native/wasm/f2d_shim.c
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading