flutter3d
Showcase Changelog 38 packages API reference

Bridging a falling rigid body and its collision

since 0.7.0 The Flame bridge

RigidBodyComponent bridges one flutter3d_physics RigidBody the same way Object3dComponent bridges any other transform — the solver decides where the body is, and the Flame side only ever reads it. CollisionBridge re-fires flutter3d's own collision events as Flame's CollisionCallbacks.

Step 1: A world and a body to fall through it #

A static floor, a Dynamics solver, and a RigidBody dropped above it. A thin trigger sits just above the floor too — Dynamics stops a falling body exactly at the surface it lands on and never inside it, so the floor itself never has an overlap to report; the trigger genuinely overlaps the crate the moment it arrives, without affecting how or where Dynamics actually settles it, since Dynamics.step ignores triggers entirely.

final world = CollisionWorld();
world.addBox(Vector3(0.0, -0.5, 0.0), Vector3(6.0, 1.0, 6.0));
// A thin trigger embedded just above the floor, so the crate's fall
// genuinely overlaps something: `Dynamics` stops a falling body exactly
// at the surface it lands on, never inside it, so the floor itself never
// reports an overlap to relay. `Dynamics.step` ignores triggers entirely
// (`includeTriggers: false` in its own contact queries), so this sensor
// never affects how or where the crate actually lands.
final landingSensor = world.add(
  Collider(
    shape: CollisionBox(Vector3(3.0, 0.05, 3.0)),
    position: Vector3(0.0, 0.02, 0.0),
    kind: ColliderKind.trigger,
  ),
);
final dynamics = Dynamics(world: world);
final body = dynamics.add(
  RigidBody(
    world: world,
    shape: CollisionBox(Vector3(0.3, 0.3, 0.3)),
    position: Vector3(0.0, 2.0, 0.0),
    mass: 1.0,
  ),
);

Step 2: Bridge the body and its collisions #

RigidBodyComponent wraps the body; CollisionBridge attaches itself to the body's own collider and relays what it touches. The trigger has no Flame component of its own, so a stand-in answers resolveOther for it — without one, the bridge would have nothing to hand back and would call nothing at all.

final scene = Scene();
final node = MeshNode(
  DeviceMesh.upload(
    device,
    CuboidShape(size: Vector3(0.6, 0.6, 0.6)).build(),
  ),
  Material(name: 'bridged-crate', baseColor: Vector4(0.7, 0.5, 0.3, 1.0)),
);
scene.add(node);
final component = _TrackingRigidBodyComponent(
  body: body,
  node: node,
  scene: scene,
  plane: BridgePlane.ground(),
);
// A Flame component standing in for the landing sensor, so the bridge
// below has somewhere real to hand back — a collider with no Flame side
// of its own would make `resolveOther` answer null, and the bridge
// calls nothing when it does.
final landingMarker = PositionComponent();
CollisionBridge(
  collider: body.collider,
  component: component,
  resolveOther: (Collider other) =>
      other == landingSensor ? landingMarker : null,
);

Step 3: Fall, land, and hear about it #

Each Dynamics step moves the body; each CollisionWorld.update dispatches whatever now overlaps.

// Falls, lands, and the world dispatches the overlap each step — the
// same two calls `physics_particles/collision_layers.dart` already makes
// by hand, now feeding a Flame collision callback instead of a listener
// this page owns directly.
const double step = 1 / 60;
for (var i = 0; i < 180; i++) {
  dynamics.step(step);
  world.update();
}
component.update(step);

The crate settled where the solver's own resting height says it should, and on the way down CollisionBridge had already turned its overlap with the landing trigger into a call on the Flame-side component — the same event a native Flame body colliding with another would fire.

Step 4: Watch it fall, over and over #

The same three pieces, now running. A component steps Dynamics and the collision world once a Flame frame; the crate's own component copies where the body went. The map in the corner is a side view of the same fall, Flame's y being the height, drawn with BridgePlane.backdrop. When the crate touches the landing pad, CollisionBridge hands the overlap to a Flame callback, and that callback turns the pad green on both layers; a moment after the crate comes to rest it is put back and dropped again from the height on the slider.

// The same world, body and bridge as above, running: a component steps the
// solver and dispatches the overlaps, and the crate's own component copies
// where the body went. The map is a side view, Flame's y being height.
final CollisionWorld world = CollisionWorld()
  ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(6.0, 1.0, 6.0));
final Collider sensor = world.add(
  Collider(
    shape: CollisionBox(Vector3(3.0, 0.05, 3.0)),
    position: Vector3(0.0, 0.02, 0.0),
    kind: ColliderKind.trigger,
  ),
);
final Dynamics dynamics = Dynamics(world: world);
final RigidBody body = dynamics.add(
  RigidBody(
    world: world,
    shape: CollisionBox(Vector3(0.3, 0.3, 0.3)),
    position: Vector3(0.0, dropHeight, 0.0),
    mass: 1.0,
  ),
);
final Map<String, Object?> start = body.save();

final RectangleComponent landing = RectangleComponent(
  position: Vector2(-3.0, -0.03),
  size: Vector2(6.0, 0.1),
  paint: Paint()..color = _idle,
);
final _TrackingRigidBodyComponent crateComponent =
    _TrackingRigidBodyComponent(
      body: body,
      node: crate,
      scene: _scene,
      plane: BridgePlane.backdrop(),
      // The Flame side reacts to the collision it was handed.
      onTouch: (bool touching) {
        landing.paint.color = touching ? _touching : _idle;
        _pad.material.baseColor.setFrom(touching ? _padTouching : _padIdle);
      },
    )..add(
      RectangleComponent(
        size: Vector2.all(0.6),
        anchor: Anchor.center,
        paint: Paint()..color = const Color(0xFFB2804D),
      ),
    );
CollisionBridge(
  collider: body.collider,
  component: crateComponent,
  resolveOther: (Collider other) => other == sensor ? landing : null,
);