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
7 changes: 5 additions & 2 deletions packages/forge2d/hook/build.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import 'package:native_toolchain_c/native_toolchain_c.dart';
///
/// Listed explicitly so that publishing and hook dependency tracking stay
/// deterministic. Regenerate with tool/vendor_box2d.sh when upgrading.
const _sources = [
const _box2dSources = [
'third_party/box2d/src/aabb.c',
'third_party/box2d/src/arena_allocator.c',
'third_party/box2d/src/array.c',
Expand Down Expand Up @@ -45,6 +45,9 @@ const _sources = [
'third_party/box2d/src/world.c',
];

/// forge2d's own C sources, compiled into the same library as Box2D.
const _forge2dSources = ['native/ffi/f2d_world_lock.c'];

void main(List<String> args) async {
await build(args, (input, output) async {
if (!input.config.buildCodeAssets) {
Expand All @@ -62,7 +65,7 @@ void main(List<String> args) async {
final builder = CBuilder.library(
name: 'box2d',
assetName: 'src/ffi/box2d.g.dart',
sources: _sources,
sources: [..._box2dSources, ..._forge2dSources],
includes: ['third_party/box2d/include', 'third_party/box2d/src'],
std: 'c17',
optimizationLevel: OptimizationLevel.o2,
Expand Down
8 changes: 7 additions & 1 deletion packages/forge2d/lib/src/api/world.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import 'package:vector_math/vector_math.dart';
/// explicitly with [destroy]; destroying a world frees every body, shape,
/// and joint in it.
///
/// A world must only be used from the isolate that created it.
/// A world must only be used from the isolate that created it, but several
/// isolates can each run worlds of their own at the same time.
class World {
/// Creates a world.
///
Expand All @@ -47,6 +48,11 @@ class World {
enableSleep: definition.enableSleep,
enableContinuous: definition.enableContinuous,
) {
if (id == 0) {
throw StateError(
'Box2D supports at most 128 worlds at a time; destroy one first.',
);
}
// Freezes the length unit: it is baked into this world's tolerances.
markWorldCreated();
}
Expand Down
5 changes: 3 additions & 2 deletions packages/forge2d/lib/src/backend/raw_box2d_ffi.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'package:forge2d/src/backend/raw_box2d.dart';
import 'package:forge2d/src/ffi/box2d.g.dart' as b2;
import 'package:forge2d/src/ffi/f2d_world_lock.dart' as f2d;

/// Prepares the FFI backend. Nothing to do: the native library is bundled by
/// the build hook and loaded lazily. [wasmUri] only applies on the web.
Expand Down Expand Up @@ -98,12 +99,12 @@ final class RawBox2DFfi implements RawBox2D {
..maximumLinearSpeed = maximumLinearSpeed
..enableSleep = enableSleep
..enableContinuous = enableContinuous;
return _packWorld(b2.b2CreateWorld(definition));
return _packWorld(f2d.f2dCreateWorldLocked(definition));
});
}

@override
void destroyWorld(int worldId) => b2.b2DestroyWorld(_world(worldId));
void destroyWorld(int worldId) => f2d.f2dDestroyWorldLocked(_world(worldId));

@override
bool worldIsValid(int worldId) => b2.b2World_IsValid(_world(worldId));
Expand Down
10 changes: 0 additions & 10 deletions packages/forge2d/lib/src/ffi/box2d.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -936,16 +936,6 @@ external b2ExplosionDef b2DefaultExplosionDef();
@ffi.Native<b2DebugDraw Function()>()
external b2DebugDraw b2DefaultDebugDraw();

/// Create a world for rigid body simulation. A world contains bodies, shapes, and constraints. You make create
/// up to 128 worlds. Each world is completely independent and may be simulated in parallel.
/// @return the world id.
@ffi.Native<b2WorldId Function(ffi.Pointer<b2WorldDef>)>()
external b2WorldId b2CreateWorld(ffi.Pointer<b2WorldDef> def);

/// Destroy a world
@ffi.Native<ffi.Void Function(b2WorldId)>()
external void b2DestroyWorld(b2WorldId worldId);

/// World id validation. Provides validation for up to 64K allocations.
@ffi.Native<ffi.Bool Function(b2WorldId)>()
external bool b2World_IsValid(b2WorldId id);
Expand Down
20 changes: 20 additions & 0 deletions packages/forge2d/lib/src/ffi/f2d_world_lock.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Bindings to native/ffi/f2d_world_lock.c, which the build hook compiles
// into the same library as Box2D.
@ffi.DefaultAsset('package:forge2d/src/ffi/box2d.g.dart')
library;

import 'dart:ffi' as ffi;

import 'package:forge2d/src/ffi/box2d.g.dart';

/// `b2CreateWorld` under a process-wide lock.
@ffi.Native<b2WorldId Function(ffi.Pointer<b2WorldDef>)>(
symbol: 'f2d_create_world_locked',
)
external b2WorldId f2dCreateWorldLocked(
ffi.Pointer<b2WorldDef> definition,
);

/// `b2DestroyWorld` under the same lock as [f2dCreateWorldLocked].
@ffi.Native<ffi.Void Function(b2WorldId)>(symbol: 'f2d_destroy_world_locked')
external void f2dDestroyWorldLocked(b2WorldId id);
29 changes: 29 additions & 0 deletions packages/forge2d/native/ffi/f2d_world_lock.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Box2D's world table is process-wide and b2CreateWorld and b2DestroyWorld
// are not thread safe (https://github.com/erincatto/box2d/issues/904), but
// Dart isolates are threads over one copy of this library.

#include "atomic.h"
#include "box2d/box2d.h"

static b2AtomicInt f2d_world_lock;

static void f2d_lock(void) {
while (b2AtomicLoadInt(&f2d_world_lock) != 0 ||
!b2AtomicCompareExchangeInt(&f2d_world_lock, 0, 1)) {
}
}

static void f2d_unlock(void) { b2AtomicStoreInt(&f2d_world_lock, 0); }

BOX2D_EXPORT b2WorldId f2d_create_world_locked(const b2WorldDef* definition) {
f2d_lock();
b2WorldId id = b2CreateWorld(definition);
f2d_unlock();
return id;
}

BOX2D_EXPORT void f2d_destroy_world_locked(b2WorldId id) {
f2d_lock();
b2DestroyWorld(id);
f2d_unlock();
}
56 changes: 56 additions & 0 deletions packages/forge2d/test/api/isolates_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
@TestOn('vm')
library;

import 'dart:isolate';

import 'package:forge2d/forge2d.dart';
import 'package:test/test.dart';

void main() {
setUpAll(initializeForge2D);

test(
'isolates can create, step and destroy worlds at the same time',
() async {
const isolates = 8;
const worldsPerIsolate = 500;
final expected = _simulate();

final results = await Future.wait([
for (var i = 0; i < isolates; i++)
Isolate.run(() async {
await initializeForge2D();
return [for (var j = 0; j < worldsPerIsolate; j++) _simulate()];
}),
]);

expect(results, everyElement(everyElement(expected)));
},
);
}

/// Drops a short stack of boxes onto the ground and returns where they end
/// up. Box2D is deterministic, so every run gives exactly the same numbers.
List<double> _simulate() {
final world = World();
world
.createBody(BodyDef(position: Vector2(0, -1)))
.createShape(Polygon.box(20, 1));
final boxes = [
for (var i = 0; i < 3; i++)
world.createBody(
BodyDef(
type: BodyType.dynamic,
position: Vector2(0.1 * i, 1 + 1.1 * i),
),
)..createShape(Polygon.square(0.5)),
];
for (var i = 0; i < 30; i++) {
world.step(1 / 60);
}
final result = [
for (final box in boxes) ...[box.position.x, box.position.y, box.angle],
];
world.destroy();
return result;
}
17 changes: 17 additions & 0 deletions packages/forge2d/test/api/world_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ void main() {
setUpAll(initializeForge2D);

group('World', () {
test('throws a StateError when Box2D runs out of worlds', () {
final worlds = <World>[];
addTearDown(() {
for (final world in worlds) {
world.destroy();
}
});
expect(
() {
while (worlds.length <= 128) {
worlds.add(World());
}
},
throwsStateError,
);
});

test('starts valid and becomes invalid on destroy', () {
final world = World();
expect(world.isValid, isTrue);
Expand Down
5 changes: 3 additions & 2 deletions packages/forge2d/test/ffi/smoke_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'dart:ffi';

import 'package:ffi/ffi.dart';
import 'package:forge2d/src/ffi/box2d.g.dart' as b2;
import 'package:forge2d/src/ffi/f2d_world_lock.dart' as f2d;
import 'package:test/test.dart';

/// Exercises the native build hook and the raw generated bindings end-to-end:
Expand All @@ -13,7 +14,7 @@ void main() {
test('a falling box lands on the ground', () {
using((arena) {
final worldDef = arena<b2.b2WorldDef>()..ref = b2.b2DefaultWorldDef();
final worldId = b2.b2CreateWorld(worldDef);
final worldId = f2d.f2dCreateWorldLocked(worldDef);
expect(b2.b2World_IsValid(worldId), isTrue);

final groundDef = arena<b2.b2BodyDef>()..ref = b2.b2DefaultBodyDef();
Expand Down Expand Up @@ -45,7 +46,7 @@ void main() {
expect(position.y, closeTo(0.5, 0.01));
expect(b2.b2Body_IsAwake(bodyId), isFalse);

b2.b2DestroyWorld(worldId);
f2d.f2dDestroyWorldLocked(worldId);
expect(b2.b2World_IsValid(worldId), isFalse);
});
});
Expand Down
3 changes: 3 additions & 0 deletions packages/forge2d/tool/ffigen/ffigen.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ functions:
exclude:
- 'b2SetAllocator'
- 'b2SetAssertFcn'
# Bound through lib/src/ffi/f2d_world_lock.dart, under a lock.
- 'b2CreateWorld'
- 'b2DestroyWorld'
structs:
include:
- 'b2.*'
Expand Down
Loading