@woosh/meep-engine 3.11.0 → 3.11.2

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 (86) hide show
  1. package/package.json +1 -1
  2. package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
  3. package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +294 -290
  4. package/src/engine/graphics3/ParticipatingMedia.d.ts +111 -0
  5. package/src/engine/graphics3/ParticipatingMedia.d.ts.map +1 -0
  6. package/src/engine/graphics3/ParticipatingMedia.js +203 -0
  7. package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.d.ts +34 -0
  8. package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.d.ts.map +1 -0
  9. package/src/engine/graphics3/ParticipatingMediaSerializationAdapter.js +73 -0
  10. package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts +87 -0
  11. package/src/engine/graphics3/ParticipatingMediaSystem3.d.ts.map +1 -0
  12. package/src/engine/graphics3/ParticipatingMediaSystem3.js +184 -0
  13. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  14. package/src/shade/playground/volumetrics_froxel/README.md +191 -0
  15. package/src/shade/playground/volumetrics_froxel/index.html +270 -0
  16. package/src/shade/playground/volumetrics_froxel/main.d.ts +2 -0
  17. package/src/shade/playground/volumetrics_froxel/main.d.ts.map +1 -0
  18. package/src/shade/playground/volumetrics_froxel/main.js +917 -0
  19. package/src/shade/playground/volumetrics_froxel/measure.d.ts +119 -0
  20. package/src/shade/playground/volumetrics_froxel/measure.d.ts.map +1 -0
  21. package/src/shade/playground/volumetrics_froxel/measure.js +559 -0
  22. package/src/shade/renderer/Renderer.d.ts +13 -0
  23. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  24. package/src/shade/renderer/Renderer.js +45 -18
  25. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  26. package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.d.ts +14 -13
  27. package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.d.ts.map +1 -1
  28. package/src/shade/renderer/shadow/map/shader/chunk_shadowmap_sample_point2.js +133 -129
  29. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.d.ts.map +1 -1
  30. package/src/shade/renderer/volumetrics/GPUViewVolumetrics.js +480 -315
  31. package/src/shade/renderer/volumetrics/NOTES.md +691 -9
  32. package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.d.ts +5 -0
  33. package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.d.ts.map +1 -1
  34. package/src/shade/renderer/volumetrics/ParticipatingMediaVolume.js +21 -2
  35. package/src/shade/renderer/volumetrics/SceneVolumetrics.d.ts +26 -0
  36. package/src/shade/renderer/volumetrics/SceneVolumetrics.d.ts.map +1 -1
  37. package/src/shade/renderer/volumetrics/SceneVolumetrics.js +64 -21
  38. package/src/shade/renderer/volumetrics/VOLUMETRICS_METADATA_STRUCT.d.ts.map +1 -1
  39. package/src/shade/renderer/volumetrics/VOLUMETRICS_METADATA_STRUCT.js +84 -28
  40. package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.d.ts +98 -0
  41. package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.d.ts.map +1 -0
  42. package/src/shade/renderer/volumetrics/VOLUMETRICS_SUN_SHADOW_RESOLUTION.js +111 -0
  43. package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.d.ts +84 -0
  44. package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.d.ts.map +1 -0
  45. package/src/shade/renderer/volumetrics/build_volumetrics_sun_shadow_transform.js +252 -0
  46. package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.d.ts +21 -0
  47. package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.d.ts.map +1 -0
  48. package/src/shade/renderer/volumetrics/chunk_camera_ray_planar_depth_scale.js +47 -0
  49. package/src/shade/renderer/volumetrics/chunk_integrate_optical_depth.d.ts.map +1 -1
  50. package/src/shade/renderer/volumetrics/chunk_integrate_optical_depth.js +75 -64
  51. package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.d.ts +37 -0
  52. package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.d.ts.map +1 -0
  53. package/src/shade/renderer/volumetrics/chunk_volumetrics_froxel_content_offset.js +56 -0
  54. package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.d.ts +43 -0
  55. package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.d.ts.map +1 -0
  56. package/src/shade/renderer/volumetrics/chunk_volumetrics_local_light_shadow.js +145 -0
  57. package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.d.ts +20 -0
  58. package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.d.ts.map +1 -0
  59. package/src/shade/renderer/volumetrics/chunk_volumetrics_sample_froxel_extinction.js +48 -0
  60. package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.d.ts +38 -0
  61. package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.d.ts.map +1 -0
  62. package/src/shade/renderer/volumetrics/chunk_volumetrics_sun_optical_depth.js +107 -0
  63. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts +4 -1
  64. package/src/shade/renderer/volumetrics/graph_build_volumetrics.d.ts.map +1 -1
  65. package/src/shade/renderer/volumetrics/graph_build_volumetrics.js +577 -465
  66. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.d.ts.map +1 -1
  67. package/src/shade/renderer/volumetrics/shader_volumetrics_build_lighting.js +418 -324
  68. package/src/shade/renderer/volumetrics/shader_volumetrics_build_scattering_lut.d.ts.map +1 -1
  69. package/src/shade/renderer/volumetrics/shader_volumetrics_build_scattering_lut.js +14 -3
  70. package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.d.ts +8 -0
  71. package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.d.ts.map +1 -0
  72. package/src/shade/renderer/volumetrics/shader_volumetrics_build_sun_shadow_volume.js +174 -0
  73. package/src/shade/renderer/volumetrics/volumetrics_position_world_to_froxel_uvw.d.ts.map +1 -1
  74. package/src/shade/renderer/volumetrics/volumetrics_position_world_to_froxel_uvw.js +48 -36
  75. package/src/shade/wgsl/emulator/CPUBitmapData.d.ts +25 -0
  76. package/src/shade/wgsl/emulator/CPUBitmapData.d.ts.map +1 -1
  77. package/src/shade/wgsl/emulator/CPUBitmapData.js +33 -0
  78. package/src/shade/wgsl/emulator/WGSLJavaScriptCompiler.js +2221 -2215
  79. package/src/shade/wgsl/emulator/WGSLLanguageCore.d.ts.map +1 -1
  80. package/src/shade/wgsl/emulator/WGSLLanguageCore.js +1297 -1117
  81. package/src/shade/wgsl/emulator/cpu_texture_gather.d.ts +69 -0
  82. package/src/shade/wgsl/emulator/cpu_texture_gather.d.ts.map +1 -0
  83. package/src/shade/wgsl/emulator/cpu_texture_gather.js +148 -0
  84. package/src/shade/wgsl/emulator/cpu_texture_sample.d.ts +16 -0
  85. package/src/shade/wgsl/emulator/cpu_texture_sample.d.ts.map +1 -1
  86. package/src/shade/wgsl/emulator/cpu_texture_sample.js +176 -172
@@ -0,0 +1,184 @@
1
+ import { assert } from "../../core/assert.js";
2
+ import { isArrayEqualStrict } from "../../core/collection/array/isArrayEqualStrict.js";
3
+ import { ParticipatingMediaVolume } from "../../shade/renderer/volumetrics/ParticipatingMediaVolume.js";
4
+ import { System } from "../ecs/System.js";
5
+ import { Transform } from "../ecs/transform/Transform.js";
6
+ import { ParticipatingMedia } from "./ParticipatingMedia.js";
7
+
8
+ /**
9
+ * Mirrors fog, smoke and cloud entities into Shade's volumetrics.
10
+ *
11
+ * The same shape as {@link LightSystem3}: the ECS says what exists and where, and this keeps the
12
+ * renderer's own objects in step. A {@link ParticipatingMediaVolume} is a unit cube posed by a
13
+ * transform, so an entity's `Transform` is the volume — position, size and orientation with no
14
+ * translation step in between.
15
+ *
16
+ * **The transform is taken as world-space, with no hierarchy applied**, which is what the volume
17
+ * model can express: a volume is an oriented box in the scene and Shade has no parent chain to walk
18
+ * for it. Parenting a fog volume to a moving entity therefore leaves the fog where the child's own
19
+ * transform puts it. The lights are placed the same way, for the same reason.
20
+ *
21
+ * **Changes are polled once a frame rather than subscribed to.** A `Transform` announces itself and
22
+ * nothing else here does — a density tuned by an editor slider, a particle spec swapped wholesale
23
+ * for a different Mie solution — so subscribing would cover one field in three and the other two
24
+ * would still have to be polled. Two paths that have to agree, where one does the whole job. It
25
+ * costs about two dozen float comparisons per volume per frame and a scene holds volumes in the
26
+ * tens, so the comparison is cheaper than the signal plumbing it replaces. The price is latency: a
27
+ * change is visible within a frame rather than instantly.
28
+ *
29
+ * **Nothing is uploaded unless something actually changed.** The GPU table is repacked whole
30
+ * whenever {@link SceneVolumetrics.version} moves, so an unconditional copy every frame would
31
+ * re-upload every volume in the scene forever. The comparison is what decides, and on almost every
32
+ * frame it decides nothing has to happen.
33
+ *
34
+ * @see ParticipatingMedia the component
35
+ * @see SceneVolumetrics what the volumes are handed to
36
+ */
37
+ export class ParticipatingMediaSystem3 extends System {
38
+
39
+ dependencies = [ParticipatingMedia, Transform];
40
+
41
+ /**
42
+ * @type {GraphicsEngine3}
43
+ */
44
+ #graphics;
45
+
46
+ /**
47
+ * @type {Scene}
48
+ */
49
+ #scene;
50
+
51
+ /**
52
+ * @type {Map<number, {component: ParticipatingMedia, transform: Transform, volume: ParticipatingMediaVolume}>}
53
+ */
54
+ #entries = new Map();
55
+
56
+ /**
57
+ * @param {GraphicsEngine3} graphics
58
+ * @param {Scene} scene
59
+ */
60
+ constructor(graphics, scene) {
61
+ super();
62
+
63
+ assert.defined(graphics, 'graphics');
64
+ assert.defined(scene, 'scene');
65
+ assert.equal(scene.isScene, true, 'scene.isScene !== true');
66
+
67
+ this.#graphics = graphics;
68
+ this.#scene = scene;
69
+ }
70
+
71
+ async startup(entityManager) {
72
+ this.entityManager = entityManager;
73
+
74
+ this.#graphics.set_scene(this.#scene);
75
+ }
76
+
77
+ async shutdown(entityManager) {
78
+ for (const entity of Array.from(this.#entries.keys())) {
79
+ this.#drop(entity);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * @param {ParticipatingMedia} component
85
+ * @param {Transform} transform
86
+ * @param {number} entity
87
+ */
88
+ link(component, transform, entity) {
89
+ const volume = new ParticipatingMediaVolume();
90
+
91
+ participating_media_volume_write(volume, component, transform);
92
+
93
+ this.#scene.volumetrics.add(volume);
94
+
95
+ this.#entries.set(entity, { component, transform, volume });
96
+ }
97
+
98
+ /**
99
+ * @param {ParticipatingMedia} component
100
+ * @param {Transform} transform
101
+ * @param {number} entity
102
+ */
103
+ unlink(component, transform, entity) {
104
+ this.#drop(entity);
105
+ }
106
+
107
+ /**
108
+ * @param {number} time_delta_seconds
109
+ */
110
+ update(time_delta_seconds) {
111
+ const volumetrics = this.#scene.volumetrics;
112
+
113
+ for (const entry of this.#entries.values()) {
114
+ const volume = entry.volume;
115
+
116
+ if (participating_media_volume_is_current(volume, entry.component, entry.transform)) {
117
+ continue;
118
+ }
119
+
120
+ participating_media_volume_write(volume, entry.component, entry.transform);
121
+
122
+ volumetrics.invalidate();
123
+ }
124
+ }
125
+
126
+ /**
127
+ * @param {number} entity
128
+ */
129
+ #drop(entity) {
130
+ const entry = this.#entries.get(entity);
131
+
132
+ if (entry === undefined) {
133
+ return;
134
+ }
135
+
136
+ this.#scene.volumetrics.remove(entry.volume);
137
+
138
+ this.#entries.delete(entity);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Copy what the component and its transform say onto the volume Shade will render.
144
+ *
145
+ * The particle spec is **copied, not referenced**: two entities are routinely built from the same
146
+ * blueprint and would otherwise share one spec, so tuning one volume's particle would silently
147
+ * retune the other's.
148
+ *
149
+ * @param {ParticipatingMediaVolume} volume Shade's
150
+ * @param {ParticipatingMedia} component meep's
151
+ * @param {Transform} transform where the box is
152
+ */
153
+ export function participating_media_volume_write(volume, component, transform) {
154
+ volume.density = component.density;
155
+ volume.fade_distance = component.fade_distance;
156
+
157
+ volume.particle_spec.copy(component.particle_spec);
158
+
159
+ volume.transform.copy(transform);
160
+ }
161
+
162
+ /**
163
+ * Whether the volume already says what the component and transform say.
164
+ *
165
+ * True on all but a handful of frames, which is the point of asking: a `false` here costs the whole
166
+ * scene's volumetrics table a trip to the GPU.
167
+ *
168
+ * **The transform is compared by its matrix rather than by `Transform.equals`**, because the matrix
169
+ * is the entire transform the GPU is given — the volume's position, rotation and scale are never
170
+ * read on their own. That makes this exactly the question worth asking, "would the upload differ",
171
+ * and it catches a transform posed by writing its matrix, which comparing position, rotation and
172
+ * scale would not.
173
+ *
174
+ * @param {ParticipatingMediaVolume} volume
175
+ * @param {ParticipatingMedia} component
176
+ * @param {Transform} transform
177
+ * @returns {boolean}
178
+ */
179
+ export function participating_media_volume_is_current(volume, component, transform) {
180
+ return volume.density === component.density
181
+ && volume.fade_distance === component.fade_distance
182
+ && isArrayEqualStrict(volume.transform.matrix, transform.matrix)
183
+ && volume.particle_spec.equals(component.particle_spec);
184
+ }
@@ -64,11 +64,11 @@ export class FluidObstacleSystem extends System<any> {
64
64
  /**
65
65
  * @param {FluidComponent} fluid
66
66
  */
67
- static "__#354@#refresh_masks"(fluid: FluidComponent): void;
67
+ static "__#356@#refresh_masks"(fluid: FluidComponent): void;
68
68
  /**
69
69
  * @param {FluidComponent} fluid
70
70
  */
71
- static "__#354@#clear_field"(fluid: FluidComponent): void;
71
+ static "__#356@#clear_field"(fluid: FluidComponent): void;
72
72
  /**
73
73
  * Mark every cell of `fluid` whose centre lies within `inflation` of the
74
74
  * posed shape as solid. Iteration is clipped to the shape's world AABB
@@ -80,7 +80,7 @@ export class FluidObstacleSystem extends System<any> {
80
80
  * @param {number} inflation world-units SDF threshold
81
81
  * @param {Float64Array} point length-3 scratch
82
82
  */
83
- static "__#354@#voxelize"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array): void;
83
+ static "__#356@#voxelize"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array): void;
84
84
  /**
85
85
  * Write the obstacle's translation velocity onto every face of every cell
86
86
  * it voxelized — the moving-wall boundary condition. Runs AFTER the mask
@@ -100,7 +100,7 @@ export class FluidObstacleSystem extends System<any> {
100
100
  * @param {number} wvy
101
101
  * @param {number} wvz
102
102
  */
103
- static "__#354@#stamp_wall_velocity"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array, wvx: number, wvy: number, wvz: number): void;
103
+ static "__#356@#stamp_wall_velocity"(fluid: FluidComponent, posed: PosedShape3D, aabb: Float64Array, inflation: number, point: Float64Array, wvx: number, wvy: number, wvz: number): void;
104
104
  constructor();
105
105
  dependencies: (typeof FluidObstacle)[];
106
106
  components_used: (ResourceAccessSpecification<typeof Transform> | ResourceAccessSpecification<typeof RigidBody> | ResourceAccessSpecification<typeof Collider> | ResourceAccessSpecification<typeof FluidComponent> | ResourceAccessSpecification<typeof FluidObstacle>)[];
@@ -0,0 +1,191 @@
1
+ # volumetrics_froxel
2
+
3
+ Real participating media, a movable sun and a wide-FOV camera in front of the froxel volumetrics
4
+ pass.
5
+
6
+ ```bash
7
+ npm run dev --workspace @woosh/meep-engine
8
+ ```
9
+
10
+ then open <http://localhost:5173/src/shade/playground/volumetrics_froxel/index.html>.
11
+
12
+ ## Why it exists
13
+
14
+ `shader_volumetrics_build_lighting` and the passes around it were reviewed against current practice.
15
+ Five correctness findings came out of it; four were fixed and one was closed as a documented
16
+ non-issue. All five were geometric, and geometry is the class of defect a unit test can prove but
17
+ not show — an emulator can assert that a sample lands at the wrong depth and still not tell you
18
+ whether that reads as a smear, a seam, or nothing at all. This page was where that got decided.
19
+
20
+ | finding | what it was | where the fix lives |
21
+ |---|---|---|
22
+ | C-01a | Planar view depth walked as a distance along a unit ray, so the froxel sample fell short by cos θ | `chunk_camera_ray_planar_depth_scale`, from the lighting pass |
23
+ | C-01b | The aerial pass integrated Beer's law over a planar depth difference rather than arc length | same chunk, from the aerial pass — this was the visible half |
24
+ | C-02 | Cell-averaged grids read with the boundary-sampled convention, a ~0.53-froxel bias | `chunk_volumetrics_froxel_content_offset` |
25
+ | C-03 | Shadow ray clipped against a frustum with no far plane, so a sun anywhere in the view cone got zero optical depth | `chunk_integrate_optical_depth` takes the metadata frustum |
26
+ | C-04 | The shadow march addressed the froxel grid with the aerial LUT's depth curve | `volumetrics_position_world_to_froxel_uvw` |
27
+ | C-05 | Adjacent columns carry independent jitter, so an XY fetch mixes depths | closed — zero-mean and TAA-absorbable; `volumetrics/NOTES.md` has the measurements and why the obvious fix makes it worse |
28
+
29
+ Those fixes are in the permanent path now and there is nothing left to A/B. What the page is for is
30
+ the *next* change here — the performance findings especially, since every one of them trades image
31
+ quality for time and none can be judged from a screenshot.
32
+
33
+ Q-01 was judged here and now ships unconditionally: every clustered point and spot light is occluded
34
+ by geometry, sampled with one nearest tap out of the shadow atlas. The two checkboxes that switched
35
+ that off, or swapped the tap for the deferred pass's filter kernels, are gone along with the metadata
36
+ word behind them — `volumetrics/NOTES.md` keeps the measurements that closed the question. The
37
+ torches are the local lights here: 130 point lights, every one of them a shadow caster, and **zero
38
+ spot lights**, so the spot half has no on-screen coverage on this page and is covered by
39
+ `shader_volumetrics_build_lighting_local_shadow.spec.js` instead. Adding a `SpotLight` from the
40
+ console is the way to look at it.
41
+
42
+ Q-04 went the same way and its switch is gone too. The sun's media self-shadowing is one fetch into
43
+ a ladder of three cascaded volumes built once a frame with their third axis on the sun, and that is
44
+ now the only path for it: the `__VOL_SUN_SHADOW` global and the checkbox over it are removed along
45
+ with the `sun_shadow_mode` word they drove, so nothing on this page reproduces the march/cascade
46
+ comparison any more. Restoring it means re-adding that word and the branch in
47
+ `shader_volumetrics_build_lighting` that reads it.
48
+
49
+ Worth knowing what it was, because it is the size of what the page can no longer show. Against a
50
+ march at `STEP_COUNT = 512` the cascades read within 1.2 mean luminance at every viewpoint here and
51
+ across 40 to 110 degrees of field of view, while sixteen steps read **+58** and **+70** at the two
52
+ poses beside the small fog boxes — the step there is eight units, the box is three thick, and the
53
+ march goes straight over it. They are also cheaper: 0.37 ms against 0.53 at 1080p, 0.81 against 1.52
54
+ at 2160p, per pass. `volumetrics/NOTES.md` has the whole measurement, including the one that says a
55
+ *single* world-uniform box could not have been sized correctly at any resolution — every shape
56
+ measured has a field of view at which it is 28 to 50 luminance from the truth, and which one that is
57
+ moves with the resolution.
58
+
59
+ The march itself is still in the tree and still runs: every point and spot light uses it, and so
60
+ would a second directional light. Only the first one is amortized, because there is one ladder of
61
+ boxes and it is aligned to one direction.
62
+
63
+ ## Measuring, rather than eyeballing
64
+
65
+ This pass is stochastic and temporally filtered, so most changes to it move the image by less than
66
+ the frame-to-frame noise. A screenshot pair is not evidence. Four traps, each of which produced a
67
+ confident wrong answer during the work above:
68
+
69
+ - **Auto exposure.** It keeps adapting for seconds after any change, so two captures of the
70
+ *identical* setting differed in mean luminance by more than the change being judged. Off by
71
+ default here; `renderer.exposure_compensation` sets the fixed value.
72
+ - **Per-frame jitter.** The column jitter and the sample RNG both key on `view.frame_index`, so
73
+ consecutive frames genuinely differ. Average 18+ frames per capture.
74
+ - **No noise floor, no result.** Capture the *same* setting twice and difference those first. If the
75
+ change's delta is not clearly above that, nothing has been shown.
76
+ - **The noise floor has to pay the same costs the signal does.** Anything that calls
77
+ `indicate_view_change()` resets temporal accumulation, so a capture taken right after one carries
78
+ a re-convergence transient that a back-to-back capture does not. Measuring the floor without that
79
+ in the middle inflates every result — it made C-04 fail a test it should have passed. Capture as
80
+ *change → settle ~45 frames → average*, and take the floor by making an identical no-op change so
81
+ both paths cost the same.
82
+
83
+ - **Frame time here is presentation-throttled, not GPU-bound.** At 1920×1080 every setting reads
84
+ ~16.5 ms, including settings that do obviously different amounts of work — `ms_octaves` at 1 and at
85
+ 8 come back within noise of each other, which is the tell. The swap chain is pacing the
86
+ measurement. Raise `renderer.pixel_ratio` until the GPU is the bottleneck (3 gives 3840×2160 and
87
+ ~10 ms of real work here), interleave the settings round-robin so drift hits all of them equally,
88
+ and take medians. Remember the froxel grid scales with resolution, so a per-froxel cost measured at
89
+ 4× the pixels is 4× the one you will pay.
90
+
91
+ Prefer the **signed mean** when the change has a direction; it discriminates far better than the
92
+ mean-absolute, which is dominated by noise. C-01b read −0.148 at 60° FOV against a floor of ±0.05,
93
+ and −1.071 at 110°: same fix, and that 7× is the `1/cos θ` scaling it predicts.
94
+
95
+ Also: mutating `light.intensity` does not mark the light buffer dirty, so a sweep of it renders
96
+ identically. Remove and re-add the light instead.
97
+
98
+ ### measure.js
99
+
100
+ `measure.js` is that protocol as code, loaded from the console rather than by the page:
101
+
102
+ ```js
103
+ const m = await (await import('./measure.js')).attach();
104
+ const r = await m.ab(v => globalThis.__VOL_TAA_ENABLED = v, false, true);
105
+ ```
106
+
107
+ `attach` pauses the page's loop — which matters for a reason beyond frame counting: that loop
108
+ assigns `renderer.pixel_ratio = window.devicePixelRatio` on every tick, ahead of its own pause check,
109
+ so a raised pixel ratio is undone before the next frame unless the loop is stopped. `set_pixel_ratio`
110
+ replaces the property for the same reason.
111
+
112
+ Everything renders into a texture the module owns, through `Renderer.render_to_target`, which shares
113
+ the view context and therefore all the temporal history with the canvas path. That is not a
114
+ refinement: a canvas texture is configured by the presentation path, cannot carry `COPY_SRC`, and is
115
+ rotated away the moment the frame is presented, so a `drawImage` off it a task later returns an empty
116
+ bitmap — and a comparison of two blanks reports perfect agreement. Driving the frames from here also
117
+ takes the swap chain out of the timing entirely, which is the presentation-throttling trap above.
118
+
119
+ `ab` runs the two settings interleaved and takes its own floor from a repeat of each. `fly`,
120
+ `converge_at` and `series_stats` are the camera-path half: a structure fitted to the camera can be
121
+ perfectly correct standing still and swim under motion, and neither swimming nor the ghosting the
122
+ 0.95-blend TAA turns it into is visible in a still. `time` and `profile` answer different questions and
123
+ `profile` is the one to believe — at 1080p, whole-frame timing of Q-04 came back with the wrong sign
124
+ three runs in a row while the per-pass capture read it cleanly.
125
+
126
+ `profile` records a `WORKLOAD` session and decodes it in the page, so a cost number does not need a
127
+ downloaded `.sgpt` and a second tool. It returns the median duration per pass label over the frames
128
+ it recorded. Interleave the settings and take medians across rounds even so — the run-to-run spread
129
+ on this machine moves a pass by more than most changes to it do:
130
+
131
+ ```js
132
+ const p = await m.profile({ frames: 24 });
133
+ p['Volumetrics / build sun shadow volume 0'];
134
+ ```
135
+
136
+ ## Recording a GPU profile
137
+
138
+ **T** starts a recording, **T** again stops it and downloads a `.sgpt` capture. The engine side is
139
+ already there — `Renderer.profile_session` is a nullable field and the null checks around it are the
140
+ whole integration — so this page is only the two imports, the key handler, and the metadata a capture
141
+ needs in order to mean anything later: adapter, engine version, device features, and a note carrying
142
+ the viewpoint, FOV, pixel ratio and volumetric TAA setting that were in force when you pressed the
143
+ key.
144
+
145
+ Level is `WORKLOAD`: pass spans, the frame graph, and dispatch/draw counts. Measured on this page it
146
+ runs about 11 KB a frame — roughly 660 KB/s at 60 Hz, and around 393 spans per frame — with the frame
147
+ graph topology stored once and shared by every frame. `VERBOSE` above it adds bind group contents at
148
+ about seven times the rate and wants a frame limit set; `PROFILE_LEVEL` in `main.js` is the one line
149
+ to change.
150
+
151
+ Two things to know. Stopping does not end the recording immediately: a frame is committed when its
152
+ timestamp readback lands, a `mapAsync` a frame or two behind submit, so the handler drains the queue
153
+ and waits for the frame count to settle before it writes the file — otherwise the last frames, the
154
+ ones you were probably looking at, are the ones missing. And the browser asks before letting a page
155
+ save more than one file, so a second capture may never appear; the filename goes to the console
156
+ either way.
157
+
158
+ Frames only accumulate while the page is rendering, so a recording taken while paused stays at zero.
159
+
160
+ ## Scene
161
+
162
+ `flying_world_-_battle_of_the_trash_god/v2`, six participating-media volumes, a low sun, and the
163
+ Venice sunset environment. Both the volumes and most of the camera placements are carried over from
164
+ the sibling repo's playground, where they were found by hand against this exact model — keeping the
165
+ numbers means a before/after pair is taken from the same place to the pixel.
166
+
167
+ Assets live under `test_assets/`, which is gitignored; `test_assets/README.md` has how they get
168
+ there. This page needs:
169
+
170
+ ```bash
171
+ mkdir -p "test_assets/flying_world_-_battle_of_the_trash_god/v2"
172
+ cp "H:/git/meep-renderer-deferred-algo/public/flying_world_-_battle_of_the_trash_god/v2/scene.gltf" "H:/git/meep-renderer-deferred-algo/public/flying_world_-_battle_of_the_trash_god/v2/scene.bin" "test_assets/flying_world_-_battle_of_the_trash_god/v2/"
173
+ cp "H:/git/meep-renderer-deferred-algo/public/environments/octahedral/venice_sunset_2k.hdr" test_assets/environments/octahedral/
174
+ ```
175
+
176
+ Only `scene.gltf` and `scene.bin` are needed from that directory — about 26 MB. glTF rather than the
177
+ `.shade` sitting beside it: that export's header reads format version 1 and `deserialize_scene`
178
+ accepts version 2 only, deliberately and with no backward compatibility.
179
+
180
+ ## Notes
181
+
182
+ Two viewpoints from the sibling repo (`v_volumetrics_light_extinction_0` and `_1`) sit inside
183
+ geometry here and were replaced with placements verified against this build. Two more —
184
+ *Beside the fog box* and *Beside the fog box, deeper* — were added for Q-04: they are the only
185
+ placements in the scene where the sun's path through the medium is short enough for the 16-step
186
+ march to miss it, which makes them the ones any change to that path has to be judged at. *Log camera* writes the
187
+ current placement to the console in the form the `VIEWS` table takes, which is how to add more.
188
+
189
+ Frame time settles around 28 ms at 1920×1080 on a 4070-class part. That is the whole frame, not the
190
+ volumetrics pass — separating it out needs a timestamp query. It is the baseline the performance
191
+ findings (P-01 … P-06) would move.
@@ -0,0 +1,270 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>meep — froxel volumetrics</title>
6
+ <style>
7
+ :root {
8
+ color-scheme: dark;
9
+ --panel: rgba(20, 22, 26, 0.88);
10
+ --line: #2b3038;
11
+ --text: #d6dae0;
12
+ --muted: #8b939f;
13
+ --pass: #55c46a;
14
+ --fail: #ff6b6b;
15
+ --warn: #e5b54a;
16
+ --tag: #c98b3f;
17
+ }
18
+
19
+ * { box-sizing: border-box; }
20
+
21
+ html, body { margin: 0; height: 100%; overflow: hidden; background: #0d0f12; }
22
+
23
+ /* body carries tabindex so the camera controller's keyboard device has something focusable
24
+ to listen on; the focus ring around the whole viewport is not wanted */
25
+ body:focus { outline: none; }
26
+
27
+ #view { display: block; width: 100vw; height: 100vh; }
28
+
29
+ #panel {
30
+ position: fixed;
31
+ top: 12px;
32
+ left: 12px;
33
+ width: 330px;
34
+ max-height: calc(100vh - 24px);
35
+ overflow-y: auto;
36
+ padding: 12px 14px;
37
+ background: var(--panel);
38
+ border: 1px solid var(--line);
39
+ border-radius: 8px;
40
+ backdrop-filter: blur(6px);
41
+ color: var(--text);
42
+ font: 13px/1.5 system-ui, -apple-system, Segoe UI, sans-serif;
43
+ }
44
+
45
+ #panel h1 { font-size: 13px; font-weight: 600; margin: 0 0 8px; }
46
+
47
+ h2 {
48
+ font-size: 11px;
49
+ font-weight: 600;
50
+ text-transform: uppercase;
51
+ letter-spacing: 0.06em;
52
+ color: var(--muted);
53
+ margin: 0 0 6px;
54
+ }
55
+
56
+ label { display: flex; align-items: center; gap: 8px; padding: 3px 0; cursor: pointer; }
57
+ label input[type="checkbox"] { margin: 0; }
58
+ label .hint { color: var(--muted); font-size: 11.5px; }
59
+
60
+ hr { border: 0; border-top: 1px solid var(--line); margin: 10px 0; }
61
+
62
+ button {
63
+ font: inherit;
64
+ padding: 5px 11px;
65
+ border-radius: 6px;
66
+ border: 1px solid var(--line);
67
+ background: #1c1f25;
68
+ color: var(--text);
69
+ cursor: pointer;
70
+ }
71
+
72
+ button:hover { border-color: #3d4552; }
73
+ button:focus-visible { outline: 2px solid var(--tag); outline-offset: 2px; }
74
+
75
+ #views { display: flex; flex-direction: column; gap: 4px; }
76
+
77
+ button.view {
78
+ text-align: left;
79
+ display: flex;
80
+ align-items: baseline;
81
+ gap: 8px;
82
+ padding: 4px 9px;
83
+ font-size: 12.5px;
84
+ }
85
+
86
+ .tag {
87
+ font: 10px ui-monospace, SFMono-Regular, Consolas, monospace;
88
+ color: var(--tag);
89
+ border: 1px solid currentColor;
90
+ border-radius: 3px;
91
+ padding: 0 4px;
92
+ flex: 0 0 auto;
93
+ }
94
+
95
+ .slider {
96
+ display: grid;
97
+ grid-template-columns: 1fr auto;
98
+ align-items: center;
99
+ gap: 2px 8px;
100
+ padding: 4px 0;
101
+ }
102
+
103
+ .slider .name { font-size: 12px; }
104
+ .slider .value {
105
+ font: 12px ui-monospace, SFMono-Regular, Consolas, monospace;
106
+ color: var(--muted);
107
+ text-align: right;
108
+ min-width: 44px;
109
+ }
110
+ .slider input[type="range"] { grid-column: 1 / -1; width: 100%; margin: 0; }
111
+
112
+ .row { display: flex; gap: 6px; align-items: center; }
113
+ .row button { flex: 1 1 auto; }
114
+
115
+ #status { font-size: 12px; color: var(--muted); word-break: break-word; margin-top: 6px; }
116
+ #status.pass { color: var(--pass); }
117
+ #status.fail { color: var(--fail); }
118
+ #status.warn { color: var(--warn); }
119
+
120
+ #stats, #profile_status {
121
+ margin-top: 4px;
122
+ font: 12px ui-monospace, SFMono-Regular, Consolas, monospace;
123
+ color: var(--text);
124
+ }
125
+
126
+ #profile_status.recording { color: var(--fail); }
127
+
128
+ p.note { margin: 6px 0 0; font-size: 11.5px; color: var(--muted); }
129
+ </style>
130
+ </head>
131
+ <body tabindex="0">
132
+
133
+ <canvas id="view"></canvas>
134
+
135
+ <div id="panel">
136
+ <h1>Froxel volumetrics</h1>
137
+
138
+ <h2>Viewpoints</h2>
139
+ <div id="views"></div>
140
+
141
+ <hr>
142
+
143
+ <h2>Sun</h2>
144
+
145
+ <div class="row">
146
+ <button id="sun_into_view">Sun into view</button>
147
+ <button id="sun_behind">Sun behind</button>
148
+ </div>
149
+ <p class="note">
150
+ Places the sun on the camera's forward axis, or directly opposite it. Media self-shadowing
151
+ depends on the shadow ray's path through the grid, so swinging the sun through the frustum
152
+ is the quickest way to exercise it.
153
+ </p>
154
+
155
+ <div class="slider">
156
+ <span class="name">Azimuth</span>
157
+ <span class="value" id="sun_azimuth_value">200°</span>
158
+ <input type="range" id="sun_azimuth" min="0" max="359" step="1" value="200">
159
+ </div>
160
+
161
+ <div class="slider">
162
+ <span class="name">Elevation</span>
163
+ <span class="value" id="sun_elevation_value">12°</span>
164
+ <input type="range" id="sun_elevation" min="1" max="80" step="1" value="12">
165
+ </div>
166
+
167
+ <hr>
168
+
169
+ <h2>Camera</h2>
170
+
171
+ <div class="slider">
172
+ <span class="name">Field of view</span>
173
+ <span class="value" id="fov_value">60°</span>
174
+ <input type="range" id="fov" min="30" max="110" step="1" value="60">
175
+ </div>
176
+ <p class="note">
177
+ Froxel geometry is angle-dependent — the planar depth curve and the ray it is walked along
178
+ diverge as cos&nbsp;θ. A wide FOV is where anything that confuses the two shows up, so open
179
+ this to 110° when judging a change to the grid.
180
+ </p>
181
+
182
+ <hr>
183
+
184
+ <h2>Volumetrics</h2>
185
+
186
+ <label>
187
+ <input type="checkbox" id="vol_taa" checked>
188
+ <span>Volumetric TAA <span class="hint">— off exposes the raw per-frame noise</span></span>
189
+ </label>
190
+
191
+ <label>
192
+ <input type="checkbox" id="taa" checked>
193
+ <span>Renderer TAA</span>
194
+ </label>
195
+
196
+ <label>
197
+ <input type="checkbox" id="auto_exposure">
198
+ <span>Auto exposure <span class="hint">— off: A/B is meaningless with it on</span></span>
199
+ </label>
200
+ <p class="note">
201
+ Eye adaptation drifts for seconds after any change, so two captures of the same setting can
202
+ differ by more than the change being judged. It starts off here for that reason; turn it on
203
+ only to see how something reads in a normally-exposed frame.
204
+ </p>
205
+
206
+ <div class="slider">
207
+ <span class="name">Froxel far</span>
208
+ <span class="value" id="froxel_far_value">off</span>
209
+ <input type="range" id="froxel_far" min="20" max="120" step="5" value="120">
210
+ </div>
211
+ <p class="note">
212
+ Packs the froxel grid into a shorter range than <code>camera.far</code>, so the near field
213
+ gets more slices and the aerial pass ray-marches the volumes beyond it. At <em>off</em> the
214
+ grid spans the whole range and the two depth curves coincide.
215
+ </p>
216
+
217
+ <div class="slider">
218
+ <span class="name">Multiscatter octaves</span>
219
+ <span class="value" id="ms_octaves_value">6</span>
220
+ <input type="range" id="ms_octaves" min="1" max="8" step="1" value="6">
221
+ </div>
222
+
223
+ <hr>
224
+
225
+ <h2>Profiling</h2>
226
+
227
+ <div id="profile_status">idle</div>
228
+ <p class="note">
229
+ <strong>T</strong> starts a GPU profile recording; <strong>T</strong> again stops it and
230
+ downloads the <code>.sgpt</code> capture. Level is <code>WORKLOAD</code> — pass spans, the
231
+ frame graph, and dispatch/draw counts, which measures around 11 KB a frame on this page.
232
+ Frames only accumulate while the page is rendering, so a recording taken while paused stays
233
+ at zero.
234
+ </p>
235
+ <p class="note">
236
+ The browser asks before letting a page save more than one file. If a second capture never
237
+ appears, that prompt is why — allow it, and the filename is on the console either way.
238
+ </p>
239
+
240
+ <hr>
241
+
242
+ <div class="row">
243
+ <button id="pause">Pause</button>
244
+ <button id="log_camera">Log camera</button>
245
+ </div>
246
+
247
+ <div id="status"></div>
248
+ <div id="stats"></div>
249
+
250
+ <p class="note">
251
+ Drag to orbit, wheel to zoom, WASD to move. <em>Log camera</em> writes the current placement
252
+ to the console in the form the <code>VIEWS</code> table takes.
253
+ </p>
254
+ </div>
255
+
256
+ <script type="module">
257
+ import { start } from './main.js';
258
+
259
+ start().catch(error => {
260
+ const status = document.getElementById('status');
261
+
262
+ status.className = 'fail';
263
+ status.textContent = String(error?.message ?? error);
264
+
265
+ console.error(error);
266
+ });
267
+ </script>
268
+
269
+ </body>
270
+ </html>
@@ -0,0 +1,2 @@
1
+ export function start(): Promise<void>;
2
+ //# sourceMappingURL=main.d.ts.map