flutter3d
Showcase Changelog 38 packages API reference

Walking on terrain: the source

since 0.5.1 Physics and particles

Regions the guide quotes: field (line 52), walk (line 63), patrol (line 139), check (line 156).

1/// A character controller falling onto sloped ground and walking across it,
2/// its feet tracking a surface that is not flat.
3///
4/// Quoted by `heightfield_collision.md` and shown whole in the Source tab.
5library;
6
7import 'dart:math' as math;
8import 'dart:typed_data';
9
10import 'package:flutter3d/flutter3d.dart';
11import 'package:flutter3d_physics/flutter3d_physics.dart';
12import 'package:flutter3d_showcase/src/demo/demo.dart';
13import 'package:vector_math/vector_math.dart';
15final class HeightfieldCollisionDemo extends ShowcaseDemo {
16 late final CollisionWorld _world;
17 late final CollisionHeightfield _field;
18 late final CharacterController _controller;
19 late final MeshNode _body;
21 static const int _columns = 25;
22 static const int _rows = 25;
23 static const double _cellSize = 1.0;
24 static const double _step = 1 / 60;
25 static const int _steps = 60;
27 /// Rolling hills: a few metres of wavelength and about half a metre of
28 /// height, gentle enough to walk everywhere.
29 Float32List _heights() => Float32List.fromList(<double>[
30 for (var row = 0; row < _rows; row++)
31 for (var col = 0; col < _columns; col++)
32 0.55 * math.sin(col * 0.45) * math.cos(row * 0.38) +
33 0.25 * math.sin(col * 0.9 + row * 0.7),
34 ]);
36 /// Where the walker is heading, on a circle round the middle of the field.
37 static const double _ring = 6.0;
39 @override
40 void configureView(DemoContext context) {
41 context.orbit
42 ..distance = 7.0
43 ..pitch = 0.35
44 ..yaw = 0.6;
45 }
47 @override
48 Scene build(DemoContext context) {
49 _world = CollisionWorld();
50 final Float32List heights = _heights();
52 // Rolling hills, one height sample to the metre. `CollisionHeightfield`
53 // is the fifth shape and the first that is not one convex solid — it
54 // hands a query the triangles near it.
55 _field = CollisionHeightfield(
56 columns: _columns,
57 rows: _rows,
58 cellSize: _cellSize,
59 heights: heights,
60 );
61 _world.add(Collider(shape: _field, position: Vector3.zero()));
63 _controller = CharacterController(
64 world: _world,
65 position: Vector3(-_ring, 1.6, 0.0),
66 );
67 for (var i = 0; i < _steps; i++) {
68 _controller.step(_step, wishDirection: Vector3(0.0, 0.0, 1.0));
69 }
71 _body = MeshNode(
72 DeviceMesh.upload(
73 context.device,
74 CuboidShape(size: Vector3(0.7, 1.8, 0.7)).build(),
75 ),
76 Material(name: 'walker', baseColor: Vector4(0.8, 0.5, 0.3, 1.0)),
77 name: 'walker',
78 )..setPositionFrom(_controller.position);
80 return Scene()
81 ..ambientColor = Vector3(0.5, 0.55, 0.65)
82 ..ambientIntensity = 0.3
83 ..add(_terrain(context, heights))
84 ..add(_body)
85 ..add(
86 LightNode(name: 'sun', intensity: 2.5)
87 ..setLocalForward(Vector3(-0.3, -0.6, -0.4)),
88 );
89 }
91 /// A visual grid matching [heights], for the same reason the collision
92 /// shape is: physics and rendering are separate packages here, so nothing
93 /// draws a `CollisionShape` for you.
94 MeshNode _terrain(DemoContext context, Float32List heights) {
95 final MeshBuilder builder = MeshBuilder(VertexLayout.standard);
96 final double width = (_columns - 1) * _cellSize;
97 final double depth = (_rows - 1) * _cellSize;
98 double at(int col, int row) =>
99 heights[row.clamp(0, _rows - 1) * _columns +
100 col.clamp(0, _columns - 1)];
101 final List<int> indices = <int>[];
102 for (var row = 0; row < _rows; row++) {
103 for (var col = 0; col < _columns; col++) {
104 indices.add(
105 builder.addVertex(
106 position: Vector3(
107 col * _cellSize - width * 0.5,
108 heights[row * _columns + col],
109 row * _cellSize - depth * 0.5,
110 ),
111 // The slope either side of the sample, turned into a normal.
112 normal: Vector3(
113 at(col - 1, row) - at(col + 1, row),
114 2.0 * _cellSize,
115 at(col, row - 1) - at(col, row + 1),
116 )..normalize(),
117 ),
118 );
119 }
120 }
121 for (var row = 0; row + 1 < _rows; row++) {
122 for (var col = 0; col + 1 < _columns; col++) {
123 final int tl = indices[row * _columns + col];
124 final int tr = indices[row * _columns + col + 1];
125 final int bl = indices[(row + 1) * _columns + col];
126 final int br = indices[(row + 1) * _columns + col + 1];
127 builder.addQuad(tl, bl, br, tr);
128 }
129 }
130 return MeshNode(
131 DeviceMesh.upload(context.device, builder.build()),
132 Material(name: 'ground', baseColor: Vector4(0.6, 0.55, 0.4, 1.0)),
133 name: 'ground',
134 );
135 }
137 @override
138 void update(DemoContext context, double dt) {
139 // Chase a point that keeps a little ahead of the walker on the circle;
140 // the controller does the rest, up every hill and down again.
141 final Vector3 at = _controller.position;
142 final double ahead = math.atan2(at.z, at.x) + 0.35;
143 final Vector3 wish = Vector3(
144 _ring * math.cos(ahead) - at.x,
145 0.0,
146 _ring * math.sin(ahead) - at.z,
147 )..normalize();
148 _controller.step(_step, wishDirection: wish);
149 _body.setPositionFrom(_controller.position);
150 context.orbit.target.setFrom(_controller.position);
151 context.orbit.apply();
152 }
154 @override
155 void verify(Scene scene, FrameResult frame) {
156 if (!_controller.isGrounded) {
157 throw StateError('the walker should have landed on the slope');
158 }
159 final Vector3 at = _controller.position;
160 final double ground = _field.heightAt(Vector3.zero(), at.x, at.z);
161 final double feet = at.y - _controller.halfExtents.y;
162 if ((feet - ground).abs() > 0.15) {
163 throw StateError(
164 'the walker\'s feet should track the slope: feet $feet, ground $ground',
165 );
166 }
167 if (at.z <= 0.5) {
168 throw StateError('the walker should have walked across the hills');
169 }
170 if (frame.drawCalls < 2) {
171 throw StateError(
172 'the ground and the walker did not both reach the frame',
173 );
174 }
175 }