flutter3d
Showcase Changelog 62 packages API reference

Collision & physics

flutter3d_physics is plain Dart. It uses neither Flutter nor the renderer, and not even dart:io, so all of it runs under dart test on the VM.

That is not tidiness. The failures this code has happen once in a thousand steps: a body that ends up inside geometry, a sweep that passes through a wall at speed, a platform that stops carrying its passenger. Finding those means running thousands of steps in a loop, which is possible exactly because none of it needs a device.

Shapes #

CollisionBox(Vector3(0.5, 0.9, 0.5));
CollisionSphere(0.35);
CollisionCapsule(radius: 0.35, halfHeight: 0.55);

Every pair of the built-in shapes is implemented exactly: box/box, box/sphere, box/capsule, sphere/sphere, sphere/capsule, capsule/capsule, plus a raycast per shape. Exact cases are cheaper to trust than one general algorithm nobody can debug. CollisionShape is an abstract base class, not a sealed one, so a game's own shape extends it directly: every pair method defaults to a GJK walk over the two shapes' supports, ground to the field's prisms, a ray to the bounding box and inertia to the solid box, so a convex shape that says its bounds, planes and support meets everything, and the native core hulls it from its support points. CustomShape is the ready-made one for a convex solid given by its support function.

The world #

final world = CollisionWorld();

final wall = world.addBox(
  Vector3(0, 2, -10), Vector3(20, 4, 1),
  userData: 'north wall',
);

final trigger = world.add(Collider(
  shape: CollisionBox(Vector3(1, 1, 1)),
  position: Vector3(4, 1, 0),
  kind: ColliderKind.trigger,
  layer: CollisionLayers.trigger,
  mask: CollisionLayers.player,
  userData: myMechanism,
)..onContact(myMechanism));
ColliderKind static (level geometry), kinematic (doors, lifts, platforms) or trigger (blocks nothing, reports overlap)
layer / mask Two colliders meet when each is in the other's mask
userData Who this collider is, the one question it answers
listener onCollisionStart, onCollision, onCollisionEnd
delta How far a kinematic mover travelled this step, for a passenger to read
surfaceVelocity What a conveyor adds to whatever stands on it

Two grids, not one #

flowchart TB
  add["Collider added"] --> which{"does it move?"}
  which -->|no| static["static grid<br>reindexed only on add/remove"]
  which -->|yes| movers["mover grid<br>rebuilt by reindex()"]
  static --> query["overlap · sweep · raycast"]
  movers --> query

SpatialGrid is a uniform grid keyed on (x, z), with four-metre cells. Statics are indexed once; movers are rebuilt by reindex(), which is why the step order calls it right after mechanisms have moved. reindex, clearKinematicDeltas and Collider.clearDelta are the step's bookkeeping and live in package:flutter3d_physics/backend.dart, for whoever writes a step; EngineLoop and WorldStep call them for a game.

reindex() before the body steps is ordering by argument, not by test. It is right, since the broadphase should match geometry that already moved. But the narrow phase reads live positions and a cell is four metres, so a stale index loses a mover only if it left its cell inside one step. No door does. Nothing here claims a test covers it.

Queries #

final hit = SweepHit();
if (world.sweep(shape, from, delta, hit, mask: CollisionLayers.world)) {
  // hit.collider, hit.normal, hit.time
}

final ray = RayHit();
if (world.rayCast(origin, direction, 30.0, ray, mask: CollisionLayers.world)) {
  // ray.collider, ray.point, ray.normal, ray.distance
}

world.overlap(shape, position, nearby, mask: CollisionLayers.actor);

// Pushes a box out of whatever it ended up inside; `out` is the correction.
world.depenetrate(center, halfExtents, out);

Sweeps are swept-AABB against the grid's ray walk, so a fast body does not tunnel. Everything here is allocation-free: hits are written into a caller-owned object, and the object is reused.

The character controller #

Kinematic. It sweeps and slides, and nothing ever moves it. That is what a first-person game feels as solidity.

final body = CharacterController(
  world: world,
  position: startPosition + Vector3(0, 0.9, 0),
  settings: const MovementSettings(
    walkSpeed: 5.0,
    sprintSpeed: 8.0,
    groundAcceleration: 60.0,
    groundFriction: 10.0,
    airAcceleration: 12.0,
    gravity: 22.0,
    terminalVelocity: 55.0,
    jumpSpeed: 7.5,
    stepHeight: 0.4,
    coyote: Duration(milliseconds: 120),
    jumpBuffer: Duration(milliseconds: 120),
    groundProbe: 0.15,
    floorSnapLength: 0.3,
  ),
);

body.requestJump();
body.step(dt, wishDirection: wish, sprint: input.held(GameAction.sprint));
Member What it is for
isGrounded, ground, groundBody What is underfoot, and whether it is a mover
requestJump() Buffers a jump; jumpBuffer decides how long
tryResize(shape, keepFeet: true) Crouch and stand, refused when there is no headroom
teleport(to) A cut, not a move, no sweep, no carry
solidFilter A ContactFilter that decides what counts as solid for this body
suppressFloorSnap() "I meant to leave the ground". Say it whenever you write velocity.y yourself

Walking down stairs #

groundProbe asks whether there is a floor within a few centimetres of the feet. A staircase is further than that, so on every tread the body is briefly in free fall: measured at 116 airborne steps out of 600 walking down 0.2 m stairs, with everything that hangs off isGrounded flickering along with it.

floorSnapLength is how far the probe may reach to keep a floor it already had, and it defaults to 0.0, which is exactly today's behaviour. It never finds new ground: the feet must have been on something as of the last step.

A drop shorter than the snap stops being a drop. Step off a ledge this high and the body is placed on the floor below instead of falling to it, so keep the figure well under the shallowest hole your levels mean as a hole. A third of a metre carries a 0.2 m staircase; two metres would turn an authored pit into a dip.

A spring, a stomp bounce or a jump the game owns is also a grounded body whose velocity.y was written from outside, and the controller cannot tell that from a stair edge. Call suppressFloorSnap() when you write it:

void launch(double speed) {
  body.velocity.y = speed;
  body.suppressFloorSnap();
}

Coyote time and jump buffering #

Both are latencies with a purpose, and both are properties of a body rather than of a control scheme:

Carrying and riding #

// reindex and clearKinematicDeltas: package:flutter3d_physics/backend.dart
mechanisms.step(dt);              // the lift moves, writing collider.delta
collision.reindex();
body.step(dt, wishDirection: wish);  // reads the delta, and is carried
collision.update();
collision.clearKinematicDeltas();

A platform that moves sideways cannot carry you if the deltas are cleared before the body steps. Note sideways: a rising lift penetrates the capsule standing on it and the controller pushes it out, upwards, whether the delta was read or not, so the obvious test passes and the real bug survives.

solidFilter is what a one-way platform is made of: the platform is solid when the body is above it and moving down, and not solid otherwise.

Rigid bodies #

Dynamics is stage one of two: mass, gravity, impulses, resting and sleeping, and no rotation, which is what makes a box stay axis-aligned and its contacts exact.

final dynamics = Dynamics(world: world)   // gravity defaults to the controller's
  ..positionIterations = 6;

final crate = dynamics.add(RigidBody(
  world: world,
  shape: CollisionBox(Vector3(0.6, 0.6, 0.6)),
  position: at,
  mass: 40.0,
  restitution: 0.0,
  friction: 0.6,
));

dynamics.step(dt);

A character does not push anything on its own #

That is not an oversight. The controller is kinematic and is never moved by anything, so the transfer is explicit, and it hands over a speed instead of a force:

body.step(dt, wishDirection: wish);
dynamics.push(
  CharacterPush(by: body.collider, velocity: body.velocity, strength: 1.0),
);

A crate gets just enough velocity to move away at the speed it is being approached at, and no more. A force proportional to mass would let a player launch a light crate across the room by brushing it, which is exactly what the first version did.

Gravity defaults to the character controller's, because a crate that falls slower than the player who dropped it reads as a bug in the crate. positionIterations is several cheap passes rather than one aggressive one: in a tall stack, the fraction the bottom crate moves is a new overlap for the one above, and a single hard correction throws the pile apart.

Bodies sleep when they stop, and wake when something touches them. A RigidBodyComponent carries a body in a snapshot, so the next kind of moving thing cannot be left out of a save by omission.

Which engine runs it #

Everything on this page has two implementations: the Dart reference in flutter3d_physics, and the C core in flutter3d_physics_native, which is an FFI library natively and a WebAssembly module in the browser. A run uses one of them for everything — bodies, character moves, rays — and picks it once:

import 'package:flutter3d_physics_native/flutter3d_physics_native.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await preparePhysics();       // fetches the module in a browser
  runApp(const MyGame());
}

// later, wherever a world is staged
final dynamics = usePhysics().dynamics(DynamicsRequest(world));

// The game's root widget.
class MyGame extends StatelessWidget {
  const MyGame({super.key});

  @override
  Widget build(BuildContext context) => const SizedBox();
}

The core is the default. The reference runs when the build asks for it with --dart-define=FLUTTER3D_PHYSICS=dart, or when the core will not start: a missing library, a module that did not load, or bindings for another ABI. In that case physicsFallbackReason says what happened, and the game keeps running.

One switch for the whole run, not one per system. If characters walk on one engine and crates fall on another, the two disagree about where the floor is, and a recording of that run cannot be replayed on either.

The two engines are not promised to agree with each other, only each with itself. A recording stores the backend it was made on (Demo.physics), and a replay runs on that backend. A game that is already on the other backend refuses the replay and gives the reason, rather than drifting from the first step.

Layers are numbers here #

A collider carries a layer and a mask, and two of them meet when each is in the other's mask. Which bit means what is a game's business. This package shipped as part of one for a while, and its layer list named monster, pickup and projectile, which is exactly the knowledge a collision world must not have. Layers.all is the only constant left, because "every bit" means the same thing in every game.

The names live in flutter3d_game as CollisionLayers, and a genre adds its own: the platformer's PlatformerLayers.oneWay is bit six.

Next #