flutter3d
Showcase Changelog 38 packages API reference

A character controller: the source

since 0.5.0 Physics and particles

Regions the guide quotes: model (line 47), floor (line 58), controller (line 61), walk (line 66), body (line 99), check (line 133).

1/// A capsule-shaped walker, dropped onto a floor and pushed sideways,
2/// stepped by hand a few dozen times rather than once, so it actually lands.
3/// The collider stays the plain box `CharacterController` gives one by
4/// default — see `character_controller.md` for why — but what stands on it
5/// is `RobotExpressive.glb` rather than the crate that used to mark its
6/// place, so a reader watches an actual character land and walk.
7///
8/// Quoted by `character_controller.md` and shown whole in the Source tab.
9library;
11import 'dart:math' as math;
13import 'package:flutter3d/flutter3d.dart';
14import 'package:flutter3d_physics/flutter3d_physics.dart';
15import 'package:flutter3d_showcase/src/demo/demo.dart';
16import 'package:vector_math/vector_math.dart';
18final class CharacterControllerDemo extends ShowcaseDemo {
19 late final CollisionWorld _world;
20 late final CharacterController _controller;
21 late final ModelAsset _asset;
22 ModelInstance? _model;
24 static const double _step = 1 / 60;
25 static const int _steps = 90;
27 /// `RobotExpressive.glb` stands this tall in its own file — measured once,
28 /// off the mesh it ships with — so [_characterHeight] can scale it to the
29 /// collider's own box without carrying a decoded model around just to ask.
30 static const double _modelHeight = 4.461221901699901;
32 /// What the collider's box already claimed: `CharacterController`'s
33 /// default shape is `CollisionBox(Vector3(0.35, 0.9, 0.35))`, 1.8 m tall.
34 static const double _characterHeight = 1.8;
36 @override
37 void configureView(DemoContext context) {
38 // Distance and target are set again in `build`, once the walk has run
39 // and there is somewhere to actually point the camera at.
40 context.orbit
41 ..pitch = 0.2
42 ..yaw = 0.6;
43 }
45 @override
46 Future<void> prepare(DemoContext context) async {
47 // CC0 — Tomás Laulhé, with facial morph targets by Don McCurdy; see
48 // `packages/flutter3d_samples/assets/ATTRIBUTION.md`. Fourteen clips ship
49 // in the one file, and "Walking" is the one this page plays.
50 final document = await loadModelByPath(
51 'packages/flutter3d_samples/assets/RobotExpressive.glb',
52 );
53 _asset = await ModelAsset.fromDocument(document, device: context.device);
54 }
56 @override
57 Scene build(DemoContext context) {
58 _world = CollisionWorld();
59 _world.addBox(Vector3(0.0, -0.5, 0.0), Vector3(20.0, 1.0, 20.0));
61 _controller = CharacterController(
62 world: _world,
63 position: Vector3(0.0, 3.0, 0.0),
64 );
66 // Stepped by hand, at a fixed rate, so the same run always lands the
67 // same way. A real game calls this once a frame with the frame's own
68 // wish direction; a page rendered once has to do all its steps up front.
69 for (var i = 0; i < _steps; i++) {
70 _controller.step(_step, wishDirection: Vector3(1.0, 0.0, 0.0));
71 }
73 // The walk covers several metres of +x, and `configureView` runs before
74 // any of it — orbiting the origin left the walker's actual resting place
75 // well outside the frame. This page draws a single, already-landed
76 // frame rather than the walk itself, so what needs framing is where the
77 // walker actually ended up, not where it started.
78 context.orbit
79 ..target.setValues(_controller.position.x, 0.9, 0.0)
80 ..distance = 5.0
81 ..apply();
83 final Scene scene = Scene()
84 ..add(
85 MeshNode(
86 DeviceMesh.upload(
87 context.device,
88 CuboidShape(size: Vector3(20.0, 1.0, 20.0)).build(),
89 ),
90 Material(name: 'floor', baseColor: Vector4(0.5, 0.55, 0.5, 1.0)),
91 name: 'floor',
92 )..setPosition(0.0, -0.5, 0.0),
93 )
94 ..add(
95 LightNode(name: 'sun', intensity: 2.5)
96 ..setLocalForward(Vector3(-0.3, -0.6, -0.4)),
97 );
99 // The model's own origin sits at its feet, not its centre, and it faces
100 // +Z at rest; turned a quarter-turn around Y so it faces the +X the walk
101 // above actually carried it towards, then dropped onto the box's own
102 // centre minus half its height, the ground the box already stands on.
103 final ModelInstance model = _model = _asset.instantiate(
104 scene,
105 name: 'walker',
106 );
107 final double scale = _characterHeight / _modelHeight;
108 model.root
109 ..setScale(scale, scale, scale)
110 ..setRotation(Quaternion.axisAngle(Vector3(0.0, 1.0, 0.0), -math.pi / 2));
111 model.player?.playNamed('Walking');
113 _placeModel();
114 return scene;
115 }
117 void _placeModel() {
118 final ModelInstance? model = _model;
119 if (model == null) return;
120 final Vector3 position = _controller.position;
121 final double feetY = position.y - _controller.halfExtents.y;
122 model.root.setPosition(position.x, feetY, position.z);
123 }
125 @override
126 void update(DemoContext context, double dt) {
127 _placeModel();
128 _model?.player?.update(dt);
129 }
131 @override
132 void verify(Scene scene, FrameResult frame) {
133 if (!_controller.isGrounded) {
134 throw StateError('the walker should have landed by now');
135 }
136 if (_controller.groundNormal.y < 0.99) {
137 throw StateError('a flat floor should read as a flat ground normal');
138 }
139 if (_controller.position.x <= 0.0) {
140 throw StateError('the walker should have moved towards +x');
141 }
142 if (_model == null || _model!.meshes.isEmpty) {
143 throw StateError('the model did not reach the scene');
144 }
145 if (frame.drawCalls < 1) {
146 throw StateError('the walker did not reach the frame');
147 }
148 }