Impostors: the source
Open the live demo · Read the guide · View on GitHub
Regions the guide quotes: tree (line 39), mesh (line 61), upload (line 83), asset (line 97), row (line 156), sun (line 163), light (line 169), mark (line 173), check (line 206), bake (line 219).
1/// A row of trees whose far ones are drawn as octahedral impostors: one card
2/// each, turned to the eye and painted from views baked in advance.
3///
4/// Quoted by `impostors.md` and shown whole in the Source tab.
5library;
7import 'dart:math' as math;
8import 'dart:typed_data';
10import 'package:flutter3d/flutter3d.dart';
11import 'package:flutter3d_showcase/src/demo/demo.dart';
12import 'package:vector_math/vector_math.dart';
14/// A ball of the tree: where it is, how big, and its colour as a paint
15/// program shows it.
16typedef _Ball = ({Vector3 centre, double radius, Vector3 colour});
18/// What a ray met first: how far along it, the surface normal there and the
19/// colour of the part it hit.
20typedef _Hit = ({double t, Vector3 normal, Vector3 colour});
22final class ImpostorsDemo extends ShowcaseDemo {
23 bool markCards = false;
24 double sunYaw = 0.6;
26 late final Scene _scene;
27 late final LightNode _sun;
29 /// Texels along each side of one baked view.
30 static const int _cell = 32;
32 /// How many trees stand in the row.
33 static const int _count = 5;
35 static Vector3 get _bark => Vector3(0.45, 0.3, 0.18);
36 static Vector3 get _leaves => Vector3(0.25, 0.6, 0.2);
37 static Vector3 get _fruit => Vector3(0.85, 0.15, 0.1);
39 static Aabb3 get _trunk =>
40 Aabb3.minMax(Vector3(-0.12, 0.0, -0.12), Vector3(0.12, 1.2, 0.12));
41 static List<_Ball> get _balls => <_Ball>[
42 (centre: Vector3(0.0, 1.75, 0.0), radius: 0.75, colour: _leaves),
43 (centre: Vector3(0.0, 2.4, 0.0), radius: 0.45, colour: _leaves),
44 // One fruit on the +X side, so the tree looks different from each side.
45 (centre: Vector3(0.8, 1.45, 0.0), radius: 0.25, colour: _fruit),
46 ];
49 void configureView(DemoContext context) {
50 context.orbit
51 ..target.setValues(0.0, 1.3, -10.0)
52 ..distance = 11.0
53 ..pitch = 0.12
54 ..yaw = 0.25;
55 }
58 Scene build(DemoContext context) {
59 final GraphicsDevice device = context.device;
61 final MeshData tree = MeshData.merge(<MeshData>[
62 _painted(
63 CuboidShape(
64 size: _trunk.max - _trunk.min,
65 ).build().transformed(Matrix4.translation(_trunk.center)),
66 _bark,
67 ),
68 for (final _Ball ball in _balls)
69 _painted(
70 SphereShape(
71 radius: ball.radius,
72 segments: 24,
73 rings: 12,
74 ).build().transformed(Matrix4.translation(ball.centre)),
75 ball.colour,
76 ),
77 ]);
78 final Vector3 centre = tree.computeBounds().center;
79 // A little over the furthest vertex: a ball's true edge can lie between
80 // the vertices of its mesh.
81 final double radius = _radiusAround(tree, centre) * 1.02;
83 final ({Uint8List albedo, Uint8List normalDepth}) atlas = _bake(
84 centre,
85 radius,
86 );
87 const int side = _cell * kImpostorGrid;
88 TextureHandle upload(Uint8List rgba) => device.createTextureFromPixels(
89 width: side,
90 height: side,
91 format: TextureFormat.r8g8b8a8UNormInt,
92 pixels: ByteData.sublistView(rgba),
93 )!;
94 final TextureHandle albedo = upload(atlas.albedo);
95 final TextureHandle normalDepth = upload(atlas.normalDepth);
97 final ModelImpostor impostor = ModelImpostor(
98 // Indices into a file's images. Nothing is read through them here: the
99 // textures are handed over below.
100 albedoImage: 0,
101 normalDepthImage: 1,
102 grid: kImpostorGrid,
103 centre: centre,
104 radius: radius,
105 );
106 final ModelAsset asset = ModelAsset(
107 name: 'tree',
108 parts: <ModelPart>[
109 ModelPart(
110 mesh: DeviceMesh.upload(device, tree),
111 material: Material(name: 'tree', roughness: 0.8),
112 name: 'tree',
113 ),
114 ],
115 nodes: <ModelNode>[
116 ModelNode(
117 name: 'tree',
118 surfaces: const <int>[0],
119 lods: <ModelLod>[
120 ModelLod.impostor(impostor: impostor, maxScreenFraction: 0.25),
121 ],
122 ),
123 ],
124 roots: const <int>[0],
125 localBounds: tree.computeBounds(),
126 impostors: <ModelImpostor, ImpostorPart>{
127 impostor: (
128 card: DeviceMesh.upload(
129 device,
130 impostorCard(centre: centre, radius: radius),
131 ),
132 albedo: albedo,
133 normalDepth: normalDepth,
134 ),
135 },
136 );
138 _scene = Scene()
139 ..ambientColor = Vector3(0.5, 0.6, 0.75)
140 ..ambientIntensity = 0.25
141 ..add(
142 MeshNode(
143 DeviceMesh.upload(
144 device,
145 const PlaneShape(width: 24, depth: 40).build(),
146 ),
147 Material(
148 name: 'grass',
149 baseColor: Vector4(0.42, 0.5, 0.32, 1.0),
150 roughness: 0.95,
151 ),
152 name: 'ground',
153 )..setPosition(0.0, 0.0, -12.0),
154 );
156 for (var i = 0; i < _count; i++) {
157 asset
158 .instantiate(_scene, name: 'tree $i')
159 .root
160 .setPosition(i.isEven ? -1.5 : 1.5, 0.0, -2.0 - 6.0 * i);
161 }
163 _sun = LightNode(name: 'sun', intensity: 3.0)..castsShadow = true;
164 return _scene..add(_sun);
165 }
168 void update(DemoContext context, double dt) {
169 _sun.setLocalForward(
170 Vector3(math.sin(sunYaw), -0.9, math.cos(sunYaw))..normalize(),
171 );
173 for (final LodGroup group in _scene.lodGroups) {
174 for (final LodLevel level in group.levels) {
175 if (level.node is ImpostorNode) {
176 level.node.material.baseColor.setValues(
177 markCards ? 0.55 : 1.0,
178 markCards ? 0.75 : 1.0,
179 1.0,
180 1.0,
181 );
182 }
183 }
184 }
185 }
188 List<DemoControl> controls(DemoContext context) => <DemoControl>[
189 ToggleControl(
190 'Tint the cards blue',
191 value: () => markCards,
192 onChanged: (bool v) => markCards = v,
193 ),
194 SliderControl(
195 'Sun direction',
196 min: -math.pi,
197 max: math.pi,
198 value: () => sunYaw,
199 onChanged: (double v) => sunYaw = v,
200 format: (double v) => '${(v * 180 / math.pi).round()} deg',
201 ),
202 ];
205 void verify(Scene scene, FrameResult frame) {
206 final List<LodGroup> groups = scene.lodGroups;
207 bool showsCard(LodGroup g) =>
208 g.levels[g.activeLevel].node is ImpostorNode &&
209 g.levels[g.activeLevel].node.visible;
210 if (groups.length != _count ||
211 !groups.any(showsCard) ||
212 !groups.any((LodGroup g) => g.activeLevel == 0) ||
213 frame.drawCalls < 1) {
214 throw StateError('the row should end in cards and start in meshes');
215 }
216 }
217}
219/// Both atlases, [kImpostorGrid] by [kImpostorGrid] views of [ImpostorsDemo._cell]
220/// texels, found by casting one ray a texel at the tree's own shapes.
221///
222/// Each view looks back along [impostorViewDirection] at the sphere of
223/// [radius] around [centre], with [impostorRight] to its right, and the top
224/// row of a view is the side its up axis points to: the layout the impostor
225/// shader reads.
226({Uint8List albedo, Uint8List normalDepth}) _bake(
227 Vector3 centre,
228 double radius,
229) {
230 const int cell = ImpostorsDemo._cell;
231 const int side = cell * kImpostorGrid;
232 final Uint8List albedo = Uint8List(side * side * 4);
233 final Uint8List normalDepth = Uint8List(side * side * 4);
234 int byte(double v) => (v.clamp(0.0, 1.0) * 255.0).round();
235 final List<_Ball> balls = ImpostorsDemo._balls;
236 final Aabb3 trunk = ImpostorsDemo._trunk;
237 final Vector3 bark = ImpostorsDemo._bark;
238 final Vector3 empty = ImpostorsDemo._leaves;
240 for (var row = 0; row < kImpostorGrid; row++) {
241 for (var column = 0; column < kImpostorGrid; column++) {
242 final Vector3 d = impostorViewDirection(column, row);
243 final Vector3 right = impostorRight(d);
244 final Vector3 up = d.cross(right);
245 for (var y = 0; y < cell; y++) {
246 for (var x = 0; x < cell; x++) {
247 final Vector3 origin =
248 centre +
249 right.scaled(((x + 0.5) / cell * 2.0 - 1.0) * radius) +
250 up.scaled((1.0 - (y + 0.5) / cell * 2.0) * radius) +
251 d.scaled(2.0 * radius);
252 final _Hit? hit = _cast(origin, -d, balls, trunk, bark);
253 final int to = ((row * cell + y) * side + column * cell + x) * 4;
254 // An empty texel still gets a leaf colour, with no coverage, so a
255 // filtered read at the edge of the tree does not mix in black.
256 final Vector3 colour = hit?.colour ?? empty;
257 albedo
258 ..[to] = byte(colour.x)
259 ..[to + 1] = byte(colour.y)
260 ..[to + 2] = byte(colour.z)
261 ..[to + 3] = hit == null ? 0 : 255;
262 if (hit == null) continue;
263 final Vector3 point = origin + (-d).scaled(hit.t);
264 // How far along the view the surface is: 0 at the near side of the
265 // sphere, 1 at the far side.
266 final double depth = 0.5 - (point - centre).dot(d) / (2.0 * radius);
267 normalDepth
268 ..[to] = byte(hit.normal.x * 0.5 + 0.5)
269 ..[to + 1] = byte(hit.normal.y * 0.5 + 0.5)
270 ..[to + 2] = byte(hit.normal.z * 0.5 + 0.5)
271 ..[to + 3] = byte(depth);
272 }
273 }
274 }
275 }
276 return (albedo: albedo, normalDepth: normalDepth);
277}
279/// The nearest of the trunk and the balls along the ray from [origin] in
280/// [direction], or null when it misses them all.
281_Hit? _cast(
282 Vector3 origin,
283 Vector3 direction,
284 List<_Ball> balls,
285 Aabb3 box,
286 Vector3 bark,
287) {
288 _Hit? nearest;
289 for (final _Ball ball in balls) {
290 final Vector3 oc = origin - ball.centre;
291 final double b = oc.dot(direction);
292 final double disc = b * b - (oc.dot(oc) - ball.radius * ball.radius);
293 if (disc < 0.0) continue;
294 final double t = -b - math.sqrt(disc);
295 if (t < 0.0 || (nearest != null && t >= nearest.t)) continue;
296 final Vector3 at = origin + direction.scaled(t);
297 nearest = (
298 t: t,
299 normal: (at - ball.centre)..scale(1.0 / ball.radius),
300 colour: ball.colour,
301 );
302 }
304 // The trunk, by the slab test: the ray enters the box where it has
305 // crossed the near face of all three pairs of faces.
306 var enter = double.negativeInfinity;
307 var leave = double.infinity;
308 var axis = -1;
309 for (var i = 0; i < 3; i++) {
310 if (direction[i].abs() < 1e-9) {
311 if (origin[i] < box.min[i] || origin[i] > box.max[i]) return nearest;
312 continue;
313 }
314 final double a = (box.min[i] - origin[i]) / direction[i];
315 final double b = (box.max[i] - origin[i]) / direction[i];
316 final double near = math.min(a, b);
317 if (near > enter) {
318 enter = near;
319 axis = i;
320 }
321 leave = math.min(leave, math.max(a, b));
322 }
323 if (axis < 0 || enter > leave || enter < 0.0) return nearest;
324 if (nearest != null && enter >= nearest.t) return nearest;
325 final Vector3 normal = Vector3.zero()
326 ..[axis] = direction[axis] > 0.0 ? -1.0 : 1.0;
327 return (t: enter, normal: normal, colour: bark);
328}
330/// [mesh] with every vertex colour set to [srgb], converted to the linear
331/// value a vertex colour is authored in.
332MeshData _painted(MeshData mesh, Vector3 srgb) {
333 double linear(double c) =>
334 c <= 0.04045 ? c / 12.92 : math.pow((c + 0.055) / 1.055, 2.4).toDouble();
335 final int stride = mesh.layout.floatsPerVertex;
336 final int at = mesh.layout.floatOffsetOf(VertexLayout.color.name);
337 final Float32List vertices = Float32List.fromList(mesh.vertices);
338 for (var v = 0; v < mesh.vertexCount; v++) {
339 vertices[v * stride + at] = linear(srgb.x);
340 vertices[v * stride + at + 1] = linear(srgb.y);
341 vertices[v * stride + at + 2] = linear(srgb.z);
342 }
343 return MeshData(
344 layout: mesh.layout,
345 vertices: vertices,
346 indices: mesh.indices,
347 );
348}
350/// The furthest any vertex of [mesh] is from [centre].
351double _radiusAround(MeshData mesh, Vector3 centre) {
352 final int stride = mesh.layout.floatsPerVertex;
353 final int at = mesh.layout.floatOffsetOf(VertexLayout.position.name);
354 return Iterable<int>.generate(mesh.vertexCount).fold(0.0, (
355 double furthest,
356 int v,
357 ) {
358 final int o = v * stride + at;
359 return math.max(
360 furthest,
361 Vector3(
362 mesh.vertices[o] - centre.x,
363 mesh.vertices[o + 1] - centre.y,
364 mesh.vertices[o + 2] - centre.z,
365 ).length,
366 );
367 });
368}