Skip to content

Commit 9081a0f

Browse files
authored
feat!: Migrate flame_forge2d to the Box2D v3 based forge2d (#3952)
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).
1 parent 0453bad commit 9081a0f

78 files changed

Lines changed: 4337 additions & 2288 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/.cspell/words_dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ renderable
2424
rerasterize
2525
rescan
2626
Roboto
27+
subclassing
2728
tappable
2829
thumbstick
2930
trackpad

doc/bridge_packages/flame_forge2d/flame_forge2d.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33
```{toctree}
44
Overview <forge2d.md>
55
Joints <joints.md>
6+
Migration <migration.md>
67
```

doc/bridge_packages/flame_forge2d/forge2d.md

Lines changed: 185 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,42 @@
11
# Forge2D
22

3-
Blue Fire maintains a ported version of the Box2D physics engine and our
4-
version is called Forge2D.
3+
Blue Fire maintains Forge2D, Dart bindings for the [Box2D](https://box2d.org/) physics engine
4+
(native on mobile and desktop, WebAssembly on the web).
55

66
If you want to use Forge2D specifically for Flame you should use our bridge library
77
[flame_forge2d](https://github.com/flame-engine/flame/tree/main/packages/flame_forge2d) and if you
88
just want to use it in a Dart project you can use the
99
[forge2d](https://github.com/flame-engine/forge2d) library directly.
1010

11-
To use it in your game you just need to add `flame_forge2d` to your
12-
`pubspec.yaml`, as can be seen in the [Forge2D
13-
[example](https://github.com/flame-engine/flame/tree/main/packages/flame_forge2d/example)
14-
and the pub. dev [installation
15-
instructions](https://pub.dev/packages/flame_forge2d)](<https://pub.dev/packages/flame_forge2d>).
11+
To use it in your game you just need to add `flame_forge2d` to your `pubspec.yaml`, as can be
12+
seen in the
13+
[Forge2D example](https://github.com/flame-engine/flame/tree/main/packages/flame_forge2d/example)
14+
and the pub.dev [installation instructions](https://pub.dev/packages/flame_forge2d).
15+
16+
Since Forge2D runs Box2D as native code, a C toolchain is required when building for native
17+
platforms (Xcode on iOS/macOS, the NDK on Android, Visual Studio Build Tools on Windows and
18+
clang or gcc on Linux). On the web a bundled WebAssembly build of Box2D is used instead.
19+
20+
Forge2D has to be initialized with `await initializeForge2D()` before any physics world is
21+
created, which on the web is what loads that WebAssembly module. `Forge2DGame` awaits this in its
22+
`onLoad`, so games don't have to do anything, but that also means that a `Forge2DGame` subclass
23+
which overrides `onLoad` has to await `super.onLoad()` before it creates any bodies:
24+
25+
```dart
26+
class MyGame extends Forge2DGame {
27+
@override
28+
Future<void> onLoad() async {
29+
await super.onLoad(); // Not awaiting this breaks the game on the web.
30+
world.add(MyBody());
31+
}
32+
}
33+
```
34+
35+
If you create a `Forge2DWorld` or a raw Forge2D `World` outside of a `Forge2DGame`, await
36+
`initializeForge2D()` yourself first, or the world creation will throw on the web.
37+
38+
If you are upgrading an existing game from flame_forge2d 0.19, see the
39+
[migration guide](migration.md).
1640

1741

1842
## Forge2DGame
@@ -23,11 +47,18 @@ If you are going to use Forge2D in your project it can be a good idea to use the
2347
It is called `Forge2DGame` and supports both the special Forge2D components called `BodyComponents`
2448
as well as normal Flame components.
2549

26-
`Forge2DGame` has a built-in `CameraComponent` and has a zoom level set to 10 by default, so your
27-
components will be a lot bigger than in a normal Flame game. This is due to the speed limit in the
28-
`Forge2D` world, which you would hit very quickly if you are using it with `zoom = 1.0`. You can
29-
easily change the zoom level either by calling `super(zoom: yourZoom)` in your constructor or
30-
doing `game.cameraComponent.viewfinder.zoom = yourZoom;` at a later stage.
50+
`Forge2DGame` has a built-in `CameraComponent` that uses a `Forge2DViewfinder`. The physics world
51+
is measured in meters, and the viewfinder renders one meter as `metersToPixels` pixels, which is
52+
100 by default. Lay your world out in meters at a realistic scale and let `metersToPixels` decide
53+
how big that is on screen; see [](#units-and-scale) for why the two are kept apart.
54+
55+
You can change the scale either by calling `super(metersToPixels: yourScale)` in your constructor
56+
or by doing `game.metersToPixels = yourScale;` at a later stage.
57+
58+
The `zoom` of the viewfinder is applied on top of `metersToPixels` and defaults to 1, so it is free
59+
for what it is normally used for: zooming the camera in and out. Everything except the rendering
60+
stays in meters, so body positions, `camera.viewfinder.position`, `camera.visibleWorldRect` and the
61+
local positions that events report are all still expressed in meters.
3162

3263
If you are previously familiar with Box2D it can be good to know that the whole concept of the
3364
Box2d world is mapped to `world` in the `Forge2DGame` component and every `Body` that you want to
@@ -48,6 +79,94 @@ A simple `Forge2DGame` implementation example can be seen in the
4879
[examples folder](https://github.com/flame-engine/flame/tree/main/packages/flame_forge2d/example).
4980

5081

82+
## Units and scale
83+
84+
Forge2D is Box2D, and Box2D is tuned for meters, kilograms and seconds. Lay your world out in
85+
meters at a realistic scale, aiming to keep moving bodies roughly between 0.1 and 10 of them, with
86+
1 meter being the sweet spot. How large that is on screen is a separate decision, and it is what
87+
`metersToPixels` is for.
88+
89+
```{note}
90+
If you used flame_forge2d before the Box2D v3 migration, this is a
91+
change of advice. The old version had a hard `maxTranslation` of 2
92+
meters per step, so about 120 m/s, and the docs told you to lay the
93+
world out much smaller than a meter to stay under it. That limit is
94+
now `WorldDef.maximumLinearSpeed`, which defaults to 400 m/s and is
95+
settable per world through `Forge2DWorld(definition: WorldDef(...))`.
96+
When you pass a definition, also pass the `gravity` argument (or set
97+
`WorldDef.gravity` explicitly), because the definition's default is
98+
Box2D's y-up `(0, -10)` rather than Flame's y-down `(0, 10)`.
99+
There is no longer a reason to shrink the world, and there are good
100+
reasons not to.
101+
```
102+
103+
104+
### Why a shrunken world misbehaves
105+
106+
A handful of Box2D's tolerances are absolute lengths rather than fractions of the shapes they
107+
apply to, so in a world laid out at a much smaller scale than a meter they stop being negligible
108+
and start dominating:
109+
110+
| Tolerance | Default | What it does in a world only a meter across |
111+
| --- | --- | --- |
112+
| `Tolerances.speculativeDistance` | 0.02 m | contacts are reported across 2% of the world |
113+
| `WorldDef.restitutionThreshold` | 1 m/s | nothing ever bounces |
114+
| `WorldDef.hitEventThreshold` | 1 m/s | no hit events are ever generated |
115+
| `BodyDef.sleepThreshold` | 0.05 m/s | bodies fall asleep while still moving |
116+
| `WorldDef.maxContactPushSpeed` | 3 m/s | overlapping bodies are pushed apart violently |
117+
| `Tolerances.aabbMargin` | 0.05 m | broadphase bounds dwarf the shapes |
118+
119+
The first one is the one that gets reported as a bug. Box2D creates contact points for shapes that
120+
are approaching but have not touched yet, which is what stops fast bodies from passing through
121+
things and removes most collision jitter. It also means `beginContact` fires while there is still
122+
a visible gap of up to `Tolerances.speculativeDistance`. A body that is not comfortably larger
123+
than that is permanently in contact with its neighbors. flame_forge2d prints a debug-mode warning
124+
once when it notices a moving body that small.
125+
126+
127+
### Scaling a world up
128+
129+
If your world is currently too small, scale it up and scale gravity with it. That last part is the
130+
one that is easy to miss: scaling lengths alone makes everything look like it is moving in
131+
treacle, while scaling lengths and gravity by the same factor leaves the timing of the simulation
132+
completely unchanged. For a length scale factor of `S`:
133+
134+
| Quantity | Scale by |
135+
| --- | --- |
136+
| lengths, positions, radii, velocities, gravity, accelerations | `S` |
137+
| densities, friction, restitution, damping, angular velocities | `1`, unchanged |
138+
| masses | `` |
139+
| forces, linear impulses | `` |
140+
| torques, rotational inertia, angular impulses | `S⁴` |
141+
| **time** | **`1`, unchanged** |
142+
143+
So a world that was 1 meter tall with a 0.02 m ball and a gravity of 9.81 becomes a world 10
144+
meters tall with a 0.2 m ball and a gravity of 98.1, behaving identically but comfortably inside
145+
the range Box2D is tuned for. Divide `metersToPixels` by the same factor to keep it the same size
146+
on screen.
147+
148+
149+
### When the layout cannot change
150+
151+
When scaling the world is not practical, tell Box2D how many of your length units make up a meter
152+
and every tolerance in the first table above moves with it:
153+
154+
```dart
155+
class MyGame extends Forge2DGame {
156+
MyGame() : super(lengthUnitsPerMeter: 0.04);
157+
}
158+
```
159+
160+
A good rule of thumb is the height of your player character: if it is 0.04 units tall and you
161+
think of it as a person, pass 0.04. You are then responsible for gravity, densities and forces
162+
being sensible at that scale, using the same table.
163+
164+
This is a process-wide setting inside Box2D that cannot change once a physics world exists, so it
165+
can only be passed to the constructor, and several games running at the same time have to agree on
166+
it. A game that asks for a different value than one already in effect throws a `StateError` rather
167+
than quietly corrupting the simulation.
168+
169+
51170
## Forge2DWorld
52171

53172
The `Forge2DWorld` is a the world that all your [`BodyComponent`]s live in. In the `Forge2DGame`
@@ -59,19 +178,41 @@ to the `Forge2DGame` instance's `world` property, `game.world = Forge2DWorld()`.
59178

60179
If you would like to re-use a world later and have it keep its physics state you have to make sure
61180
that the bodies aren't destroyed when the world is removed from the game. You can do this by
62-
setting `world.destroyOnRemove` to false, like `game.world.destroyOnRemove = false;`.
181+
setting `world.destroyBodiesOnRemove` to false, like `game.world.destroyBodiesOnRemove = false;`.
182+
183+
The underlying Forge2D physics world is available as `world.physicsWorld`, which you can use to
184+
access the parts of the Forge2D API that `Forge2DWorld` doesn't wrap, like creating joints or
185+
polling the raw event streams.
63186

64187

65188
## BodyComponent
66189

67190
The `BodyComponent` is a wrapper for the `Forge2D` body, which is the body that the physics engine
68-
is interacting with. To create a `BodyComponent` you can either:
191+
is interacting with. A body carries one or more `Shape`s, which are created from a
192+
`ShapeGeometry` (`Circle`, `Capsule`, `Segment` or `Polygon`, plus chains via
193+
`body.createChain`) and an optional `ShapeDef` that holds the surface material (friction,
194+
restitution), density, filter, and event flags.
195+
196+
To create a `BodyComponent` you can either:
69197

70198
- override `createBody()` and create and return your created body;
71199
- use the default `createBody()` implementation by passing a `BodyDef` instance (and optionally a
72-
list of `FixtureDef` instances) to the BodyComponent's constructor;
200+
list of `ShapeSpec` instances, which pair a `ShapeGeometry` with an optional `ShapeDef`) to the
201+
BodyComponent's constructor;
73202
- use the default `createBody()` implementation and assign a `BodyDef` instance to `this.bodyDef`,
74-
and optionally a list of `FixtureDef` instances to `this.fixtureDefs`.
203+
and optionally a list of `ShapeSpec` instances to `this.shapeSpecs`.
204+
205+
```dart
206+
final ball = BodyComponent(
207+
bodyDef: BodyDef(type: BodyType.dynamic),
208+
shapeSpecs: [
209+
ShapeSpec(
210+
Circle(radius: 0.5),
211+
ShapeDef(material: SurfaceMaterial(restitution: 0.8)),
212+
),
213+
],
214+
);
215+
```
75216

76217
The `BodyComponent` is by default having `renderBody = true`, since otherwise, it wouldn't show
77218
anything after you have created a `Body` and added the `BodyComponent` to the game. If you want to
@@ -90,16 +231,18 @@ So instead of `add(Weapon()))`, `world.add(Weapon())` should be used (as below),
90231
should also of course initially be added to the world.
91232

92233
```dart
93-
class Weapon extends BodyComponent {
234+
class Weapon extends BodyComponent {
94235
@override
95-
void onLoad() {
96-
...
236+
Future<void> onLoad() async {
237+
await super.onLoad();
238+
// ...
97239
}
98240
}
99241
100-
class Player extends BodyComponent {
242+
class Player extends BodyComponent {
101243
@override
102-
void onLoad() {
244+
Future<void> onLoad() async {
245+
await super.onLoad();
103246
world.add(Weapon());
104247
}
105248
}
@@ -114,9 +257,9 @@ to avoid some tunneling problems.
114257

115258
`Forge2DGame` provides a simple out-of-the-box solution to propagate contact events.
116259

117-
Contact events occur whenever two `Fixture`s meet each other. These events allow listening when
118-
these `Fixture`s begin to come in contact (`beginContact`) and cease being in contact
119-
(`endContact`).
260+
Contact events occur whenever two `Shape`s meet each other. These events allow listening when
261+
these `Shape`s begin to come in contact (`beginContact`) and cease being in contact
262+
(`endContact`). Sensor overlaps are delivered through the same callbacks.
120263

121264
There are multiple ways to listen to these events. One common way is to use the `ContactCallbacks`
122265
class as a mixin in the `BodyComponent` where you are interested in these events.
@@ -133,13 +276,18 @@ class Ball extends BodyComponent with ContactCallbacks {
133276
}
134277
```
135278

136-
For the above to work, the `Ball`'s `body.userData` or contacting `fixture.userData` must be
137-
set to a `ContactCallback`. And if `Wall` is a `BodyComponent` it's `body.userData` or contacting
138-
`fixture.userData` must be set to `Wall`.
279+
For the above to work, the `Ball`'s `body.userData` or contacting `shape.userData` must be
280+
set to a `ContactCallbacks`. And if `Wall` is a `BodyComponent` its `body.userData` or contacting
281+
`shape.userData` must be set to `Wall`.
139282

140283
If `userData` is `null` the contact events are ignored, it is `null` by default.
141284

142-
A convenient way of setting `userData` is to assign it when creating the body. For example:
285+
Forge2D only generates events for shapes that have opted in to them, so the involved shapes also
286+
need `ShapeDef.enableContactEvents` set to true (and `ShapeDef.enableSensorEvents` for sensors
287+
and their visitors). The default `createBody()` implementation of `BodyComponent` enables these
288+
flags automatically for shapes created through `shapeSpecs` when a `ContactCallbacks` is present
289+
in the body's or shape's userData, but if you override `createBody()` you need to set them
290+
yourself:
143291

144292
```dart
145293
class Ball extends BodyComponent with ContactCallbacks {
@@ -151,14 +299,22 @@ class Ball extends BodyComponent with ContactCallbacks {
151299
final bodyDef = BodyDef(
152300
userData: this,
153301
);
302+
final shapeDef = ShapeDef(
303+
enableContactEvents: true,
304+
);
154305
...
155306
}
156307
157308
}
158309
```
159310

160311
Every time `Ball` and `Wall` begin to come in contact `beginContact` will be called, and once the
161-
fixtures cease being in contact, `endContact` will be called.
312+
shapes cease being in contact, `endContact` will be called.
313+
314+
The old `preSolve` and `postSolve` callbacks no longer exist. To disable a contact before it is
315+
solved (for example for one-sided platforms), use `world.preSolveCallback` together with
316+
`ShapeDef.enablePreSolveEvents`. To measure impact strength, enable `ShapeDef.enableHitEvents`
317+
and poll `world.physicsWorld.contactEvents.hit`.
162318

163319
An implementation example can be seen in the [Flame Forge2D
164320
example](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart).

0 commit comments

Comments
 (0)