flutter3d
Showcase Changelog 38 packages API reference

Baking a lightmap: the source

since 0.4.1 Simulation, audio and XR

Regions the guide quotes: live (line 63), level (line 206), layout (line 217), bake (line 219).

1/// Unwrapping a level's brush faces onto an atlas, and baking the light the
2/// walls throw on each other into it.
3///
4/// Quoted by `lightmap_bake.md` and shown whole in the Source tab.
5library;
6
7import 'dart:async';
8import 'dart:math' as math;
9import 'dart:typed_data';
10import 'dart:ui' as ui;
12import 'package:flutter/widgets.dart';
13import 'package:flutter3d/flutter3d.dart';
14import 'package:flutter3d_showcase/src/demo/demo.dart';
15import 'package:flutter3d_sim/flutter3d_sim.dart';
16import 'package:vector_math/vector_math.dart';
18final class LightmapBakeDemo extends ShowcaseDemo {
19 late final String _report;
21 double lightX = 2.0;
22 double bounces = 1.0;
23 double samples = 8.0;
24 double texels = 2.0;
26 bool _dirty = true;
27 bool _busy = false;
28 ui.Image? _atlas;
29 String _stats = '';
31 @override
32 Scene build(DemoContext context) {
33 _report = _run();
34 final material = Material(
35 name: 'closet',
36 baseColor: Vector4(0.8, 0.8, 0.7, 1.0),
37 );
38 final node = MeshNode(
39 DeviceMesh.upload(context.device, SphereShape(segments: 16).build()),
40 material,
41 );
42 return Scene()
43 ..add(node)
44 ..add(
45 LightNode(name: 'sun', intensity: 3.0)
46 ..setLocalForward(Vector3(-0.4, -1.0, -0.3)),
47 );
48 }
50 @override
51 void update(DemoContext context, double dt) {
52 if (_dirty && !_busy) {
53 _dirty = false;
54 _busy = true;
55 unawaited(_rebake().whenComplete(() => _busy = false));
56 }
57 }
59 /// Bakes the closet again with whatever the sliders say, and turns the
60 /// atlas into a picture: each texel's irradiance, clamped to what a screen
61 /// can show.
62 Future<void> _rebake() async {
63 final Level level = Level(
64 name: 'closet',
65 brushes: <Brush>[
66 Brush(centre: Vector3(2, 0, 2), size: Vector3(4, 0.2, 4)),
67 Brush(centre: Vector3(2, 2, 0), size: Vector3(4, 4, 0.2)),
68 ],
69 lights: <LevelLight>[
70 LevelLight(position: Vector3(lightX, 3, 2), intensity: 6.0),
71 ],
72 );
73 final LightmapLayout layout = LightmapLayout.plan(
74 level,
75 texelsPerMetre: texels,
76 );
77 final Lightmap lightmap = LightmapBaker(
78 bounces: bounces.round(),
79 samples: samples.round(),
80 ).bake(level, layout: layout);
81 // A closet's light is a small number, so the picture is exposed for the
82 // brightest texel in it, the way a photograph would be.
83 var brightest = 1e-6;
84 for (var y = 0; y < lightmap.height; y++) {
85 for (var x = 0; x < lightmap.width; x++) {
86 final Vector3 light = lightmap.irradianceAt(x, y);
87 brightest = math.max(
88 brightest,
89 math.max(light.x, math.max(light.y, light.z)),
90 );
91 }
92 }
93 final Uint8List rgba = Uint8List(lightmap.texelCount * 4);
94 var lit = 0;
95 for (var y = 0; y < lightmap.height; y++) {
96 for (var x = 0; x < lightmap.width; x++) {
97 final Vector3 light = lightmap.irradianceAt(x, y);
98 final int at = (y * lightmap.width + x) * 4;
99 // Square-rooted, since a screen shows most of its range in the darks.
100 rgba[at] = (math.sqrt(light.x / brightest) * 255).round();
101 rgba[at + 1] = (math.sqrt(light.y / brightest) * 255).round();
102 rgba[at + 2] = (math.sqrt(light.z / brightest) * 255).round();
103 rgba[at + 3] = 255;
104 if (light.x + light.y + light.z > 0.0) lit++;
105 }
106 }
107 final Completer<ui.Image> decoded = Completer<ui.Image>();
108 ui.decodeImageFromPixels(
109 rgba,
110 lightmap.width,
111 lightmap.height,
112 ui.PixelFormat.rgba8888,
113 decoded.complete,
114 );
115 final ui.Image image = await decoded.future;
116 _stats =
117 '${layout.faces.length} faces in a ${layout.width}x${layout.height} '
118 'atlas, $lit of ${lightmap.texelCount} texels lit';
119 _atlas = image;
120 }
122 @override
123 Widget? customBody(BuildContext buildContext, DemoContext context) {
124 final ui.Image? atlas = _atlas;
125 return ColoredBox(
126 color: const Color(0xFF14161A),
127 child: Padding(
128 padding: const EdgeInsets.all(24),
129 child: Column(
130 crossAxisAlignment: CrossAxisAlignment.start,
131 children: <Widget>[
132 Text(
133 'the baked atlas: what each texel of the closet receives. $_stats',
134 style: const TextStyle(color: Color(0xFFE8E8EC), fontSize: 15),
135 ),
136 const SizedBox(height: 16),
137 Expanded(
138 child: atlas == null
139 ? const Center(child: Text('baking…'))
140 : Center(
141 child: AspectRatio(
142 aspectRatio: atlas.width / atlas.height,
143 child: RawImage(
144 image: atlas,
145 fit: BoxFit.fill,
146 filterQuality: FilterQuality.none,
147 ),
148 ),
149 ),
150 ),
151 ],
152 ),
153 ),
154 );
155 }
157 @override
158 List<DemoControl> controls(DemoContext context) => <DemoControl>[
159 SliderControl(
160 'Light position',
161 min: 0.4,
162 max: 3.6,
163 value: () => lightX,
164 onChanged: (double v) {
165 lightX = v;
166 _dirty = true;
167 },
168 format: (double v) => '${v.toStringAsFixed(1)} m',
169 ),
170 SliderControl(
171 'Bounces',
172 min: 0,
173 max: 3,
174 value: () => bounces,
175 onChanged: (double v) {
176 bounces = v.roundToDouble();
177 _dirty = true;
178 },
179 format: (double v) => v.round().toString(),
180 ),
181 SliderControl(
182 'Samples per texel',
183 min: 1,
184 max: 32,
185 value: () => samples,
186 onChanged: (double v) {
187 samples = v.roundToDouble();
188 _dirty = true;
189 },
190 format: (double v) => v.round().toString(),
191 ),
192 SliderControl(
193 'Texels per metre',
194 min: 1,
195 max: 8,
196 value: () => texels,
197 onChanged: (double v) {
198 texels = v.roundToDouble();
199 _dirty = true;
200 },
201 format: (double v) => v.round().toString(),
202 ),
203 ];
205 static String _run() {
206 final level = Level(
207 name: 'closet',
208 brushes: <Brush>[
209 Brush(centre: Vector3(2, 0, 2), size: Vector3(4, 0.2, 4)),
210 Brush(centre: Vector3(2, 2, 0), size: Vector3(4, 4, 0.2)),
211 ],
212 lights: <LevelLight>[
213 LevelLight(position: Vector3(2, 3, 2), intensity: 6.0),
214 ],
215 );
217 final layout = LightmapLayout.plan(level, texelsPerMetre: 2.0);
219 const baker = LightmapBaker(bounces: 1, samples: 8);
220 final lightmap = baker.bake(level, layout: layout);
221 final anyLight = lightmap.pixels.any((int byte) => byte > 0);
223 return '${layout.faces.length} faces packed into a '
224 '${layout.width}x${layout.height} atlas\n'
225 'the baked map is ${lightmap.width}x${lightmap.height}, '
226 'and holds some light: $anyLight';
227 }
229 @override
230 void verify(Scene scene, FrameResult frame) {
231 if (frame.drawCalls < 1) {
232 throw StateError('the closet marker was not drawn');
233 }
234 if (!_report.contains('holds some light: true')) {
235 throw StateError(
236 'a room with a light in it should bake a lightmap '
237 'that is not entirely black',
238 );
239 }
240 }