diff --git a/packages/forge2d/hook/build.dart b/packages/forge2d/hook/build.dart index bd8eb8a4..35ee2584 100644 --- a/packages/forge2d/hook/build.dart +++ b/packages/forge2d/hook/build.dart @@ -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', @@ -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 args) async { await build(args, (input, output) async { if (!input.config.buildCodeAssets) { @@ -62,7 +65,7 @@ void main(List 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, diff --git a/packages/forge2d/lib/src/api/world.dart b/packages/forge2d/lib/src/api/world.dart index 8594b296..fa9be2f0 100644 --- a/packages/forge2d/lib/src/api/world.dart +++ b/packages/forge2d/lib/src/api/world.dart @@ -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. /// @@ -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(); } diff --git a/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart b/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart index daae1da3..4a3f5c8b 100644 --- a/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart +++ b/packages/forge2d/lib/src/backend/raw_box2d_ffi.dart @@ -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. @@ -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)); diff --git a/packages/forge2d/lib/src/ffi/box2d.g.dart b/packages/forge2d/lib/src/ffi/box2d.g.dart index cb5d5c2c..109491cc 100644 --- a/packages/forge2d/lib/src/ffi/box2d.g.dart +++ b/packages/forge2d/lib/src/ffi/box2d.g.dart @@ -936,16 +936,6 @@ external b2ExplosionDef b2DefaultExplosionDef(); @ffi.Native() 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)>() -external b2WorldId b2CreateWorld(ffi.Pointer def); - -/// Destroy a world -@ffi.Native() -external void b2DestroyWorld(b2WorldId worldId); - /// World id validation. Provides validation for up to 64K allocations. @ffi.Native() external bool b2World_IsValid(b2WorldId id); diff --git a/packages/forge2d/lib/src/ffi/f2d_world_lock.dart b/packages/forge2d/lib/src/ffi/f2d_world_lock.dart new file mode 100644 index 00000000..a69398c2 --- /dev/null +++ b/packages/forge2d/lib/src/ffi/f2d_world_lock.dart @@ -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)>( + symbol: 'f2d_create_world_locked', +) +external b2WorldId f2dCreateWorldLocked( + ffi.Pointer definition, +); + +/// `b2DestroyWorld` under the same lock as [f2dCreateWorldLocked]. +@ffi.Native(symbol: 'f2d_destroy_world_locked') +external void f2dDestroyWorldLocked(b2WorldId id); diff --git a/packages/forge2d/native/ffi/f2d_world_lock.c b/packages/forge2d/native/ffi/f2d_world_lock.c new file mode 100644 index 00000000..5be3f83c --- /dev/null +++ b/packages/forge2d/native/ffi/f2d_world_lock.c @@ -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(); +} diff --git a/packages/forge2d/test/api/isolates_test.dart b/packages/forge2d/test/api/isolates_test.dart new file mode 100644 index 00000000..32a1742b --- /dev/null +++ b/packages/forge2d/test/api/isolates_test.dart @@ -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 _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; +} diff --git a/packages/forge2d/test/api/world_test.dart b/packages/forge2d/test/api/world_test.dart index fe268edd..57934bdd 100644 --- a/packages/forge2d/test/api/world_test.dart +++ b/packages/forge2d/test/api/world_test.dart @@ -5,6 +5,23 @@ void main() { setUpAll(initializeForge2D); group('World', () { + test('throws a StateError when Box2D runs out of worlds', () { + final worlds = []; + 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); diff --git a/packages/forge2d/test/ffi/smoke_test.dart b/packages/forge2d/test/ffi/smoke_test.dart index f0e0d4b6..117d803f 100644 --- a/packages/forge2d/test/ffi/smoke_test.dart +++ b/packages/forge2d/test/ffi/smoke_test.dart @@ -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: @@ -13,7 +14,7 @@ void main() { test('a falling box lands on the ground', () { using((arena) { final worldDef = arena()..ref = b2.b2DefaultWorldDef(); - final worldId = b2.b2CreateWorld(worldDef); + final worldId = f2d.f2dCreateWorldLocked(worldDef); expect(b2.b2World_IsValid(worldId), isTrue); final groundDef = arena()..ref = b2.b2DefaultBodyDef(); @@ -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); }); }); diff --git a/packages/forge2d/tool/ffigen/ffigen.yaml b/packages/forge2d/tool/ffigen/ffigen.yaml index e33c5345..a178995c 100644 --- a/packages/forge2d/tool/ffigen/ffigen.yaml +++ b/packages/forge2d/tool/ffigen/ffigen.yaml @@ -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.*'