@woosh/meep-engine 3.11.0 → 3.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/src/shade/playground/volumetrics_froxel/README.md +88 -0
  3. package/src/shade/playground/volumetrics_froxel/index.html +251 -0
  4. package/src/shade/playground/volumetrics_froxel/main.d.ts +2 -0
  5. package/src/shade/playground/volumetrics_froxel/main.d.ts.map +1 -0
  6. package/src/shade/playground/volumetrics_froxel/main.js +619 -0
  7. package/src/shade/renderer/Renderer.d.ts +13 -0
  8. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  9. package/src/shade/renderer/Renderer.js +23 -0
  10. package/src/shade/renderer/volumetrics/NOTES.md +42 -1
  11. package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.d.ts +21 -0
  12. package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.d.ts.map +1 -0
  13. package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.js +47 -0
  14. package/src/shade/renderer/volumetrics/chunk_integrate_optical_depth.d.ts.map +1 -1
  15. package/src/shade/renderer/volumetrics/chunk_integrate_optical_depth.js +19 -8
  16. package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.d.ts +37 -0
  17. package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.d.ts.map +1 -0
  18. package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.js +56 -0
  19. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
  20. package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +6 -0
  21. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts.map +1 -1
  22. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.js +367 -324
  23. package/src/shade/renderer/volumetrics/shader_volumetrics_build_scattering_lut.d.ts.map +1 -1
  24. package/src/shade/renderer/volumetrics/shader_volumetrics_build_scattering_lut.js +14 -3
  25. package/src/shade/renderer/volumetrics/volumetrics_position_world_to_froxel_uvw.d.ts.map +1 -1
  26. package/src/shade/renderer/volumetrics/volumetrics_position_world_to_froxel_uvw.js +48 -36
@@ -0,0 +1,619 @@
1
+ import Quaternion from "../../../core/geom/Quaternion.js";
2
+ import Vector3 from "../../../core/geom/Vector3.js";
3
+ import { DEG_TO_RAD } from "../../../core/math/DEG_TO_RAD.js";
4
+ import { MIE_PARTICLES_STANDARD_PRECOMPUTED } from "../../../core/math/physics/mie/MIE_PARTICLES_STANDARD_PRECOMPUTED.js";
5
+ import { OrbitalCameraController } from "../../renderer/camera/OrbitalCameraController.js";
6
+ import { PerspectiveCamera } from "../../renderer/camera/PerspectiveCamera.js";
7
+ import { create_frame_loop } from "../../renderer/create_frame_loop.js";
8
+ import { make_sunlight } from "../../renderer/light/make_sunlight.js";
9
+ import { load_gltf } from "../../renderer/loader/gltf/load_gltf.js";
10
+ import { Renderer } from "../../renderer/Renderer.js";
11
+ import { Scene } from "../../renderer/scene/Scene.js";
12
+ import {
13
+ ParticipatingMediaVolume,
14
+ VolumetricsParticleSpec
15
+ } from "../../renderer/volumetrics/ParticipatingMediaVolume.js";
16
+ import { load_default_environment } from "../load_default_environment.js";
17
+ import { make_playground_asset_manager } from "../make_playground_asset_manager.js";
18
+
19
+ /**
20
+ * The froxel volumetrics pass, with the controls that make its known defects visible.
21
+ *
22
+ * `shader_volumetrics_build_lighting` and the passes around it were reviewed statically and five
23
+ * correctness findings came out of it (C-01 … C-05). Four are geometric, and geometry is the one
24
+ * class of defect a unit test can prove but not show: an emulator can assert that a sample lands at
25
+ * the wrong depth, and it still cannot tell you whether that reads as a smear, a seam, or nothing
26
+ * at all. This page exists so each fix can be looked at before and after.
27
+ *
28
+ * Every control below is aimed at one finding. The panel says which.
29
+ *
30
+ * Runs under the dev server (`npm run dev`), which serves `test_assets/` and resolves the one bare
31
+ * specifier the renderer reaches for. The panel is hand-rolled for the same reason the Sponza page
32
+ * hand-rolls its own — a dozen inputs are not worth a dependency the library does not otherwise
33
+ * have.
34
+ *
35
+ * @see src/shade/renderer/volumetrics/NOTES.md
36
+ */
37
+
38
+ /**
39
+ * Served out of `test_assets/`, which is gitignored and unpublished — `test_assets/README.md` has
40
+ * how it gets there.
41
+ *
42
+ * glTF rather than the `.shade` scene sitting next to it: that export's header reads format
43
+ * version 1 and `deserialize_scene` accepts version 2 only, deliberately and with no backward
44
+ * compatibility. Re-exporting it is the fix if the `.shade` is ever wanted.
45
+ *
46
+ * @type {string}
47
+ */
48
+ const SCENE_URL = '/test_assets/flying_world_-_battle_of_the_trash_god/v2/scene.gltf';
49
+
50
+ /**
51
+ * A low sun, which is what the volumetrics want: it rakes the scene, so the shafts have something
52
+ * to be cast by, and it sits near the horizon where the sun can be swung in and out of the frustum
53
+ * without also swinging it behind the terrain.
54
+ *
55
+ * @type {string}
56
+ */
57
+ const ENVIRONMENT_URL = '/test_assets/environments/octahedral/venice_sunset_2k.hdr';
58
+
59
+ /**
60
+ * Camera placements carried over from the sibling repo's playground, where they were found by
61
+ * hand while looking at this exact scene. Keeping the numbers rather than re-deriving them means a
62
+ * before/after pair is taken from the same place to the pixel.
63
+ *
64
+ * The `c` field names the finding each one is good for; the panel groups by it.
65
+ */
66
+ const VIEWS = [
67
+ {
68
+ id: 'overview',
69
+ label: 'Dead overview',
70
+ c: 'orientation',
71
+ hint: 'the whole island — start here',
72
+ position: { x: -44.23572420730413, y: 16.787419516572818, z: -0.3908097424888902 },
73
+ rotation: { x: -0.10289617976426199, y: 0.8965686124184902, z: -0.32052909150868936, w: -0.2878162624184363 }
74
+ },
75
+ {
76
+ id: 'vol_0',
77
+ label: 'Fog bank, wide',
78
+ c: 'C-01',
79
+ hint: 'fog fills the frame corners, where the depth error is largest',
80
+ position: { x: -39.165083083628595, y: 9.002002938463146, z: -12.822321096883556 },
81
+ rotation: { x: 0.0461501744849993, y: 0.9414711984407541, z: -0.14428237367052793, w: 0.3011390717750397 }
82
+ },
83
+ {
84
+ id: 'vol_1',
85
+ label: 'Inside the fog',
86
+ c: 'C-01',
87
+ hint: 'camera inside the medium — radial thinning shows against a flat wall of fog',
88
+ position: { x: -57.19543561067503, y: 4.497368033549733, z: -5.431935542965578 },
89
+ rotation: {
90
+ x: 0.0012158994167123557,
91
+ y: 0.9584376528911036,
92
+ z: -0.0040851224490818586,
93
+ w: 0.28527022081489145
94
+ }
95
+ },
96
+ {
97
+ id: 'broadside',
98
+ label: 'Island broadside',
99
+ c: 'C-03',
100
+ hint: 'the island against open sky — the clearest place to swing the sun in and out of frame',
101
+ position: { x: -30, y: 11, z: -5 },
102
+ rotation: {
103
+ x: 0.08308534470573395,
104
+ y: -0.7022085341941733,
105
+ z: 0.08308534470573395,
106
+ w: 0.7022085341941732
107
+ }
108
+ },
109
+ {
110
+ id: 'above',
111
+ label: 'Island from above',
112
+ c: 'C-03',
113
+ hint: 'all six volumes at once, with sky behind them',
114
+ position: { x: -18, y: 26, z: 26 },
115
+ rotation: {
116
+ x: -0.09605984305376475,
117
+ y: 0.8751796663464206,
118
+ z: -0.19438519850844194,
119
+ w: -0.43248982966896177
120
+ }
121
+ },
122
+ {
123
+ id: 'cloud_deck',
124
+ label: 'Inside the cloud deck',
125
+ c: 'C-01',
126
+ hint: 'uniform medium filling the frame — radial thinning reads as a vignette here',
127
+ position: { x: -57, y: -10, z: 20 },
128
+ rotation: { x: 0, y: 0.9999652844073138, z: 0.008332465433472727, w: 0 }
129
+ },
130
+ {
131
+ id: 'aliasing_0',
132
+ label: 'Volume edge',
133
+ c: 'C-02',
134
+ hint: 'a fog volume boundary against geometry — a half-froxel shift moves this edge',
135
+ position: { x: -44.358600521646366, y: 8.13976892720825, z: 2.6707745658793725 },
136
+ rotation: { x: -0.07197787209539422, y: 0.9277035010259875, z: -0.24609398688026018, w: -0.2713358614966388 }
137
+ },
138
+ {
139
+ id: 'aliasing_1',
140
+ label: 'Volume edge, above',
141
+ c: 'C-02',
142
+ hint: 'the same boundary from above, where the slab stack is edge-on',
143
+ position: { x: -36.56903863396318, y: 19.1017887067011, z: 13.881382214134083 },
144
+ rotation: { x: -0.0961072760435774, y: 0.9036695806244697, z: -0.3054532939528032, w: -0.28432897453277794 }
145
+ },
146
+ {
147
+ id: 'inside_out',
148
+ label: 'Under the island',
149
+ c: 'C-05',
150
+ hint: 'thin haze over a dark ground — per-froxel noise is most legible here',
151
+ position: { x: -61.55864473831582, y: 23.7158848700074, z: -19.634434309820563 },
152
+ rotation: { x: 0.27745576215003553, y: 0.0922110339369582, z: -0.026764015593898134, w: 0.9559284035634205 }
153
+ },
154
+ ];
155
+
156
+ /**
157
+ * The participating media, carried over from the sibling repo's setup for this scene. Positions are
158
+ * tuned to the model, so they move with it and not with anything here.
159
+ *
160
+ * `target_extinction` rather than `density`: it normalizes across particle specs, so swapping a
161
+ * preset changes how the medium *looks* without changing how much light it stops.
162
+ *
163
+ * @param {Scene} scene
164
+ */
165
+ function add_participating_media(scene) {
166
+
167
+ /**
168
+ * @param {object} spec
169
+ * @param {number[]} spec.position
170
+ * @param {number[]} spec.scale
171
+ * @param {object} spec.particle from MIE_PARTICLES_STANDARD_PRECOMPUTED
172
+ * @param {number} spec.extinction
173
+ * @param {number} spec.fade
174
+ * @param {number} [spec.yaw_degrees]
175
+ */
176
+ function volume({ position, scale, particle, extinction, fade, yaw_degrees = 0 }) {
177
+ const v = new ParticipatingMediaVolume();
178
+
179
+ v.transform.position.set(...position);
180
+ v.transform.scale.set(...scale);
181
+
182
+ if (yaw_degrees !== 0) {
183
+ v.transform.rotation.fromAxisAngle(Vector3.up, yaw_degrees * DEG_TO_RAD);
184
+ }
185
+
186
+ v.fade_distance = fade;
187
+ v.particle_spec = VolumetricsParticleSpec.fromMeep(particle);
188
+ v.target_extinction = extinction;
189
+
190
+ scene.volumetrics.add(v);
191
+
192
+ return v;
193
+ }
194
+
195
+ const P = MIE_PARTICLES_STANDARD_PRECOMPUTED;
196
+
197
+ // the dense bank along the western edge — the thickest medium in the scene, and the one that
198
+ // fills the frame corners from the `vol_0` view
199
+ volume({
200
+ position: [-55, 4, -5.116],
201
+ scale: [10, 3, 20],
202
+ yaw_degrees: 83,
203
+ particle: P.CLOUD_DROPLET_LARGE,
204
+ extinction: 1,
205
+ fade: 0.5,
206
+ });
207
+
208
+ // graveyard
209
+ volume({
210
+ position: [-51.98019820725889, 7.899311905935347, -15.3228393616169],
211
+ scale: [7, 5, 7],
212
+ particle: P.FOG_DROPLET_SMALL,
213
+ extinction: 0.4,
214
+ fade: 1,
215
+ });
216
+
217
+ // mine
218
+ volume({
219
+ position: [-50.846390607312934, 0.3159949821015605, 17.89429433940319],
220
+ scale: [10, 7, 10],
221
+ particle: P.COARSE_DUST_MEDIUM,
222
+ extinction: 0.4,
223
+ fade: 4,
224
+ });
225
+
226
+ // large crystal
227
+ volume({
228
+ position: [-53.41918585295826, 17.333878151116526, 29.90844167706601],
229
+ scale: [18, 10, 18],
230
+ particle: P.MARITIME_HAZE_MEDIUM,
231
+ extinction: 0.1,
232
+ fade: 4,
233
+ });
234
+
235
+ // the low cloud deck the island sits above — big and thin, so it is what a wide FOV sees at the
236
+ // frame edges
237
+ volume({
238
+ position: [-57.36387203979457, -15, 2.607232848593789],
239
+ scale: [400, 20, 400],
240
+ particle: P.CLOUD_DROPLET_LARGE,
241
+ extinction: 0.1,
242
+ fade: 4,
243
+ });
244
+
245
+ // ball cloud overhead
246
+ volume({
247
+ position: [-51.137423384559284, 33, 5.593067265589105],
248
+ scale: [15, 15, 15],
249
+ particle: P.CLOUD_DROPLET_LARGE,
250
+ extinction: 1,
251
+ fade: 7,
252
+ });
253
+ }
254
+
255
+ /**
256
+ * A rolling mean frame time, so the readout is legible rather than jittering.
257
+ */
258
+ class FrameTimer {
259
+ #samples = new Float64Array(60);
260
+ #count = 0;
261
+ #cursor = 0;
262
+
263
+ add(seconds) {
264
+ this.#samples[this.#cursor] = seconds * 1000;
265
+ this.#cursor = (this.#cursor + 1) % this.#samples.length;
266
+ this.#count = Math.min(this.#count + 1, this.#samples.length);
267
+ }
268
+
269
+ reset() {
270
+ this.#count = 0;
271
+ this.#cursor = 0;
272
+ }
273
+
274
+ get mean_ms() {
275
+ if (this.#count === 0) {
276
+ return 0;
277
+ }
278
+
279
+ let total = 0;
280
+ for (let i = 0; i < this.#count; i++) {
281
+ total += this.#samples[i];
282
+ }
283
+ return total / this.#count;
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Point a directional light along a heading, in place.
289
+ *
290
+ * The sun's azimuth is the control C-03 turns on: the shadow ray is clipped against a frustum whose
291
+ * far plane is degenerate, so a sun direction that lies inside the view cone never exits it. Swing
292
+ * the sun through the frame and the media self-shadowing should be steady; if it switches on as the
293
+ * sun leaves the frustum, that is the finding.
294
+ *
295
+ * @param {DirectionalLight} light
296
+ * @param {number} azimuth_degrees 0 points along +X
297
+ * @param {number} elevation_degrees above the horizon
298
+ */
299
+ function aim_light(light, azimuth_degrees, elevation_degrees) {
300
+ const azimuth = azimuth_degrees * DEG_TO_RAD;
301
+ const elevation = elevation_degrees * DEG_TO_RAD;
302
+
303
+ const horizontal = Math.cos(elevation);
304
+
305
+ // direction the light travels — downward for a sun above the horizon
306
+ const direction = new Vector3(
307
+ Math.cos(azimuth) * horizontal,
308
+ -Math.sin(elevation),
309
+ Math.sin(azimuth) * horizontal
310
+ ).normalize();
311
+
312
+ const q = new Quaternion();
313
+ q.fromUnitVectors(Vector3.forward, direction);
314
+ q.normalize();
315
+
316
+ light.transform_local.setRotation(q.x, q.y, q.z, q.w);
317
+ light.updateMatrices();
318
+ }
319
+
320
+ export async function start() {
321
+
322
+ const element = id => document.getElementById(id);
323
+
324
+ const status_element = element('status');
325
+ const stats_element = element('stats');
326
+
327
+ function set_status(text, kind = '') {
328
+ status_element.textContent = text;
329
+ status_element.className = kind;
330
+ }
331
+
332
+ const canvas = element('view');
333
+
334
+ const context = canvas.getContext('webgpu');
335
+
336
+ if (context === null) {
337
+ set_status('no WebGPU context — this browser has none, or the page is not a secure context', 'fail');
338
+ return;
339
+ }
340
+
341
+ const renderer = new Renderer();
342
+
343
+ set_status('acquiring a device…');
344
+
345
+ await renderer.initialize({ context });
346
+
347
+ const camera = new PerspectiveCamera();
348
+
349
+ camera.near = 0.1;
350
+ camera.far = 120;
351
+ camera.fov = 60 * DEG_TO_RAD;
352
+ // aspect is set by the frame loop, which is the first place the viewport has a size
353
+
354
+ const scene = new Scene();
355
+
356
+ const controller = new OrbitalCameraController(camera, document.body);
357
+
358
+ // handy from the console
359
+ globalThis.renderer = renderer;
360
+ globalThis.scene = scene;
361
+ globalThis.camera = camera;
362
+ globalThis.controller = controller;
363
+
364
+ // ---- scene ----------------------------------------------------------------------------
365
+
366
+ set_status(`loading ${SCENE_URL} …`);
367
+
368
+ try {
369
+ const bundle = await load_gltf(SCENE_URL, { assetManager: await make_playground_asset_manager() });
370
+
371
+ scene.add(bundle.scenes);
372
+ } catch (error) {
373
+ set_status(
374
+ `could not load the scene (${error.message}). Put the assets under test_assets/ — `
375
+ + `see test_assets/README.md.`,
376
+ 'fail'
377
+ );
378
+ console.error(error);
379
+ return;
380
+ }
381
+
382
+ const sun = make_sunlight({ intensity: 0.6, temperature: 5500 });
383
+
384
+ scene.lights.add(sun);
385
+
386
+ add_participating_media(scene);
387
+
388
+ load_default_environment(scene, ENVIRONMENT_URL).then(applied => {
389
+ if (applied) {
390
+ renderer.indicate_view_change();
391
+ }
392
+ });
393
+
394
+ // ---- controls -------------------------------------------------------------------------
395
+
396
+ const frame_timer = new FrameTimer();
397
+
398
+ let paused = false;
399
+
400
+ /** Re-render and drop the frame-time history, since a setting change invalidates it. */
401
+ function repaint() {
402
+ renderer.indicate_view_change();
403
+ frame_timer.reset();
404
+ }
405
+
406
+ // -- views
407
+
408
+ const views_element = element('views');
409
+
410
+ /** @param {object} view */
411
+ function go_to(view) {
412
+ camera.transform.fromJSON({ position: view.position, rotation: view.rotation });
413
+
414
+ // the controller holds its own spherical state and would otherwise snap the camera back on
415
+ // the next update
416
+ controller.from_transform(camera.transform);
417
+
418
+ repaint();
419
+ }
420
+
421
+ for (const view of VIEWS) {
422
+ const button = document.createElement('button');
423
+
424
+ button.className = 'view';
425
+ button.title = view.hint;
426
+ button.innerHTML = `<span class="tag">${view.c}</span>${view.label}`;
427
+ button.addEventListener('click', () => {
428
+ go_to(view);
429
+ set_status(view.hint);
430
+ });
431
+
432
+ views_element.appendChild(button);
433
+ }
434
+
435
+ // -- sun
436
+
437
+ let sun_azimuth = 200;
438
+ let sun_elevation = 12;
439
+
440
+ function update_sun() {
441
+ aim_light(sun, sun_azimuth, sun_elevation);
442
+
443
+ element('sun_azimuth_value').textContent = `${sun_azimuth}°`;
444
+ element('sun_elevation_value').textContent = `${sun_elevation}°`;
445
+
446
+ repaint();
447
+ }
448
+
449
+ element('sun_azimuth').addEventListener('input', event => {
450
+ sun_azimuth = Number(event.target.value);
451
+ update_sun();
452
+ });
453
+
454
+ element('sun_elevation').addEventListener('input', event => {
455
+ sun_elevation = Number(event.target.value);
456
+ update_sun();
457
+ });
458
+
459
+ /**
460
+ * Put the sun directly in front of the camera, or directly behind it, at the current elevation.
461
+ *
462
+ * This is the C-03 A/B reduced to two clicks. "Into view" places the sun's direction inside the
463
+ * frustum's direction cone, which is the condition under which `frustum_clip_ray` returns its
464
+ * 1e20 sentinel.
465
+ *
466
+ * @param {boolean} in_front
467
+ */
468
+ function aim_sun_relative_to_camera(in_front) {
469
+ // Straight off the view matrix rather than by rotating a basis vector through the camera's
470
+ // quaternion by hand: the third row of a view matrix is the camera's backward axis, so this
471
+ // is the renderer's own answer and cannot disagree with what is on screen. The hand-rolled
472
+ // version this replaces had a sign error that put the sun behind the camera on both
473
+ // branches, which is invisible in a screenshot and made the C-03 A/B read as a null result.
474
+ // The matrices are rebuilt during render, so a placement made this tick is not in them yet
475
+ // — reading straight after `go_to` would aim the sun at wherever the camera used to be.
476
+ camera.update();
477
+
478
+ const vm = camera.view_matrix;
479
+
480
+ const forward_x = -vm[2];
481
+ const forward_z = -vm[10];
482
+
483
+ // `aim_light` takes the direction light *travels*, so the sun sits opposite it. Placing the
484
+ // sun on the camera's forward axis therefore means aiming its travel back along that axis.
485
+ const toward_sun_x = in_front ? forward_x : -forward_x;
486
+ const toward_sun_z = in_front ? forward_z : -forward_z;
487
+
488
+ const azimuth = Math.atan2(-toward_sun_z, -toward_sun_x) / DEG_TO_RAD;
489
+
490
+ sun_azimuth = Math.round((azimuth % 360 + 360) % 360);
491
+
492
+ element('sun_azimuth').value = String(sun_azimuth);
493
+
494
+ update_sun();
495
+ }
496
+
497
+ element('sun_into_view').addEventListener('click', () => {
498
+ aim_sun_relative_to_camera(true);
499
+ set_status('sun placed in front of the camera — C-03 predicts media self-shadowing vanishes here', 'warn');
500
+ });
501
+
502
+ element('sun_behind').addEventListener('click', () => {
503
+ aim_sun_relative_to_camera(false);
504
+ set_status('sun placed behind the camera — C-03 predicts media self-shadowing returns here', 'pass');
505
+ });
506
+
507
+ // -- camera
508
+
509
+ element('fov').addEventListener('input', event => {
510
+ const degrees = Number(event.target.value);
511
+
512
+ camera.fov = degrees * DEG_TO_RAD;
513
+
514
+ element('fov_value').textContent = `${degrees}°`;
515
+
516
+ repaint();
517
+ });
518
+
519
+ // -- volumetrics knobs, read by GPUViewVolumetrics on every metadata update
520
+
521
+ element('vol_taa').addEventListener('change', event => {
522
+ globalThis.__VOL_TAA_ENABLED = event.target.checked;
523
+ repaint();
524
+ });
525
+
526
+ element('taa').addEventListener('change', event => {
527
+ renderer.feature_taa_enabled = event.target.checked;
528
+ repaint();
529
+ });
530
+
531
+ // Off by default on this page. Auto exposure keeps adapting for seconds after anything changes,
532
+ // which makes a before/after pair differ by more than most of the fixes being judged here do —
533
+ // it read as a large effect with the wrong sign more than once while this page was being built.
534
+ renderer.feature_automatic_exposure_enabled = false;
535
+
536
+ element('auto_exposure').addEventListener('change', event => {
537
+ renderer.feature_automatic_exposure_enabled = event.target.checked;
538
+ repaint();
539
+ });
540
+
541
+ element('froxel_far').addEventListener('input', event => {
542
+ const value = Number(event.target.value);
543
+
544
+ // the slider's top notch means "no split", which is the engine default
545
+ globalThis.__VOL_FROXEL_FAR = value >= 120 ? Infinity : value;
546
+
547
+ element('froxel_far_value').textContent = value >= 120 ? 'off' : `${value} m`;
548
+
549
+ repaint();
550
+ });
551
+
552
+ element('ms_octaves').addEventListener('input', event => {
553
+ const value = Number(event.target.value);
554
+
555
+ globalThis.__VOL_MS_OCTAVES = value;
556
+
557
+ element('ms_octaves_value').textContent = String(value);
558
+
559
+ repaint();
560
+ });
561
+
562
+ element('pause').addEventListener('click', () => {
563
+ paused = !paused;
564
+ element('pause').textContent = paused ? 'Resume' : 'Pause';
565
+ });
566
+
567
+ element('log_camera').addEventListener('click', () => {
568
+ const t = camera.transform;
569
+
570
+ const json = {
571
+ position: { x: t.position.x, y: t.position.y, z: t.position.z },
572
+ rotation: { x: t.rotation.x, y: t.rotation.y, z: t.rotation.z, w: t.rotation.w }
573
+ };
574
+
575
+ console.log(JSON.stringify(json, null, 4));
576
+ set_status('camera written to the console', 'pass');
577
+ });
578
+
579
+ // ---- start ----------------------------------------------------------------------------
580
+
581
+ update_sun();
582
+ go_to(VIEWS[0]);
583
+
584
+ controller.movement_speed_scale = 4;
585
+
586
+ // the controller's keyboard device listens on body, and only a focused element gets key events
587
+ document.body.focus();
588
+
589
+ set_status(`loaded — ${scene.instances.instances.length} instances, ${scene.volumetrics.volumes.length} media volumes`, 'pass');
590
+
591
+ // ---- frame loop -----------------------------------------------------------------------
592
+
593
+ const FRAME_RATE_LIMIT = 60;
594
+
595
+ create_frame_loop(time_delta_s => {
596
+ const width = window.innerWidth;
597
+ const height = window.innerHeight;
598
+
599
+ renderer.resize(width, height);
600
+ renderer.pixel_ratio = window.devicePixelRatio;
601
+ camera.aspect = width / height;
602
+
603
+ controller.update();
604
+
605
+ if (paused) {
606
+ return;
607
+ }
608
+
609
+ renderer.render(camera, scene, time_delta_s);
610
+
611
+ frame_timer.add(time_delta_s);
612
+
613
+ const mean = frame_timer.mean_ms;
614
+
615
+ stats_element.textContent =
616
+ `${mean.toFixed(1)} ms · ${mean > 0 ? (1000 / mean).toFixed(0) : '0'} fps`
617
+ + ` · fov ${Math.round(camera.fov / DEG_TO_RAD)}°`;
618
+ }, FRAME_RATE_LIMIT);
619
+ }
@@ -48,6 +48,19 @@ export class Renderer {
48
48
  feature_tetrahedron_point_shadows: boolean;
49
49
  feature_bloom_enabled: boolean;
50
50
  feature_automatic_exposure_enabled: boolean;
51
+ set exposure_compensation(arg: number);
52
+ /**
53
+ * Fixed exposure, in f-stops, used when {@link feature_automatic_exposure_enabled} is off.
54
+ *
55
+ * Turning adaptation off without being able to set this leaves the frame at 2 stops, which is
56
+ * black for any scene the eye would otherwise have opened up for — so the flag alone is not
57
+ * usable. Anything comparing two renders wants adaptation off and this pinned: eye adaptation
58
+ * keeps drifting for seconds after a change, which makes two shots of the *same* setting differ
59
+ * by more than the change being judged.
60
+ *
61
+ * @returns {number}
62
+ */
63
+ get exposure_compensation(): number;
51
64
  /**
52
65
  * Virtual texture streaming (opt-in per material via
53
66
  * {@link StandardShadeMaterial#vt_stack}). This flag is the system-wide
@@ -1 +1 @@
1
- {"version":3,"file":"Renderer.d.ts","sourceRoot":"","sources":["../../../../src/shade/renderer/Renderer.js"],"names":[],"mappings":"AA4FA;IAOI,0BAEC;IAED;;OAEG;IACH,0BAAQ;IAER;;;;;;OAMG;IACH,kDAEC;IAED;;OAEG;IACH,kBAAO;IAYP;;;OAGG;IACH,2BAEC;IAaD,0CAGC;IAED,uCAEC;IAED;;;;OAIG;IACH,wBAFU,yBAAyB,CAEoB;IAEvD,6BAA4B;IAC5B,8BAA4B;IAC5B;;;;OAIG;IACH,mCAAkC;IAClC;;;;;;;OAOG;IACH,mCAFU,OAAO,CAEyB;IAC1C,+BAA6B;IAC7B,4CAA0C;IAE1C;;;;;;;OAOG;IACH,0BAFU,OAAO,CAEe;IAQhC;;;;;OAKG;IACH,sCAOC;IAjBD,mCAEC;IAiBD;;;;;;;;;OASG;IACH,6BAFU,OAAO,CAEmB;IAWpC;;;;;OAKG;IACH,8BAA+C;IAE/C;;;;;;;;OAQG;IACH,6BAFU,OAAO,CAEmB;IAEpC;;;;;;;;;;;;;;OAcG;IACH,8BAFU,OAAO,CAEoB;IAErC;;;;;;;;;;OAUG;IACH,qBAFU,OAAO,CAEW;IAU5B;;;;;OAKG;IACH,wBAA0C;IAE1C;;;;;;;;OAQG;IACH,eAFU,UAAU,GAAC,QAAQ,CAEF;IAU3B;;;;OAIG;IACH,gCAAwD;IAExD;;;;;;;;;;;;OAYG;IACH,gBAFU,OAAO,CAEK;IAEtB;;;OAGG;IACH,4BAFU,OAAO,CAEiB;IAKlC;;;OAGG;IACH,yBAEC;IAOD;;;OAGG;IACH,gCAEC;IAcD;;;;;;;;;OASG;IACH,qBAA+B;IAE/B,wBAA+B;IAE/B;;;OAGG;IACH,kBAA4B;IAiB5B;;;OAGG;IACH,2CAeC;IAED;;;OAGG;IACH,wCAEC;IA0BD;;;OAGG;IACH,iCAEC;IAED;;;;OAIG;IACH,2BAEC;IAQD;;;OAGG;IACH,+CAIC;IAED;;;OAGG;IACH,gDAIC;IAwCD;;;OAGG;IACH,6BAcC;IA1BD;;;OAGG;IACH,0BAEC;IA4BD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,iBAFU,oBAAkB,IAAI,CAET;IAEvB;;OAEG;IACH;;;;;;;;;;;;;;OAcG;IACH,2DAEC;IAED;;;;;OAKG;IACH,8CAFa,OAAO,CAInB;IAED;;;OAGG;IACH,uBAHW,UAAU,GACR,MAAM,CAIlB;IAGD,sCAIC;IAeD;;;;OAIG;IACH,iCAFY,gBAAgB,CAW3B;IAED;;;OAGG;IACH,6BAYC;IAED;;;;OAIG;IACH,wCAFa,2BAA2B,CAcvC;IAGD;;;OAGG;IACH,+BAuBC;IAmMD;;;;OAIG;IACH,0BAEC;IAgBD;;;;;;OAMG;IACH,cAFU,iBAAiB,CAEU;IAErC;;;;;;OAMG;IACH,eAgBC;IAiFD;;;;;OAKG;IACH,+EA0NC;IAED;;OAEG;IACH,gBAGC;IAQD,4BAwBC;IAqID;;;;;OAKG;IACH,UAHW,MAAM,KACN,MAAM,QAgBhB;IAoFD,0CAMC;IAED;;;;;;;;;;OAUG;IACH,qEAHW,MAAM,GACJ,OAAO,CAsBnB;IAED;;;;;;;;;;;;;;;OAeG;IACH,8EAJW,MAAM,UACN,iBAAiB,GACf,OAAO,CA62BnB;;CACJ;6BAh5E4B,yBAAyB;0CACZ,gCAAgC;2BAL/C,yCAAyC;6BACvC,4CAA4C;8BAC3C,2CAA2C;4BAc7C,uBAAuB;gCA1BnB,sBAAsB;mBAjDnC,oCAAoC;oBACnC,4BAA4B;kCAuEd,gCAAgC;2BAvCvC,2BAA2B;iCAoCrB,0CAA0C;4CAvB/B,+DAA+D;yBA7BlF,yBAAyB;kCAgDhB,wBAAwB;oBAVtC,0BAA0B;uCAJP,sDAAsD"}
1
+ {"version":3,"file":"Renderer.d.ts","sourceRoot":"","sources":["../../../../src/shade/renderer/Renderer.js"],"names":[],"mappings":"AA4FA;IAOI,0BAEC;IAED;;OAEG;IACH,0BAAQ;IAER;;;;;;OAMG;IACH,kDAEC;IAED;;OAEG;IACH,kBAAO;IAYP;;;OAGG;IACH,2BAEC;IAaD,0CAGC;IAED,uCAEC;IAED;;;;OAIG;IACH,wBAFU,yBAAyB,CAEoB;IAEvD,6BAA4B;IAC5B,8BAA4B;IAC5B;;;;OAIG;IACH,mCAAkC;IAClC;;;;;;;OAOG;IACH,mCAFU,OAAO,CAEyB;IAC1C,+BAA6B;IAC7B,4CAA0C;IAkB1C,uCAKC;IArBD;;;;;;;;;;OAUG;IACH,oCAGC;IASD;;;;;;;OAOG;IACH,0BAFU,OAAO,CAEe;IAQhC;;;;;OAKG;IACH,sCAOC;IAjBD,mCAEC;IAiBD;;;;;;;;;OASG;IACH,6BAFU,OAAO,CAEmB;IAWpC;;;;;OAKG;IACH,8BAA+C;IAE/C;;;;;;;;OAQG;IACH,6BAFU,OAAO,CAEmB;IAEpC;;;;;;;;;;;;;;OAcG;IACH,8BAFU,OAAO,CAEoB;IAErC;;;;;;;;;;OAUG;IACH,qBAFU,OAAO,CAEW;IAU5B;;;;;OAKG;IACH,wBAA0C;IAE1C;;;;;;;;OAQG;IACH,eAFU,UAAU,GAAC,QAAQ,CAEF;IAU3B;;;;OAIG;IACH,gCAAwD;IAExD;;;;;;;;;;;;OAYG;IACH,gBAFU,OAAO,CAEK;IAEtB;;;OAGG;IACH,4BAFU,OAAO,CAEiB;IAKlC;;;OAGG;IACH,yBAEC;IAOD;;;OAGG;IACH,gCAEC;IAcD;;;;;;;;;OASG;IACH,qBAA+B;IAE/B,wBAA+B;IAE/B;;;OAGG;IACH,kBAA4B;IAiB5B;;;OAGG;IACH,2CAeC;IAED;;;OAGG;IACH,wCAEC;IA0BD;;;OAGG;IACH,iCAEC;IAED;;;;OAIG;IACH,2BAEC;IAQD;;;OAGG;IACH,+CAIC;IAED;;;OAGG;IACH,gDAIC;IAwCD;;;OAGG;IACH,6BAcC;IA1BD;;;OAGG;IACH,0BAEC;IA4BD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,iBAFU,oBAAkB,IAAI,CAET;IAEvB;;OAEG;IACH;;;;;;;;;;;;;;OAcG;IACH,2DAEC;IAED;;;;;OAKG;IACH,8CAFa,OAAO,CAInB;IAED;;;OAGG;IACH,uBAHW,UAAU,GACR,MAAM,CAIlB;IAGD,sCAIC;IAeD;;;;OAIG;IACH,iCAFY,gBAAgB,CAW3B;IAED;;;OAGG;IACH,6BAYC;IAED;;;;OAIG;IACH,wCAFa,2BAA2B,CAcvC;IAGD;;;OAGG;IACH,+BAuBC;IAmMD;;;;OAIG;IACH,0BAEC;IAgBD;;;;;;OAMG;IACH,cAFU,iBAAiB,CAEU;IAErC;;;;;;OAMG;IACH,eAgBC;IAiFD;;;;;OAKG;IACH,+EA0NC;IAED;;OAEG;IACH,gBAGC;IAQD,4BAwBC;IAqID;;;;;OAKG;IACH,UAHW,MAAM,KACN,MAAM,QAgBhB;IAoFD,0CAMC;IAED;;;;;;;;;;OAUG;IACH,qEAHW,MAAM,GACJ,OAAO,CAsBnB;IAED;;;;;;;;;;;;;;;OAeG;IACH,8EAJW,MAAM,UACN,iBAAiB,GACf,OAAO,CA62BnB;;CACJ;6BAv6E4B,yBAAyB;0CACZ,gCAAgC;2BAL/C,yCAAyC;6BACvC,4CAA4C;8BAC3C,2CAA2C;4BAc7C,uBAAuB;gCA1BnB,sBAAsB;mBAjDnC,oCAAoC;oBACnC,4BAA4B;kCAuEd,gCAAgC;2BAvCvC,2BAA2B;iCAoCrB,0CAA0C;4CAvB/B,+DAA+D;yBA7BlF,yBAAyB;kCAgDhB,wBAAwB;oBAVtC,0BAA0B;uCAJP,sDAAsD"}
@@ -187,6 +187,29 @@ export class Renderer {
187
187
  feature_bloom_enabled = true;
188
188
  feature_automatic_exposure_enabled = true;
189
189
 
190
+ /**
191
+ * Fixed exposure, in f-stops, used when {@link feature_automatic_exposure_enabled} is off.
192
+ *
193
+ * Turning adaptation off without being able to set this leaves the frame at 2 stops, which is
194
+ * black for any scene the eye would otherwise have opened up for — so the flag alone is not
195
+ * usable. Anything comparing two renders wants adaptation off and this pinned: eye adaptation
196
+ * keeps drifting for seconds after a change, which makes two shots of the *same* setting differ
197
+ * by more than the change being judged.
198
+ *
199
+ * @returns {number}
200
+ */
201
+ get exposure_compensation() {
202
+ // the manager is built in initialize(); before that there is no value to report
203
+ return this.#autoexposure === undefined ? 0 : this.#autoexposure.exposure_compensation;
204
+ }
205
+
206
+ set exposure_compensation(v) {
207
+ assert.isNumber(v, 'exposure_compensation');
208
+ assert.defined(this.#autoexposure, 'autoexposure (set exposure_compensation after initialize)');
209
+
210
+ this.#autoexposure.exposure_compensation = v;
211
+ }
212
+
190
213
  /**
191
214
  * Virtual texture streaming (opt-in per material via
192
215
  * {@link StandardShadeMaterial#vt_stack}). This flag is the system-wide