flutter3d
Showcase Changelog 38 packages API reference

A character controller

since 0.5.0 Physics and particles

A rigid body that never rotates and never bounces is the wrong tool for a player: what a player wants is to slide along a wall rather than bounce off it, and to feel the same on every machine regardless of frame rate. CharacterController is a second, purpose-built way of moving a body through the same CollisionWorld.

Step 1: Something to stand on #

_world = CollisionWorld();
_world.addBox(Vector3(0.0, -0.5, 0.0), Vector3(20.0, 1.0, 20.0));

Step 2: The walker itself #

A controller registers its own collider in the world it is given, so a monster sees the player as an obstacle and a trigger sees them arrive. Left unspecified, the shape is a box; a capsule suits anything a melee swing or a blast needs to test against exactly.

_controller = CharacterController(
  world: _world,
  position: Vector3(0.0, 3.0, 0.0),
);

Step 3: Step it, more than once #

step takes a wish direction and the seconds since the last call. It already applies gravity, tries to jump if one was buffered, moves the body and slides it along whatever it meets, and probes the ground underneath it, all in one call. A page rendered once cannot wait for real frames, so it runs the steps a game would spread across a second and a half up front, before the one frame it draws.

// Stepped by hand, at a fixed rate, so the same run always lands the
// same way. A real game calls this once a frame with the frame's own
// wish direction; a page rendered once has to do all its steps up front.
for (var i = 0; i < _steps; i++) {
  _controller.step(_step, wishDirection: Vector3(1.0, 0.0, 0.0));
}

Step 4: A body to actually show #

The collider above is invisible on purpose — nothing in this engine draws a CollisionShape. What a reader sees standing on the floor is RobotExpressive.glb, loaded the same way any application loads a model and scaled to the height the box already claimed.

// CC0 — Tomás Laulhé, with facial morph targets by Don McCurdy; see
// `packages/flutter3d_samples/assets/ATTRIBUTION.md`. Fourteen clips ship
// in the one file, and "Walking" is the one this page plays.
final document = await loadModelByPath(
  'packages/flutter3d_samples/assets/RobotExpressive.glb',
);
_asset = await ModelAsset.fromDocument(document, device: context.device);
// The model's own origin sits at its feet, not its centre, and it faces
// +Z at rest; turned a quarter-turn around Y so it faces the +X the walk
// above actually carried it towards, then dropped onto the box's own
// centre minus half its height, the ground the box already stands on.
final ModelInstance model = _model = _asset.instantiate(
  scene,
  name: 'walker',
);
final double scale = _characterHeight / _modelHeight;
model.root
  ..setScale(scale, scale, scale)
  ..setRotation(Quaternion.axisAngle(Vector3(0.0, 1.0, 0.0), -math.pi / 2));
model.player?.playNamed('Walking');

Step 5: What landing means #

isGrounded is true once the probe below the feet finds something to stand on, and groundNormal is the face it found: straight up on a flat floor, tilted on a slope. Reading it costs nothing extra — the probe already knew it and used to throw it away.

if (!_controller.isGrounded) {
  throw StateError('the walker should have landed by now');
}
if (_controller.groundNormal.y < 0.99) {
  throw StateError('a flat floor should read as a flat ground normal');
}
if (_controller.position.x <= 0.0) {
  throw StateError('the walker should have moved towards +x');
}

Note. groundBody is null here, because the floor is a static box and nothing is carrying the walker. It is only ever set to something that moves under its own power, such as a lift.