@woosh/meep-engine 3.18.0 → 3.19.0

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 (120) hide show
  1. package/build/bundle-worker-image-decoder.js +1 -1
  2. package/editor/view/particles/effect/ParticleCurveEditorView.d.ts.map +1 -1
  3. package/editor/view/particles/effect/ParticleCurveEditorView.js +303 -120
  4. package/editor/view/particles/effect/ParticleGradientEditorView.d.ts.map +1 -1
  5. package/editor/view/particles/effect/ParticleGradientEditorView.js +108 -63
  6. package/editor/view/particles/effect/ParticleGraphEditorView.js +1 -1
  7. package/editor/view/particles/effect/ParticleNodeParametersView.d.ts.map +1 -1
  8. package/editor/view/particles/effect/ParticleNodeParametersView.js +3 -1
  9. package/editor/view/particles/effect/particle-editor.css +60 -30
  10. package/package.json +1 -2
  11. package/samples/engine/README.md +1 -1
  12. package/src/core/binary/compression/decompress_bytes.d.ts +13 -0
  13. package/src/core/binary/compression/decompress_bytes.d.ts.map +1 -0
  14. package/src/core/binary/compression/decompress_bytes.js +28 -0
  15. package/src/core/model/node-graph/visual/layout/layout_assign_coordinates.js +67 -22
  16. package/src/engine/asset/loaders/image/ImageDecoderWorker.js +12 -27
  17. package/src/engine/asset/loaders/image/prototypePNG.js +8 -7
  18. package/src/engine/ecs/storage/populateEngineSerializationRegistry.d.ts.map +1 -1
  19. package/src/engine/ecs/storage/populateEngineSerializationRegistry.js +4 -0
  20. package/src/engine/graphics/ecs/particles/ParticleEffect.d.ts +266 -0
  21. package/src/engine/graphics/ecs/particles/ParticleEffect.d.ts.map +1 -0
  22. package/src/engine/graphics/ecs/particles/ParticleEffect.js +455 -0
  23. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.d.ts +58 -0
  24. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.d.ts.map +1 -0
  25. package/src/engine/graphics/ecs/particles/ParticleEffectSerializationAdapter.js +219 -0
  26. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts +178 -25
  27. package/src/engine/graphics3/GPUParticleEmitterSystem.d.ts.map +1 -1
  28. package/src/engine/graphics3/GPUParticleEmitterSystem.js +809 -310
  29. package/src/format/image/png/PNGReader.d.ts +7 -6
  30. package/src/format/image/png/PNGReader.d.ts.map +1 -1
  31. package/src/format/image/png/PNGReader.js +13 -12
  32. package/src/format/image/png/chunk/png_chunk_decode_iTXt.d.ts +3 -2
  33. package/src/format/image/png/chunk/png_chunk_decode_iTXt.d.ts.map +1 -1
  34. package/src/format/image/png/chunk/png_chunk_decode_iTXt.js +5 -4
  35. package/src/format/image/png/chunk/png_chunk_decode_zTXt.d.ts +3 -2
  36. package/src/format/image/png/chunk/png_chunk_decode_zTXt.d.ts.map +1 -1
  37. package/src/format/image/png/chunk/png_chunk_decode_zTXt.js +5 -4
  38. package/src/format/image/png/png_inflate.d.ts +3 -3
  39. package/src/format/image/png/png_inflate.d.ts.map +1 -1
  40. package/src/format/image/png/png_inflate.js +29 -39
  41. package/src/format/texture/ktx2/ktx2_read.d.ts +4 -4
  42. package/src/format/texture/ktx2/ktx2_read.d.ts.map +1 -1
  43. package/src/format/texture/ktx2/ktx2_read.js +18 -21
  44. package/src/shade/playground/particle_ecs/README.md +203 -0
  45. package/src/shade/playground/particle_ecs/bonfire_editor.d.ts +39 -0
  46. package/src/shade/playground/particle_ecs/bonfire_editor.d.ts.map +1 -0
  47. package/src/shade/playground/particle_ecs/bonfire_editor.js +315 -0
  48. package/src/shade/playground/particle_ecs/bonfire_effects.d.ts +145 -0
  49. package/src/shade/playground/particle_ecs/bonfire_effects.d.ts.map +1 -0
  50. package/src/shade/playground/particle_ecs/bonfire_effects.js +202 -0
  51. package/src/shade/playground/particle_ecs/bonfire_sprites.d.ts +23 -0
  52. package/src/shade/playground/particle_ecs/bonfire_sprites.d.ts.map +1 -0
  53. package/src/shade/playground/particle_ecs/bonfire_sprites.js +315 -0
  54. package/src/shade/playground/particle_ecs/bonfire_world.d.ts +86 -0
  55. package/src/shade/playground/particle_ecs/bonfire_world.d.ts.map +1 -0
  56. package/src/shade/playground/particle_ecs/bonfire_world.js +303 -0
  57. package/src/shade/playground/particle_ecs/effects/embers.json +1634 -0
  58. package/src/shade/playground/particle_ecs/effects/flame.json +1882 -0
  59. package/src/shade/playground/particle_ecs/effects/smoke.json +1860 -0
  60. package/src/shade/playground/particle_ecs/effects/soot.json +1606 -0
  61. package/src/shade/playground/particle_ecs/index.html +330 -0
  62. package/src/shade/playground/particle_ecs/main.d.ts +2 -0
  63. package/src/shade/playground/particle_ecs/main.d.ts.map +1 -0
  64. package/src/shade/playground/particle_ecs/main.js +566 -0
  65. package/src/shade/playground/particle_ecs/moonlit_environment.d.ts +16 -0
  66. package/src/shade/playground/particle_ecs/moonlit_environment.d.ts.map +1 -0
  67. package/src/shade/playground/particle_ecs/moonlit_environment.js +144 -0
  68. package/src/shade/playground/particle_editor/README.md +5 -4
  69. package/src/shade/playground/profile_hotkey.d.ts +58 -0
  70. package/src/shade/playground/profile_hotkey.d.ts.map +1 -0
  71. package/src/shade/playground/profile_hotkey.js +325 -0
  72. package/src/shade/playground/ssr_variance/README.md +105 -0
  73. package/src/shade/playground/ssr_variance/capture.d.ts +16 -0
  74. package/src/shade/playground/ssr_variance/capture.d.ts.map +1 -0
  75. package/src/shade/playground/ssr_variance/capture.js +96 -0
  76. package/src/shade/playground/ssr_variance/index.html +22 -0
  77. package/src/shade/playground/ssr_variance/main.d.ts +2 -0
  78. package/src/shade/playground/ssr_variance/main.d.ts.map +1 -0
  79. package/src/shade/playground/ssr_variance/main.js +246 -0
  80. package/src/shade/playground/ssr_variance/reference.d.ts +18 -0
  81. package/src/shade/playground/ssr_variance/reference.d.ts.map +1 -0
  82. package/src/shade/playground/ssr_variance/reference.js +78 -0
  83. package/src/shade/playground/ssr_variance/scene.d.ts +11 -0
  84. package/src/shade/playground/ssr_variance/scene.d.ts.map +1 -0
  85. package/src/shade/playground/ssr_variance/scene.js +61 -0
  86. package/src/shade/playground/ssr_variance/statistics.d.ts +44 -0
  87. package/src/shade/playground/ssr_variance/statistics.d.ts.map +1 -0
  88. package/src/shade/playground/ssr_variance/statistics.js +51 -0
  89. package/src/shade/renderer/loader/gltf/tiny-gltf.d.ts.map +1 -1
  90. package/src/shade/renderer/loader/gltf/tiny-gltf.js +9 -3
  91. package/src/shade/renderer/loader/usd/usd_decode_image.d.ts +4 -4
  92. package/src/shade/renderer/loader/usd/usd_decode_image.d.ts.map +1 -1
  93. package/src/shade/renderer/loader/usd/usd_decode_image.js +4 -4
  94. package/src/shade/renderer/particles/DESIGN.md +748 -703
  95. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts +11 -4
  96. package/src/shade/renderer/particles/runtime/ParticleEmitter.d.ts.map +1 -1
  97. package/src/shade/renderer/particles/runtime/ParticleEmitter.js +431 -424
  98. package/src/shade/renderer/postprocess/ssr/SSR.d.ts.map +1 -1
  99. package/src/shade/renderer/postprocess/ssr/SSR.js +2 -1
  100. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.d.ts.map +1 -1
  101. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_reprojection.js +0 -6
  102. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.d.ts.map +1 -1
  103. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_sample_history.js +19 -5
  104. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.d.ts +4 -0
  105. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.d.ts.map +1 -0
  106. package/src/shade/renderer/postprocess/ssr/reproject/chunk_ssr_temporal_accumulate.js +133 -0
  107. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts +0 -8
  108. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.d.ts.map +1 -1
  109. package/src/shade/renderer/postprocess/ssr/ssr_reproject_shader.js +16 -113
  110. package/src/shade/renderer/texture/source/texel_data_from_ktx2.d.ts +2 -2
  111. package/src/shade/renderer/texture/source/texel_data_from_ktx2.d.ts.map +1 -1
  112. package/src/shade/renderer/texture/source/texel_data_from_ktx2.js +3 -3
  113. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts +0 -20
  114. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_warmup.d.ts.map +0 -1
  115. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.d.ts +0 -14
  116. package/src/shade/renderer/particles/shaders/chunk_particle_emitter_world_sphere.d.ts.map +0 -1
  117. package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts +0 -50
  118. package/src/shade/renderer/particles/shaders/shader_particle_reclaim.d.ts.map +0 -1
  119. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts +0 -17
  120. package/src/shade/renderer/postprocess/ssr/reproject/shader_ffx_denoiser_reflections_reproject.d.ts.map +0 -1
@@ -0,0 +1,144 @@
1
+ import Vector3 from "../../../core/geom/Vector3.js";
2
+ import { Sampler2D } from "../../../engine/graphics/texture/sampler/Sampler2D.js";
3
+ import { sampler2d_to_f16 } from "../../../engine/graphics/texture/sampler/sampler2d_to_f16.js";
4
+ import { octahedral_uv_to_direction } from "../../renderer/light/environment/octahedral_uv_to_direction.js";
5
+ import { ColorSpace } from "../../renderer/texture/ColorSpace.js";
6
+ import { ShadeImage } from "../../renderer/texture/source/ShadeImage.js";
7
+ import { ShadeTexture } from "../../renderer/texture/ShadeTexture.js";
8
+
9
+ /**
10
+ * A moonlit night sky, generated: what the bonfire is lit by when it is not lighting itself.
11
+ *
12
+ * Built rather than loaded for the same reason the sprites are — `test_assets/` is gitignored, so a
13
+ * fresh checkout has no environment map, and a page that needs one to look right would look wrong
14
+ * for everybody who has not fetched the asset repository. This is small, it is in linear radiance,
15
+ * and it is the same octahedral projection `scene.lights.environment` samples.
16
+ *
17
+ * **The sky is the reason the fire reads as a fire.** Shade assumes global illumination, so a scene
18
+ * with no environment renders unlit; give it a bright day and the flame washes out to a pale smear,
19
+ * because the fire's own light is then a small fraction of what is already there. A night sky about
20
+ * three orders of magnitude below daylight is what leaves the fire room to be the brightest thing
21
+ * in the frame, and what makes the smoke read as blue where the firelight has stopped reaching it.
22
+ */
23
+
24
+ /**
25
+ * Resolution per octahedral face grid.
26
+ *
27
+ * Small on purpose: this is an ambient source, convolved into irradiance and a handful of roughness
28
+ * mips before anything samples it. What it has to be is smooth and correctly oriented.
29
+ *
30
+ * @type {number}
31
+ */
32
+ const RESOLUTION = 128;
33
+
34
+ /**
35
+ * Where the moon is. Unit, +Y up — the direction light arrives *from*, so the shadows fall the
36
+ * other way.
37
+ *
38
+ * @type {Vector3}
39
+ */
40
+ export const MOON_DIRECTION = new Vector3(-0.42, 0.62, -0.66).normalize();
41
+
42
+ /**
43
+ * Angular radius of the moon's disc, in radians. Far larger than the real one (about 0.0045): the
44
+ * map is 128 texels across a hemisphere, so a true disc would be a fraction of a texel and would
45
+ * disappear into the first mip. This is a moon to look at, not an ephemeris.
46
+ *
47
+ * @type {number}
48
+ */
49
+ const MOON_RADIUS = 0.075;
50
+
51
+ /**
52
+ * @type {Vector3}
53
+ */
54
+ const SCRATCH_DIRECTION = new Vector3();
55
+
56
+ /**
57
+ * Radiance arriving from a direction, in linear space.
58
+ *
59
+ * @param {number[]} out rgb
60
+ * @param {number} x
61
+ * @param {number} y +Y is up
62
+ * @param {number} z
63
+ */
64
+ function moonlit_radiance(out, x, y, z) {
65
+ // How close this direction is to the moon, as the cosine of the angle between them.
66
+ const alignment = x * MOON_DIRECTION.x + y * MOON_DIRECTION.y + z * MOON_DIRECTION.z;
67
+ const angle = Math.acos(Math.min(1, Math.max(-1, alignment)));
68
+
69
+ if (y >= 0) {
70
+ // Night sky: deepest at the zenith, lifting toward the horizon where the atmosphere is
71
+ // thicker and there is more of it to scatter what little light there is.
72
+ const horizon = Math.pow(1 - y, 4);
73
+
74
+ out[0] = 0.0035 + horizon * 0.011;
75
+ out[1] = 0.0052 + horizon * 0.014;
76
+ out[2] = 0.0115 + horizon * 0.020;
77
+ } else {
78
+ // Below the horizon: ground bounce, dimmer and less blue than the sky. This is what lights
79
+ // the underside of the smoke, so it is not zero however dark the night is.
80
+ const t = Math.pow(1 + y, 2);
81
+
82
+ out[0] = 0.0016 + t * 0.0042;
83
+ out[1] = 0.0017 + t * 0.0044;
84
+ out[2] = 0.0022 + t * 0.0055;
85
+ }
86
+
87
+ // A broad halo, then the disc itself. The halo is what makes the sky around the moon read as
88
+ // sky with a moon in it rather than as a sticker on a gradient.
89
+ const halo = Math.exp(-angle * 5.5) * 0.05;
90
+
91
+ out[0] += halo * 0.75;
92
+ out[1] += halo * 0.82;
93
+ out[2] += halo;
94
+
95
+ if (angle < MOON_RADIUS) {
96
+ // Soft-edged, so the disc survives the convolution into the roughness mips with a shape
97
+ // rather than as ringing.
98
+ const disc = 1 - Math.pow(angle / MOON_RADIUS, 3);
99
+
100
+ out[0] += disc * 5.2;
101
+ out[1] += disc * 5.6;
102
+ out[2] += disc * 6.4;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * The environment map. Assign it to `scene.lights.environment`.
108
+ *
109
+ * @returns {ShadeTexture}
110
+ */
111
+ export function moonlit_environment() {
112
+ const sampler = Sampler2D.float32(4, RESOLUTION, RESOLUTION);
113
+
114
+ const data = sampler.data;
115
+ const rgb = [0, 0, 0];
116
+
117
+ for (let v = 0; v < RESOLUTION; v++) {
118
+ for (let u = 0; u < RESOLUTION; u++) {
119
+ // texel centres, so the mapping is symmetric across the octahedron's folds
120
+ octahedral_uv_to_direction(
121
+ SCRATCH_DIRECTION,
122
+ (u + 0.5) / RESOLUTION,
123
+ (v + 0.5) / RESOLUTION
124
+ );
125
+
126
+ moonlit_radiance(rgb, SCRATCH_DIRECTION.x, SCRATCH_DIRECTION.y, SCRATCH_DIRECTION.z);
127
+
128
+ const offset = (v * RESOLUTION + u) * 4;
129
+
130
+ data[offset] = rgb[0];
131
+ data[offset + 1] = rgb[1];
132
+ data[offset + 2] = rgb[2];
133
+ data[offset + 3] = 1;
134
+ }
135
+ }
136
+
137
+ const image = ShadeImage.fromSampler2D(sampler2d_to_f16(sampler));
138
+
139
+ // radiance, not colour: the values above are light arriving, and treating them as sRGB would
140
+ // put a transfer curve through numbers that never had one
141
+ image.color_space = ColorSpace.LinearSRGB;
142
+
143
+ return ShadeTexture.from(image);
144
+ }
@@ -78,10 +78,11 @@ edits it at both altitudes.
78
78
  - Two of them are **ramps**, edited as a picture. An **RGBA Gradient** (and an RGB one) is a strip of
79
79
  the colour it produces with its stops underneath: drag a stop, click the lane to add one — in the
80
80
  colour the gradient already is there, so adding never changes anything — and middle-click to remove.
81
- A **Curve** is the unit square with its keys in it, and the picker under it decides the selected
82
- key's tangents. Neither is sampled from a table at runtime: both are written out into the program as
83
- arithmetic, which is what the **Code** tab shows. `Curve Table`, which *is* the table, is the other
84
- node — it takes a row handle rather than a curve.
81
+ A **Curve** plots normalized time against unrestricted values, with a Y axis that fits the whole
82
+ curve, including peaks between keys. Its fields edit the selected key's time, value and tangents.
83
+ The scale stays fixed during a drag and refits on release. Neither ramp is sampled from a table
84
+ at runtime: both are written out into the program as arithmetic, which is what the **Code** tab
85
+ shows. `Curve Table`, which *is* the table, is the other node — it takes a row handle rather than a curve.
85
86
  - The strip along the bottom lists every problem with the effect, from **both** phases, loudest
86
87
  first. Click one to jump to the phase it is in and select the node; the node is outlined in the
87
88
  graph too.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * One key that records a GPU profile capture and downloads it.
3
+ *
4
+ * `T` starts a recording, `T` again stops it and saves an `.sgpt` next to the browser's other
5
+ * downloads. That is the whole interaction, and it is what every playground page wants: a capture is
6
+ * something you take *while looking at* the thing you are suspicious of, so reaching for a panel is
7
+ * already too slow.
8
+ *
9
+ * **The engine never constructs a `GPUProfileSession`.** `Renderer.profile_session` is a nullable
10
+ * field and the null checks around it are the entire integration, which is what keeps the profiler
11
+ * out of a bundle that does not ask for it. Importing this module is the opt-in.
12
+ *
13
+ * `volumetrics_froxel/main.js` has its own copy of this, written first; it is the page this was
14
+ * lifted from and it can adopt this when somebody has the assets to verify it against.
15
+ */
16
+ /**
17
+ * @typedef {object} ProfileHotkeyHandle
18
+ * @property {function(): boolean} is_recording
19
+ * @property {function(): Promise<void>} toggle start a recording, or stop and save the one running
20
+ * @property {function(): void} dispose remove the key listener
21
+ */
22
+ /**
23
+ * Wire the key up.
24
+ *
25
+ * @param {object} params
26
+ * @param {import("../renderer/Renderer.js").Renderer} params.renderer initialized — this reads its
27
+ * device for the capture's metadata
28
+ * @param {string} params.name goes in the filename and in the capture's note
29
+ * @param {HTMLElement|null} [params.status] written with what the recording is doing; the element
30
+ * gets the class `recording` while one is running, and nothing otherwise
31
+ * @param {function(): string[]} [params.settings] what the page was set to when the capture was
32
+ * taken. A pass timing says nothing until you know that
33
+ * @param {number} [params.level] see {@link GPUProfileLevel}; `WORKLOAD` is the most detailed
34
+ * @param {string} [params.key] the letter to bind, lowercase
35
+ * @param {EventTarget} [params.target] where the listener goes
36
+ * @returns {ProfileHotkeyHandle}
37
+ */
38
+ export function install_profile_hotkey({ renderer, name, status, settings, level, key, target, }: {
39
+ renderer: import("../renderer/Renderer.js").Renderer;
40
+ name: string;
41
+ status?: HTMLElement | null;
42
+ settings?: () => string[];
43
+ level?: number;
44
+ key?: string;
45
+ target?: EventTarget;
46
+ }): ProfileHotkeyHandle;
47
+ export type ProfileHotkeyHandle = {
48
+ is_recording: () => boolean;
49
+ /**
50
+ * start a recording, or stop and save the one running
51
+ */
52
+ toggle: () => Promise<void>;
53
+ /**
54
+ * remove the key listener
55
+ */
56
+ dispose: () => void;
57
+ };
58
+ //# sourceMappingURL=profile_hotkey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"profile_hotkey.d.ts","sourceRoot":"","sources":["../../../../src/shade/playground/profile_hotkey.js"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;GAcG;AAEH;;;;;GAKG;AAEH;;;;;;;;;;;;;;;GAeG;AACH;IAZ8D,QAAQ,EAA3D,OAAO,yBAAyB,EAAE,QAAQ;IAE3B,IAAI,EAAnB,MAAM;IACoB,MAAM,GAAhC,WAAW,GAAC,IAAI;IAEc,QAAQ,SAA1B,MAAM,EAAE;IAEJ,KAAK,GAArB,MAAM;IACU,GAAG,GAAnB,MAAM;IACe,MAAM,GAA3B,WAAW;IACT,mBAAmB,CAwO/B;;wBA3PyB,OAAO;;;;kBACP,QAAQ,IAAI,CAAC;;;;mBACb,IAAI"}
@@ -0,0 +1,325 @@
1
+ import package_json from "../../../package.json";
2
+ import { GPUProfileLevel } from "../device/timing/profile/GPUProfileLevel.js";
3
+ import { GPUProfileSession } from "../device/timing/profile/GPUProfileSession.js";
4
+
5
+ /**
6
+ * One key that records a GPU profile capture and downloads it.
7
+ *
8
+ * `T` starts a recording, `T` again stops it and saves an `.sgpt` next to the browser's other
9
+ * downloads. That is the whole interaction, and it is what every playground page wants: a capture is
10
+ * something you take *while looking at* the thing you are suspicious of, so reaching for a panel is
11
+ * already too slow.
12
+ *
13
+ * **The engine never constructs a `GPUProfileSession`.** `Renderer.profile_session` is a nullable
14
+ * field and the null checks around it are the entire integration, which is what keeps the profiler
15
+ * out of a bundle that does not ask for it. Importing this module is the opt-in.
16
+ *
17
+ * `volumetrics_froxel/main.js` has its own copy of this, written first; it is the page this was
18
+ * lifted from and it can adopt this when somebody has the assets to verify it against.
19
+ */
20
+
21
+ /**
22
+ * @typedef {object} ProfileHotkeyHandle
23
+ * @property {function(): boolean} is_recording
24
+ * @property {function(): Promise<void>} toggle start a recording, or stop and save the one running
25
+ * @property {function(): void} dispose remove the key listener
26
+ */
27
+
28
+ /**
29
+ * Wire the key up.
30
+ *
31
+ * @param {object} params
32
+ * @param {import("../renderer/Renderer.js").Renderer} params.renderer initialized — this reads its
33
+ * device for the capture's metadata
34
+ * @param {string} params.name goes in the filename and in the capture's note
35
+ * @param {HTMLElement|null} [params.status] written with what the recording is doing; the element
36
+ * gets the class `recording` while one is running, and nothing otherwise
37
+ * @param {function(): string[]} [params.settings] what the page was set to when the capture was
38
+ * taken. A pass timing says nothing until you know that
39
+ * @param {number} [params.level] see {@link GPUProfileLevel}; `WORKLOAD` is the most detailed
40
+ * @param {string} [params.key] the letter to bind, lowercase
41
+ * @param {EventTarget} [params.target] where the listener goes
42
+ * @returns {ProfileHotkeyHandle}
43
+ */
44
+ export function install_profile_hotkey({
45
+ renderer,
46
+ name,
47
+ status = null,
48
+ settings = () => [],
49
+ level = GPUProfileLevel.WORKLOAD,
50
+ key = "t",
51
+ target = window,
52
+ }) {
53
+ /**
54
+ * The recording in progress, or null.
55
+ * @type {GPUProfileSession|null}
56
+ */
57
+ let session = null;
58
+
59
+ /** Guards against the key being leaned on while a stop is still draining. */
60
+ let busy = false;
61
+
62
+ /**
63
+ * @param {string} text
64
+ * @param {boolean} [recording]
65
+ */
66
+ function set_status(text, recording = false) {
67
+ if (status === null) {
68
+ return;
69
+ }
70
+
71
+ status.textContent = text;
72
+ status.className = recording ? "recording" : "";
73
+ }
74
+
75
+ function start() {
76
+ const device = renderer.device;
77
+
78
+ const recording = new GPUProfileSession({
79
+ level,
80
+ note: `${name} — ${[...settings(), `${renderer.pixel_ratio.toFixed(2)}x`].join(" · ")}`,
81
+ });
82
+
83
+ /*
84
+ Populated before start(): the meta record is written into the stream there and not read
85
+ again. Without it the capture is unreadable in the sense that matters — a pass taking 0.4 ms
86
+ says nothing until you know which GPU and at what resolution.
87
+ */
88
+ const meta = recording.meta;
89
+
90
+ // `GPUDevice.adapterInfo` is recent enough that a browser running this page may not have it,
91
+ // and several browsers withhold the fields even when they do. Empty is what the format
92
+ // expects in that case.
93
+ const info = device.adapterInfo;
94
+
95
+ if (info !== undefined && info !== null) {
96
+ meta.adapter_vendor = info.vendor ?? "";
97
+ meta.adapter_architecture = info.architecture ?? "";
98
+ meta.adapter_device = info.device ?? "";
99
+ meta.adapter_description = info.description ?? "";
100
+ }
101
+
102
+ meta.features = [...device.features];
103
+ meta.engine_version = package_json.version;
104
+
105
+ recording.onBytesWritten.add(bytes => {
106
+ /*
107
+ Only while this is still the live recording. Frames keep landing all through the drain
108
+ in `stop`, which is the whole point of draining — and without this check each one would
109
+ overwrite "stopping…" with "● recording · T to stop", so a capture that is on its way to
110
+ disk reads as one that is still running.
111
+ */
112
+ if (session !== recording) {
113
+ return;
114
+ }
115
+
116
+ set_status(
117
+ `● recording — ${recording.frames_recorded} frames, ${format_bytes(bytes)}`
118
+ + ` · ${key.toUpperCase()} to stop`,
119
+ true
120
+ );
121
+ });
122
+
123
+ renderer.profile_session = recording;
124
+ recording.start();
125
+
126
+ session = recording;
127
+
128
+ // The timers go quiet without this rather than failing, so the capture still carries the
129
+ // frame graph and the workload — it just has no durations in it. Worth knowing before you
130
+ // record for a minute and then look.
131
+ const timed = device.features.has("timestamp-query");
132
+
133
+ set_status(
134
+ timed
135
+ ? `● recording — 0 frames · ${key.toUpperCase()} to stop`
136
+ : "● recording without timestamp-query — structure only, no durations"
137
+ + ` · ${key.toUpperCase()} to stop`,
138
+ true
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Wait for frames already submitted to finish landing in the session.
144
+ *
145
+ * A frame is committed when its timestamp readback resolves — a `mapAsync` a frame or two behind
146
+ * submit, see `ShadeGPUCommandContext.profiling_absorbed` — and `record_frame` silently ignores
147
+ * anything arriving after `stop()`. Stopping the instant the key goes down therefore discards
148
+ * the last few frames, which are the ones you were most likely looking at.
149
+ *
150
+ * Drains the queue, then waits for the frame count to stop moving. Capped, because a recording
151
+ * taken while the page is paused never moves at all.
152
+ *
153
+ * @param {GPUProfileSession} recording
154
+ * @returns {Promise<void>}
155
+ */
156
+ async function drain(recording) {
157
+ const DEADLINE_MS = 1000;
158
+ const QUIET_MS = 60;
159
+
160
+ await renderer.device.queue.onSubmittedWorkDone();
161
+
162
+ const deadline = performance.now() + DEADLINE_MS;
163
+
164
+ let previous = recording.frames_recorded;
165
+
166
+ while (performance.now() < deadline) {
167
+ await new Promise(resolve => setTimeout(resolve, QUIET_MS));
168
+
169
+ if (recording.frames_recorded === previous) {
170
+ return;
171
+ }
172
+
173
+ previous = recording.frames_recorded;
174
+ }
175
+ }
176
+
177
+ async function stop() {
178
+ const recording = session;
179
+
180
+ session = null;
181
+
182
+ // Detached first, so nothing encoded during the drain below joins the recording. Frames
183
+ // already encoded are unaffected: Renderer captures the session into a local before it
184
+ // subscribes to the readback.
185
+ renderer.profile_session = null;
186
+
187
+ set_status("stopping — draining in-flight frames…");
188
+
189
+ await drain(recording);
190
+
191
+ const bytes = recording.stop();
192
+ const filename = `${name}-${file_timestamp()}.sgpt`;
193
+
194
+ download_bytes(bytes, filename);
195
+
196
+ const topologies = `${recording.topology_count} topolog${recording.topology_count === 1 ? "y" : "ies"}`;
197
+
198
+ // Also to the console, because the status line is the only other place the name appears and
199
+ // a browser that declines the save says nothing about which capture it declined.
200
+ console.log(
201
+ `profile: ${recording.frames_recorded} frames, ${format_bytes(bytes.byteLength)}, `
202
+ + `${topologies} -> ${filename}`
203
+ );
204
+
205
+ set_status(
206
+ `${recording.frames_recorded} frames, ${format_bytes(bytes.byteLength)}, ${topologies}`
207
+ + ` · ${filename}`
208
+ );
209
+ }
210
+
211
+ /**
212
+ * @returns {Promise<void>}
213
+ */
214
+ function toggle() {
215
+ if (busy) {
216
+ return Promise.resolve();
217
+ }
218
+
219
+ busy = true;
220
+
221
+ const done = session === null ? Promise.resolve(start()) : stop();
222
+
223
+ return done.catch(error => {
224
+ set_status(`profiling failed: ${error?.message ?? error}`);
225
+ console.error(error);
226
+
227
+ session = null;
228
+ renderer.profile_session = null;
229
+ }).finally(() => {
230
+ busy = false;
231
+ });
232
+ }
233
+
234
+ /**
235
+ * @param {KeyboardEvent} event
236
+ */
237
+ function on_keydown(event) {
238
+ if (event.ctrlKey || event.metaKey || event.altKey || event.repeat) {
239
+ return;
240
+ }
241
+
242
+ /*
243
+ `key` first, because it is the letter actually printed on the key the user pressed — `code`
244
+ is physical position, so on AZERTY it names the key where T sits on QWERTY. `code` is the
245
+ fallback for layouts whose `key` is not Latin at all (Cyrillic gives "е" here).
246
+ */
247
+ if (event.key.toLowerCase() !== key && event.code !== `Key${key.toUpperCase()}`) {
248
+ return;
249
+ }
250
+
251
+ // a panel's fields and buttons are focusable; do not steal a key from one
252
+ const focused = event.target;
253
+
254
+ if (
255
+ focused instanceof HTMLInputElement
256
+ || focused instanceof HTMLTextAreaElement
257
+ || focused instanceof HTMLSelectElement
258
+ ) {
259
+ return;
260
+ }
261
+
262
+ event.preventDefault();
263
+
264
+ toggle();
265
+ }
266
+
267
+ target.addEventListener("keydown", on_keydown);
268
+
269
+ return {
270
+ is_recording: () => session !== null,
271
+ toggle,
272
+ dispose: () => target.removeEventListener("keydown", on_keydown),
273
+ };
274
+ }
275
+
276
+ /**
277
+ * @param {Uint8Array} bytes
278
+ * @param {string} filename
279
+ */
280
+ function download_bytes(bytes, filename) {
281
+ const url = URL.createObjectURL(new Blob([bytes], { type: "application/octet-stream" }));
282
+
283
+ const anchor = document.createElement("a");
284
+
285
+ anchor.href = url;
286
+ anchor.download = filename;
287
+
288
+ document.body.appendChild(anchor);
289
+ anchor.click();
290
+ anchor.remove();
291
+
292
+ // Revoking in the same task cancels the download in some browsers — the click only schedules it.
293
+ setTimeout(() => URL.revokeObjectURL(url), 60000);
294
+ }
295
+
296
+ /**
297
+ * `YYYYMMDD-HHMMSS` in local time, matching the naming the other `.sgpt` captures already use — same
298
+ * sort order, and it reads as the clock you took it by rather than as UTC.
299
+ *
300
+ * @returns {string}
301
+ */
302
+ function file_timestamp() {
303
+ const now = new Date();
304
+
305
+ const pad = value => String(value).padStart(2, "0");
306
+
307
+ return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
308
+ + `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
309
+ }
310
+
311
+ /**
312
+ * @param {number} bytes
313
+ * @returns {string}
314
+ */
315
+ function format_bytes(bytes) {
316
+ if (bytes < 1024) {
317
+ return `${bytes} B`;
318
+ }
319
+
320
+ if (bytes < 1024 * 1024) {
321
+ return `${(bytes / 1024).toFixed(1)} KiB`;
322
+ }
323
+
324
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
325
+ }
@@ -0,0 +1,105 @@
1
+ # SSR variance playground
2
+
3
+ Run `npm run dev` from the workspace, then open
4
+ <http://localhost:5173/src/shade/playground/ssr_variance/>.
5
+
6
+ The scene contains exactly two quads: a metallic ground plane at y=0 with roughness
7
+ 0.30, and a vertical card facing the camera. The card carries an emissive cross
8
+ texture with two coloured details. The pattern does not add geometry or normal
9
+ edges. A constant dim environment supplies the SSR fallback.
10
+
11
+ The camera, scene, exposure (compensation 1), and 800×500 resolution are fixed.
12
+ Automatic exposure, SSAO, bloom, sharpening, and motion blur are disabled.
13
+ Main TAA defaults off and can be enabled independently.
14
+ Raster jitter defaults off separately: the production renderer still applies its
15
+ jitter sequence with TAA off, so a playground-only `FrameStart` extension freezes
16
+ the view's projection and jitter uniforms before rasterization. The jitter control
17
+ can restore production behaviour. SSR's stochastic frame index still advances.
18
+
19
+ ## Exact capture
20
+
21
+ The page starts at frame **0** without submitting a frame or scheduling an animation
22
+ loop. **Capture frames 100–115** submits exactly 115 frames, each with dt=1/60, and
23
+ records frame 100 and every subsequent frame through 115. GPU completion and readback
24
+ are awaited between captures. Readback does not render extra frames. The renderer's
25
+ own count is checked against the experiment count; its zero-based SSR indices are
26
+ 99–114 for these captures.
27
+
28
+ From the console:
29
+
30
+ ```js
31
+ const lab = await ssr_variance_ready;
32
+ const measurements = await lab.run(); // requires frame zero; finishes at frame 115
33
+ lab.frame_count; // 115, stays there indefinitely
34
+ lab.frames; // 16 PNG byte arrays and linear float32 stage arrays
35
+ lab.files; // Map<filename, Uint8Array | Float32Array>
36
+ ```
37
+
38
+ For manual control on a fresh page:
39
+
40
+ ```js
41
+ await ssr_variance_ready;
42
+ await ssr_variance_lab.step(99);
43
+ await ssr_variance_lab.step(1, true); // submit frame 100 and capture it
44
+ await ssr_variance_lab.step(15, true); // capture every frame from 101 through 115
45
+ ```
46
+
47
+ `step(n, true)` records each of its n frames. The standard `run()` additionally
48
+ calculates statistics and fills the page's results. Operations are serialized;
49
+ overlapping calls are rejected. Restarting or changing a control reloads the page,
50
+ giving every comparison a fresh renderer and the same frame-index sequence.
51
+ Appending `?run` runs the standard capture automatically. `spatial=off`,
52
+ `temporal=off`, `jitter=on`, and `taa=on` select controls in the query string.
53
+ Use `?run&jitter=on&taa=on` to measure the full production jitter/TAA path.
54
+
55
+ ## Convergence
56
+
57
+ **Measure convergence**, `?convergence`, or `await lab.convergence()` on a fresh page
58
+ records frames 4, 8, 16, 32, 64, 100, 128, 256, and 512. It then averages the
59
+ pre-temporal input from frames 513–1536 into a separate float32 GPU texture. This
60
+ reference is never fed into the renderer. The table compares each checkpoint's
61
+ temporal and post-spatial output with that 1,024-sample mean, using linear luminance
62
+ RMSE and signed bias over the same reflection rectangle.
63
+
64
+ This catches a darkened or frozen output that scores well on temporal variance.
65
+ The reference estimates the prefilter's expected output; it is not ray-traced
66
+ ground truth and does not validate SSR visibility, ray reuse, or the prefilter's
67
+ own bias. Finite reference noise also sets an error floor. This pixel-aligned
68
+ comparison requires raster jitter off; the playground rejects it otherwise before
69
+ submitting any frames. Production history is bounded at 128 frames to
70
+ retain responsiveness, so convergence approaches a noise floor rather than an
71
+ unbounded average. `convergence.json`, `reference.f32`, checkpoint PNGs, and stage
72
+ arrays are available in `lab.files` after the run stops at frame 1536.
73
+
74
+ ## Outputs and measurement
75
+
76
+ The playground copies resolve, spatial step 1, temporal reprojection, spatial step 2,
77
+ spatial step 4, pre-TAA scene colour, and final linear scene colour after TAA using
78
+ integer `textureLoad` calls before presentation.
79
+ Copies run only on recorded frames. Disabling spatial filtering makes those stage
80
+ captures refer to their bypass inputs. Resolve still performs its own ray reuse
81
+ and roughness-dependent source mip sampling.
82
+
83
+ Each displayed image is the actual canvas output copied immediately after render,
84
+ before the browser can discard it. Select a thumbnail to download its PNG. The JSON
85
+ download includes frame indices, controls, camera settings, and statistics. Full raw
86
+ captures and per-pixel mean/variance maps are available as typed arrays in `lab.files`.
87
+ An external driver can write each array directly, for example as a `fetch` request
88
+ body. Captures stay in CPU memory without duplicating HDR arrays into Blob storage.
89
+ RGBA captures are little-endian float32, row-major, top-left origin. SSR radiance
90
+ alpha carries the shader's internal variance estimate; scene alpha is opacity.
91
+ The mask uses 1 for upward-facing ground pixels and 0 elsewhere.
92
+
93
+ Temporal variance is computed **per pixel** over all 16 linear Rec.709 luminance
94
+ samples (0.2126 R + 0.7152 G + 0.0722 B), then averaged spatially. Population variance
95
+ divides by 16; sample variance divides by 15. RMS temporal sigma is the square root
96
+ of mean population variance. This is distinct from image contrast or the shader's
97
+ alpha-channel estimate. Non-finite readback values fail the measurement.
98
+
99
+ The JSON reports full-image, whole-ground, and reflection-region statistics. The
100
+ reflection region is the fixed rectangle x=200…599, y=250…459 intersected with the
101
+ frame-100 ground mask. It covers the main visible reflection, not its full rough
102
+ tail. The heatmap uses a fixed 0…0.10 linear-luminance sigma scale. With raster jitter
103
+ enabled, silhouette coverage can vary; the fixed-projection default avoids that
104
+ confound. A lower variance alone does not prove better image quality: blurring can
105
+ reduce variance while destroying reflected detail.
@@ -0,0 +1,16 @@
1
+ /** TAA-off currently leaves raster jitter active. Freeze only this lab's view before rasterization. */
2
+ export class FixedProjection extends RenderExtension {
3
+ record(frame: any): void;
4
+ }
5
+ /** Exact textureLoad copies of production SSR stages, only on requested frames. */
6
+ export class CaptureSSR extends RenderExtension {
7
+ constructor(device: any);
8
+ enabled: boolean;
9
+ textures: Map<any, any>;
10
+ device: any;
11
+ record(frame: any): void;
12
+ jitter: any[];
13
+ read(): Promise<{}>;
14
+ }
15
+ import { RenderExtension } from "../../renderer/extension/RenderExtension.js";
16
+ //# sourceMappingURL=capture.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capture.d.ts","sourceRoot":"","sources":["../../../../../src/shade/playground/ssr_variance/capture.js"],"names":[],"mappings":"AA6BA,uGAAuG;AACvG;IAGI,yBAKC;CACJ;AAED,mFAAmF;AACnF;IAKI,yBAAsD;IAFtD,iBAAgB;IAChB,wBAAqB;IACU,YAAoB;IACnD,yBAuCC;IArCG,cAA2C;IAsC/C,oBAMC;CACJ;gCA/F+B,6CAA6C"}