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
34 changes: 34 additions & 0 deletions .github/workflows/build-wasm.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: build-wasm

on:
pull_request:
paths:
- "packages/forge2d/native/wasm/**"
- "packages/forge2d/third_party/box2d/**"
- "packages/forge2d/tool/build_wasm.sh"
- ".github/workflows/build-wasm.yml"
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: mymindstorm/setup-emsdk@v14
with:
# Keep in sync with the version documented in tool/build_wasm.sh;
# the artifact check below relies on reproducible output.
version: 4.0.15
- name: Rebuild box2d.wasm
run: packages/forge2d/tool/build_wasm.sh
- 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."
exit 1
fi
- uses: actions/upload-artifact@v4
with:
name: box2d-wasm
path: packages/forge2d/lib/src/backend/wasm/box2d.wasm
15 changes: 15 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,19 @@ jobs:
- uses: subosito/flutter-action@v2
- uses: bluefireteam/melos-action@v3
- run: melos test

test-web:
needs: [format, analyze]
strategy:
fail-fast: false
matrix:
compiler: [dart2js, dart2wasm]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
- uses: bluefireteam/melos-action@v3
- name: Test the WebAssembly backend
working-directory: packages/forge2d
run: dart test -p chrome -c ${{ matrix.compiler }}
# END TESTING STAGE
20 changes: 16 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,16 @@ You can use it independently in Dart or in your

- Dart 3.12+ (Flutter 3.38+), which builds the bundled C library
automatically through build hooks.
- A C toolchain: Xcode on macOS/iOS, Visual Studio Build Tools on Windows,
clang or gcc on Linux, and the NDK on Android.
- Supported platforms: Android, iOS, macOS, Windows, and Linux. Web support
is planned via a WebAssembly build of Box2D behind the same API.
- A C toolchain on native platforms: Xcode on macOS/iOS, Visual Studio
Build Tools on Windows, clang or gcc on Linux, and the NDK on Android.
- Supported platforms: Android, iOS, macOS, Windows, Linux, and the web.

On the web the same API runs against a bundled WebAssembly build of Box2D
(about 220 KB). No setup is needed: `initializeForge2D()` finds the module
at the package asset path in Dart web apps, and at the bundled package
asset in Flutter web apps. For custom hosting setups the location can be
overridden with `initializeForge2D(wasmUri: ...)`, and
`dart run forge2d:setup_web` copies the module into a `web/` directory.

## Getting started

Expand Down Expand Up @@ -109,6 +115,12 @@ concepts map as follows:
removed; stay on forge2d 0.14 if you depend on it.
- Worlds default to `subStepCount: 4` in `step` instead of velocity and
position iterations.
- A bare `World()` now has the Box2D default gravity of `(0, -10)`; the old
API defaulted to zero gravity. Top-down games should pass
`World(gravity: Vector2.zero())`.
- Destroying bodies, shapes, chains, or joints while the world is stepping
(from a collision callback) is deferred until the step ends; creating
them mid-step throws a `StateError` instead of the old silent queueing.

## Timeline

Expand Down
32 changes: 32 additions & 0 deletions packages/forge2d/bin/setup_web.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copies the bundled box2d.wasm into a web app's web/ directory, where
// initializeForge2D() finds it at runtime.
//
// Run from the root of your app:
// dart run forge2d:setup_web
// ignore_for_file: avoid_print
import 'dart:io';
import 'dart:isolate';

Future<void> main() async {
final packageUri = await Isolate.resolvePackageUri(
Uri.parse('package:forge2d/src/backend/wasm/box2d.wasm'),
);
if (packageUri == null) {
stderr.writeln('Could not resolve the forge2d package.');
exitCode = 1;
return;
}

final webDirectory = Directory('web');
if (!webDirectory.existsSync()) {
stderr.writeln(
'No web/ directory found. Run this from the root of a web app.',
);
exitCode = 1;
return;
}

final target = File('web/box2d.wasm');
File.fromUri(packageUri).copySync(target.path);
print('Copied box2d.wasm to ${target.path}');
}
5 changes: 5 additions & 0 deletions packages/forge2d/dart_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# The default run exercises the FFI backend on the VM. Run the same suite
# against the WebAssembly backend with:
# dart test -p chrome (dart2js)
# dart test -p chrome -c dart2wasm
platforms: [vm]
55 changes: 54 additions & 1 deletion packages/forge2d/lib/src/api/body.dart
Original file line number Diff line number Diff line change
Expand Up @@ -336,8 +336,23 @@ class Body {
}

/// Creates a shape with the given [geometry] and attaches it to the body.
///
/// Cannot be called while the world is stepping.
Shape createShape(ShapeGeometry geometry, [ShapeDef? definition]) {
world.checkCanMutate('create a shape');
final shapeDefinition = definition ?? ShapeDef();
assert(
shapeDefinition.density >= 0,
'ShapeDef.density must not be negative',
);
assert(
switch (geometry) {
Circle(:final radius) => radius > 0 && radius.isFinite,
Capsule(:final radius) => radius > 0 && radius.isFinite,
_ => true,
},
'The geometry radius must be positive and finite',
);
final rawDefinition = _rawShapeDef(shapeDefinition);
final (shapeIndex1, shapeWorldAndGeneration) = switch (geometry) {
Circle(:final center, :final radius) => rawBox2D.createCircleShape(
Expand Down Expand Up @@ -407,7 +422,22 @@ class Body {
}

/// Creates a chain of one-sided segments and attaches it to the body.
///
/// Chains need at least four points: open chains use the first and last
/// point as invisible ghost anchors that smooth collisions at the ends.
///
/// Cannot be called while the world is stepping.
Chain createChain(ChainDef definition) {
world.checkCanMutate('create a chain');
if (definition.points.length < 4) {
throw ArgumentError('A chain needs at least four points');
}
final materialCount = definition.materials.length;
if (materialCount != 1 && materialCount != definition.points.length) {
throw ArgumentError(
'A chain needs one material, or one material per point',
);
}
final (chainIndex1, chainWorldAndGeneration) = rawBox2D.createChain(
index1,
worldAndGeneration,
Expand All @@ -434,6 +464,10 @@ class Body {
world.chainUserData[(chainIndex1, chainWorldAndGeneration)] =
definition.userData;
}
world.chainOwners[(chainIndex1, chainWorldAndGeneration)] = (
index1,
worldAndGeneration,
);
return Chain.internal(world, chainIndex1, chainWorldAndGeneration);
}

Expand All @@ -455,14 +489,33 @@ class Body {
];
}

/// Destroys this body and all its shapes and joints.
/// Destroys this body and all its shapes, chains, and joints.
///
/// Safe to call while the world is stepping (from a callback or while
/// processing events): the destruction is deferred until the step ends,
/// and the body stays valid until then.
void destroy() {
if (world.locked) {
world.deferredActions.add(destroy);
return;
}
if (!isValid) {
// Also covers a deferred destroy that was requested more than once.
return;
}
for (final shape in shapes) {
world.shapeUserData.remove((shape.index1, shape.worldAndGeneration));
}
for (final joint in joints) {
world.jointUserData.remove((joint.index1, joint.worldAndGeneration));
}
world.chainOwners.removeWhere((chainId, ownerId) {
if (ownerId == (index1, worldAndGeneration)) {
world.chainUserData.remove(chainId);
return true;
}
return false;
});
world.bodyUserData.remove((index1, worldAndGeneration));
rawBox2D.destroyBody(index1, worldAndGeneration);
}
Expand Down
11 changes: 11 additions & 0 deletions packages/forge2d/lib/src/api/chain.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,19 @@ class Chain {
}

/// Destroys this chain and its segment shapes.
///
/// Safe to call while the world is stepping: the destruction is deferred
/// until the step ends, and the chain stays valid until then.
void destroy() {
if (world.locked) {
world.deferredActions.add(destroy);
return;
}
if (!isValid) {
return;
}
world.chainUserData.remove((index1, worldAndGeneration));
world.chainOwners.remove((index1, worldAndGeneration));
rawBox2D.destroyChain(index1, worldAndGeneration);
}

Expand Down
3 changes: 2 additions & 1 deletion packages/forge2d/lib/src/api/defs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ class BodyDef {
/// The speed below which the body may fall asleep, in meters per second.
double sleepThreshold;

/// An optional name for debugging.
/// An optional name for debugging, truncated to 31 bytes by the native
/// library.
String? name;

/// An arbitrary object associated with the body.
Expand Down
10 changes: 10 additions & 0 deletions packages/forge2d/lib/src/api/joints/joint.dart
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,17 @@ abstract base class Joint {
}

/// Destroys this joint.
///
/// Safe to call while the world is stepping: the destruction is deferred
/// until the step ends, and the joint stays valid until then.
void destroy() {
if (world.locked) {
world.deferredActions.add(destroy);
return;
}
if (!isValid) {
return;
}
world.jointUserData.remove((index1, worldAndGeneration));
rawBox2D.destroyJoint(index1, worldAndGeneration);
}
Expand Down
61 changes: 61 additions & 0 deletions packages/forge2d/lib/src/api/shape.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import 'package:forge2d/src/api/body.dart';
import 'package:forge2d/src/api/defs.dart';
import 'package:forge2d/src/api/enums.dart';
import 'package:forge2d/src/api/geometry.dart';
import 'package:forge2d/src/api/math.dart';
import 'package:forge2d/src/api/world.dart';
import 'package:forge2d/src/initialize.dart';
Expand Down Expand Up @@ -149,6 +150,56 @@ class Shape {
bool testPoint(Vector2 point) =>
rawBox2D.shapeTestPoint(index1, worldAndGeneration, point.x, point.y);

/// The geometry of the shape in the body's local coordinates, read back
/// from the simulation.
///
/// Polygons return their convex hull, so a [Polygon.box] comes back as a
/// four-vertex [Polygon]. Chain segments come back as the [Segment] they
/// collide with.
ShapeGeometry get geometry {
switch (type) {
case ShapeType.circle:
final (centerX, centerY, radius) = rawBox2D.shapeGetCircle(
index1,
worldAndGeneration,
);
return Circle(center: Vector2(centerX, centerY), radius: radius);
case ShapeType.capsule:
final (center1X, center1Y, center2X, center2Y, radius) = rawBox2D
.shapeGetCapsule(index1, worldAndGeneration);
return Capsule(
center1: Vector2(center1X, center1Y),
center2: Vector2(center2X, center2Y),
radius: radius,
);
case ShapeType.segment:
final (point1X, point1Y, point2X, point2Y) = rawBox2D.shapeGetSegment(
index1,
worldAndGeneration,
);
return Segment(
point1: Vector2(point1X, point1Y),
point2: Vector2(point2X, point2Y),
);
case ShapeType.chainSegment:
final (point1X, point1Y, point2X, point2Y) = rawBox2D
.shapeGetChainSegment(index1, worldAndGeneration);
return Segment(
point1: Vector2(point1X, point1Y),
point2: Vector2(point2X, point2Y),
);
case ShapeType.polygon:
final polygon = rawBox2D.shapeGetPolygon(index1, worldAndGeneration);
return Polygon(
[
for (var i = 0; i < polygon.points.length; i += 2)
Vector2(polygon.points[i], polygon.points[i + 1]),
],
radius: polygon.radius,
);
}
}

/// The current world axis-aligned bounding box of the shape.
Aabb get aabb {
final (lowerX, lowerY, upperX, upperY) = rawBox2D.shapeGetAabb(
Expand All @@ -173,7 +224,17 @@ class Shape {
///
/// When [updateBodyMass] is false, call [Body.applyMassFromShapes]
/// afterwards.
///
/// Safe to call while the world is stepping: the destruction is deferred
/// until the step ends, and the shape stays valid until then.
void destroy({bool updateBodyMass = true}) {
if (world.locked) {
world.deferredActions.add(() => destroy(updateBodyMass: updateBodyMass));
return;
}
if (!isValid) {
return;
}
world.shapeUserData.remove((index1, worldAndGeneration));
rawBox2D.destroyShape(
index1,
Expand Down
Loading
Loading