@woosh/meep-engine 3.14.0 → 3.14.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 (220) hide show
  1. package/README.md +33 -64
  2. package/package.json +1 -1
  3. package/src/avif/native/DECISIONS.md +544 -536
  4. package/src/avif/native/heif/parse_avif_file.d.ts.map +1 -1
  5. package/src/avif/native/heif/parse_avif_file.js +384 -383
  6. package/src/avif/native/heif/parse_image_grid.d.ts.map +1 -1
  7. package/src/avif/native/heif/parse_image_grid.js +49 -48
  8. package/src/basis/BasisBitReader.d.ts +77 -0
  9. package/src/basis/BasisBitReader.d.ts.map +1 -0
  10. package/src/basis/BasisBitReader.js +231 -0
  11. package/src/basis/BasisHuffmanTable.d.ts +53 -0
  12. package/src/basis/BasisHuffmanTable.d.ts.map +1 -0
  13. package/src/basis/BasisHuffmanTable.js +263 -0
  14. package/src/basis/basis_read_huffman_table.d.ts +20 -0
  15. package/src/basis/basis_read_huffman_table.d.ts.map +1 -0
  16. package/src/basis/basis_read_huffman_table.js +187 -0
  17. package/src/basis/etc1s/Etc1sSelectorHistory.d.ts +50 -0
  18. package/src/basis/etc1s/Etc1sSelectorHistory.d.ts.map +1 -0
  19. package/src/basis/etc1s/Etc1sSelectorHistory.js +89 -0
  20. package/src/basis/etc1s/etc1s_block_colors.d.ts +14 -0
  21. package/src/basis/etc1s/etc1s_block_colors.d.ts.map +1 -0
  22. package/src/basis/etc1s/etc1s_block_colors.js +75 -0
  23. package/src/basis/etc1s/etc1s_decode_slice.d.ts +57 -0
  24. package/src/basis/etc1s/etc1s_decode_slice.d.ts.map +1 -0
  25. package/src/basis/etc1s/etc1s_decode_slice.js +258 -0
  26. package/src/basis/etc1s/etc1s_read_endpoint_codebook.d.ts +48 -0
  27. package/src/basis/etc1s/etc1s_read_endpoint_codebook.d.ts.map +1 -0
  28. package/src/basis/etc1s/etc1s_read_endpoint_codebook.js +111 -0
  29. package/src/basis/etc1s/etc1s_read_selector_codebook.d.ts +42 -0
  30. package/src/basis/etc1s/etc1s_read_selector_codebook.d.ts.map +1 -0
  31. package/src/basis/etc1s/etc1s_read_selector_codebook.js +127 -0
  32. package/src/basis/etc1s/etc1s_read_slice_tables.d.ts +54 -0
  33. package/src/basis/etc1s/etc1s_read_slice_tables.d.ts.map +1 -0
  34. package/src/basis/etc1s/etc1s_read_slice_tables.js +47 -0
  35. package/src/basis/transcode/build_range_plan.js +54 -0
  36. package/src/basis/transcode/etc1s_to_bc1.d.ts +24 -0
  37. package/src/basis/transcode/etc1s_to_bc1.d.ts.map +1 -0
  38. package/src/basis/transcode/etc1s_to_bc1.js +205 -0
  39. package/src/basis/transcode/etc1s_to_bc7.d.ts +30 -0
  40. package/src/basis/transcode/etc1s_to_bc7.d.ts.map +1 -0
  41. package/src/basis/transcode/etc1s_to_bc7.js +496 -0
  42. package/src/basis/transcode/fit_endpoints.d.ts +59 -0
  43. package/src/basis/transcode/fit_endpoints.d.ts.map +1 -0
  44. package/src/basis/transcode/fit_endpoints.js +226 -0
  45. package/src/{avif/native/isobmff → core/binary}/ByteCursor.d.ts +19 -6
  46. package/src/core/binary/ByteCursor.d.ts.map +1 -0
  47. package/src/{avif/native/isobmff → core/binary}/ByteCursor.js +41 -14
  48. package/src/engine/EngineHarness.js +1 -1
  49. package/src/engine/asset/codec/Codec.d.ts.map +1 -0
  50. package/src/engine/asset/codec/CodecWithFallback.d.ts.map +1 -0
  51. package/src/engine/asset/{loaders/image/codec → codec}/CodecWithFallback.js +2 -2
  52. package/src/engine/asset/loaders/image/IMAGE_CODEC_REVIEW_2026_08_28.md +4 -4
  53. package/src/engine/asset/loaders/image/ImageRGBADataLoader.d.ts +1 -1
  54. package/src/engine/asset/loaders/image/ImageRGBADataLoader.d.ts.map +1 -1
  55. package/src/engine/asset/loaders/image/ImageRGBADataLoader.js +1 -1
  56. package/src/engine/asset/loaders/image/codec/NativeImageDecoder.d.ts +1 -1
  57. package/src/engine/asset/loaders/image/codec/NativeImageDecoder.d.ts.map +1 -1
  58. package/src/engine/asset/loaders/image/codec/NativeImageDecoder.js +1 -1
  59. package/src/engine/asset/loaders/image/codec/ThreadedImageDecoder.d.ts +1 -1
  60. package/src/engine/asset/loaders/image/codec/ThreadedImageDecoder.d.ts.map +1 -1
  61. package/src/engine/asset/loaders/image/codec/ThreadedImageDecoder.js +1 -1
  62. package/src/engine/graphics/texture/atlas/TextureAtlas.d.ts.map +1 -1
  63. package/src/engine/graphics/texture/atlas/TextureAtlas.js +13 -0
  64. package/src/engine/physics/broadphase/generate_pairs.d.ts.map +1 -1
  65. package/src/engine/physics/broadphase/generate_pairs.js +7 -1
  66. package/src/engine/physics/fluid/ecs/FluidObstacleSystem.d.ts +4 -4
  67. package/src/ktx2/KTX2_IDENTIFIER.d.ts +14 -0
  68. package/src/ktx2/KTX2_IDENTIFIER.d.ts.map +1 -0
  69. package/src/ktx2/KTX2_IDENTIFIER.js +15 -0
  70. package/src/ktx2/KhrDfModel.d.ts +35 -0
  71. package/src/ktx2/KhrDfModel.d.ts.map +1 -0
  72. package/src/ktx2/KhrDfModel.js +49 -0
  73. package/src/ktx2/SupercompressionScheme.d.ts +16 -0
  74. package/src/ktx2/SupercompressionScheme.d.ts.map +1 -0
  75. package/src/ktx2/SupercompressionScheme.js +23 -0
  76. package/src/ktx2/ktx2_read.d.ts +71 -0
  77. package/src/ktx2/ktx2_read.d.ts.map +1 -0
  78. package/src/ktx2/ktx2_read.js +581 -0
  79. package/src/ktx2/ktx2_read_basis_lz_global_data.d.ts +27 -0
  80. package/src/ktx2/ktx2_read_basis_lz_global_data.d.ts.map +1 -0
  81. package/src/ktx2/ktx2_read_basis_lz_global_data.js +189 -0
  82. package/src/ktx2/vk_format_to_texture_format.d.ts +55 -0
  83. package/src/ktx2/vk_format_to_texture_format.d.ts.map +1 -0
  84. package/src/ktx2/vk_format_to_texture_format.js +290 -0
  85. package/src/shade/descriptor/GPUTextureUsage.d.ts +23 -0
  86. package/src/shade/descriptor/GPUTextureUsage.d.ts.map +1 -0
  87. package/src/shade/descriptor/GPUTextureUsage.js +24 -0
  88. package/src/shade/descriptor/WebGPUExtensionType.d.ts +3 -0
  89. package/src/shade/descriptor/WebGPUExtensionType.js +26 -0
  90. package/src/shade/descriptor/texture/TextureDescriptor.d.ts +2 -6
  91. package/src/shade/descriptor/texture/TextureDescriptor.d.ts.map +1 -1
  92. package/src/shade/descriptor/texture/TextureDescriptor.js +24 -40
  93. package/src/shade/descriptor/texture/format/TextureFormat.d.ts +125 -0
  94. package/src/shade/descriptor/texture/format/TextureFormat.d.ts.map +1 -0
  95. package/src/shade/descriptor/texture/format/TextureFormat.js +146 -0
  96. package/src/shade/descriptor/texture/format/gpu_texture_format_info.d.ts +60 -0
  97. package/src/shade/descriptor/texture/format/gpu_texture_format_info.d.ts.map +1 -1
  98. package/src/shade/descriptor/texture/format/gpu_texture_format_info.js +102 -0
  99. package/src/shade/device/graph/TextureResourceDescriptor.d.ts.map +1 -1
  100. package/src/shade/device/graph/TextureResourceDescriptor.js +160 -155
  101. package/src/shade/device/mock/SoftwareGPUQueue.d.ts +11 -4
  102. package/src/shade/device/mock/SoftwareGPUQueue.d.ts.map +1 -1
  103. package/src/shade/device/mock/SoftwareGPUQueue.js +31 -37
  104. package/src/shade/device/mock/SoftwareGPUTexture.d.ts.map +1 -1
  105. package/src/shade/device/mock/SoftwareGPUTexture.js +8 -4
  106. package/src/shade/device/timing/profile/frame_graph_extract_topology.d.ts.map +1 -1
  107. package/src/shade/device/timing/profile/frame_graph_extract_topology.js +164 -174
  108. package/src/shade/playground/ground_seam/README.md +235 -0
  109. package/src/shade/playground/ground_seam/capture_scene_color.d.ts +45 -0
  110. package/src/shade/playground/ground_seam/capture_scene_color.d.ts.map +1 -0
  111. package/src/shade/playground/ground_seam/capture_scene_color.js +242 -0
  112. package/src/shade/playground/ground_seam/depth_step_across_seam.d.ts +11 -0
  113. package/src/shade/playground/ground_seam/depth_step_across_seam.d.ts.map +1 -0
  114. package/src/shade/playground/ground_seam/depth_step_across_seam.js +185 -0
  115. package/src/shade/playground/ground_seam/index.html +195 -0
  116. package/src/shade/playground/ground_seam/main.d.ts +2 -0
  117. package/src/shade/playground/ground_seam/main.d.ts.map +1 -0
  118. package/src/shade/playground/ground_seam/main.js +879 -0
  119. package/src/shade/playground/ground_seam/make_grid_texture.d.ts +29 -0
  120. package/src/shade/playground/ground_seam/make_grid_texture.d.ts.map +1 -0
  121. package/src/shade/playground/ground_seam/make_grid_texture.js +105 -0
  122. package/src/shade/playground/ground_seam/make_ground.d.ts +70 -0
  123. package/src/shade/playground/ground_seam/make_ground.d.ts.map +1 -0
  124. package/src/shade/playground/ground_seam/make_ground.js +161 -0
  125. package/src/shade/playground/ground_seam/measure_flicker.d.ts +71 -0
  126. package/src/shade/playground/ground_seam/measure_flicker.d.ts.map +1 -0
  127. package/src/shade/playground/ground_seam/measure_flicker.js +531 -0
  128. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  129. package/src/shade/renderer/Renderer.js +2519 -2511
  130. package/src/shade/renderer/hiz/HierarchicalZBuffer.d.ts.map +1 -1
  131. package/src/shade/renderer/hiz/HierarchicalZBuffer.js +275 -274
  132. package/src/shade/renderer/light/GPULightCollection.d.ts.map +1 -1
  133. package/src/shade/renderer/light/GPULightCollection.js +323 -314
  134. package/src/shade/renderer/lightmap/GPULightMap.js +336 -336
  135. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.d.ts.map +1 -1
  136. package/src/shade/renderer/material/resident/GPUResidentMaterialContext.js +12 -2
  137. package/src/shade/renderer/material/standard/fragment_gbuffer.d.ts.map +1 -1
  138. package/src/shade/renderer/material/standard/fragment_gbuffer.js +3 -1
  139. package/src/shade/renderer/material/standard/fragment_gbuffer_vt.js +169 -169
  140. package/src/shade/renderer/path_tracer/shader/chunk_sample_material_data.d.ts.map +1 -1
  141. package/src/shade/renderer/path_tracer/shader/chunk_sample_material_data.js +3 -1
  142. package/src/shade/renderer/postprocess/nss/NSS.d.ts +1 -1
  143. package/src/shade/renderer/rasterize/native/oit/chunk_forward_shade_standard_fragment_brick4.d.ts.map +1 -1
  144. package/src/shade/renderer/rasterize/native/oit/chunk_forward_shade_standard_fragment_brick4.js +3 -1
  145. package/src/shade/renderer/rasterize/native/oit/chunk_forward_shade_standard_fragment_ibl.d.ts.map +1 -1
  146. package/src/shade/renderer/rasterize/native/oit/chunk_forward_shade_standard_fragment_ibl.js +3 -1
  147. package/src/shade/renderer/shader/chunk/geometry/normal/chunk_decode_tangent_normal.d.ts +28 -0
  148. package/src/shade/renderer/shader/chunk/geometry/normal/chunk_decode_tangent_normal.d.ts.map +1 -0
  149. package/src/shade/renderer/shader/chunk/geometry/normal/chunk_decode_tangent_normal.js +39 -0
  150. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_full.d.ts +45 -0
  151. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_full.d.ts.map +1 -1
  152. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_full.js +94 -61
  153. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_solve.d.ts +24 -0
  154. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_solve.d.ts.map +1 -0
  155. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_barycentric_solve.js +43 -0
  156. package/src/shade/renderer/texture/COMPRESSED_TEXTURE_PLAN_2026_09_01.md +1462 -0
  157. package/src/shade/renderer/texture/TextureManager.d.ts.map +1 -1
  158. package/src/shade/renderer/texture/TextureManager.js +20 -8
  159. package/src/shade/renderer/texture/TextureRole.d.ts +19 -0
  160. package/src/shade/renderer/texture/TextureRole.d.ts.map +1 -0
  161. package/src/shade/renderer/texture/TextureRole.js +26 -0
  162. package/src/shade/renderer/texture/TextureVariantSet.d.ts +71 -0
  163. package/src/shade/renderer/texture/TextureVariantSet.d.ts.map +1 -0
  164. package/src/shade/renderer/texture/TextureVariantSet.js +105 -0
  165. package/src/shade/renderer/texture/download_texture_data.d.ts.map +1 -1
  166. package/src/shade/renderer/texture/download_texture_data.js +22 -0
  167. package/src/shade/renderer/texture/format/gpu_texture_compression_support.d.ts +15 -0
  168. package/src/shade/renderer/texture/format/gpu_texture_compression_support.d.ts.map +1 -0
  169. package/src/shade/renderer/texture/format/gpu_texture_compression_support.js +50 -0
  170. package/src/shade/renderer/texture/format/gpu_texture_format_can_generate_mipmaps.d.ts +27 -0
  171. package/src/shade/renderer/texture/format/gpu_texture_format_can_generate_mipmaps.d.ts.map +1 -0
  172. package/src/shade/renderer/texture/format/gpu_texture_format_can_generate_mipmaps.js +65 -0
  173. package/src/shade/renderer/texture/format/infer_format_from_parameters.d.ts +11 -0
  174. package/src/shade/renderer/texture/format/infer_format_from_parameters.d.ts.map +1 -0
  175. package/src/shade/renderer/texture/format/infer_format_from_parameters.js +88 -0
  176. package/src/shade/renderer/texture/format/texture_format_from_shade_image.d.ts +24 -0
  177. package/src/shade/renderer/texture/format/texture_format_from_shade_image.d.ts.map +1 -0
  178. package/src/shade/renderer/texture/format/texture_format_from_shade_image.js +71 -0
  179. package/src/shade/renderer/texture/format/texture_format_preference.d.ts +22 -0
  180. package/src/shade/renderer/texture/format/texture_format_preference.d.ts.map +1 -0
  181. package/src/shade/renderer/texture/format/texture_format_preference.js +89 -0
  182. package/src/shade/renderer/texture/format/texture_variant_select.d.ts +24 -0
  183. package/src/shade/renderer/texture/format/texture_variant_select.d.ts.map +1 -0
  184. package/src/shade/renderer/texture/format/texture_variant_select.js +38 -0
  185. package/src/shade/renderer/texture/shade_texture_might_have_alpha.d.ts +9 -1
  186. package/src/shade/renderer/texture/shade_texture_might_have_alpha.d.ts.map +1 -1
  187. package/src/shade/renderer/texture/shade_texture_might_have_alpha.js +73 -8
  188. package/src/shade/renderer/texture/source/AlphaMode.d.ts +27 -0
  189. package/src/shade/renderer/texture/source/AlphaMode.d.ts.map +1 -0
  190. package/src/shade/renderer/texture/source/AlphaMode.js +52 -0
  191. package/src/shade/renderer/texture/source/ShadeImage.d.ts +33 -0
  192. package/src/shade/renderer/texture/source/ShadeImage.d.ts.map +1 -1
  193. package/src/shade/renderer/texture/source/ShadeImage.js +74 -0
  194. package/src/shade/renderer/texture/source/ShadeTexelData.d.ts +78 -0
  195. package/src/shade/renderer/texture/source/ShadeTexelData.d.ts.map +1 -0
  196. package/src/shade/renderer/texture/source/ShadeTexelData.js +158 -0
  197. package/src/shade/renderer/texture/texture_descriptor_from_texture_shade.d.ts +0 -10
  198. package/src/shade/renderer/texture/texture_descriptor_from_texture_shade.d.ts.map +1 -1
  199. package/src/shade/renderer/texture/texture_descriptor_from_texture_shade.js +73 -175
  200. package/src/shade/renderer/texture/texture_write_level.d.ts +32 -0
  201. package/src/shade/renderer/texture/texture_write_level.d.ts.map +1 -0
  202. package/src/shade/renderer/texture/texture_write_level.js +77 -0
  203. package/src/shade/renderer/texture/texture_write_to_gpu.d.ts +8 -1
  204. package/src/shade/renderer/texture/texture_write_to_gpu.d.ts.map +1 -1
  205. package/src/shade/renderer/texture/texture_write_to_gpu.js +62 -37
  206. package/src/shade/renderer/texture/virtual/source/VTSourceImage.d.ts +8 -0
  207. package/src/shade/renderer/texture/virtual/source/VTSourceImage.d.ts.map +1 -1
  208. package/src/shade/renderer/texture/virtual/source/VTSourceImage.js +8 -0
  209. package/src/shade/util/texture_mip_extent.d.ts +49 -0
  210. package/src/shade/util/texture_mip_extent.d.ts.map +1 -0
  211. package/src/shade/util/texture_mip_extent.js +87 -0
  212. package/src/avif/native/isobmff/ByteCursor.d.ts.map +0 -1
  213. package/src/engine/asset/loaders/image/codec/Codec.d.ts.map +0 -1
  214. package/src/engine/asset/loaders/image/codec/CodecWithFallback.d.ts.map +0 -1
  215. package/src/shade/util/compute_max_mip_count.d.ts +0 -8
  216. package/src/shade/util/compute_max_mip_count.d.ts.map +0 -1
  217. package/src/shade/util/compute_max_mip_count.js +0 -27
  218. /package/src/engine/asset/{loaders/image/codec → codec}/Codec.d.ts +0 -0
  219. /package/src/engine/asset/{loaders/image/codec → codec}/Codec.js +0 -0
  220. /package/src/engine/asset/{loaders/image/codec → codec}/CodecWithFallback.d.ts +0 -0
@@ -0,0 +1,879 @@
1
+ import Vector3 from "../../../core/geom/Vector3.js";
2
+ import { DEG_TO_RAD } from "../../../core/math/DEG_TO_RAD.js";
3
+ import { PerspectiveCamera } from "../../renderer/camera/PerspectiveCamera.js";
4
+ import { create_frame_loop } from "../../renderer/create_frame_loop.js";
5
+ import { make_sunlight } from "../../renderer/light/make_sunlight.js";
6
+ import { Renderer } from "../../renderer/Renderer.js";
7
+ import { Scene } from "../../renderer/scene/Scene.js";
8
+ import { load_default_environment } from "../load_default_environment.js";
9
+ import { make_grid_texture } from "./make_grid_texture.js";
10
+ import { GROUND_TRIANGULATION, make_ground } from "./make_ground.js";
11
+ import { FramePhase } from "../../renderer/extension/FramePhase.js";
12
+ import { CaptureSceneColor } from "./capture_scene_color.js";
13
+ import { depth_step_across_seam } from "./depth_step_across_seam.js";
14
+ import { attach_measurement, classify_ground, screen_line_distance } from "./measure_flicker.js";
15
+
16
+ /**
17
+ * A ground quad that shimmers on one side of its own triangle seam — as a page that can measure it.
18
+ *
19
+ * The report this page was built from was written from *outside* the engine, by screenshotting a
20
+ * demo through CDP, and three of its conclusions had to be retracted: two were stated before they
21
+ * were measured, and one came from a sign error in a hand-rolled camera basis. So the point of this
22
+ * page is not to look at the artifact. It is to put every claim about the artifact on a number that
23
+ * comes out of the renderer's own state:
24
+ *
25
+ * - the picture is read back from a texture this page owns, not scraped off the canvas;
26
+ * - the frames are driven from JS, so the burst length is a number chosen here rather than one the
27
+ * compositor handed over;
28
+ * - pixels are assigned to triangles by unprojecting through `camera.view_projection_matrix` and
29
+ * intersecting the ground plane, and the unprojection round-trips itself and reports the error.
30
+ *
31
+ * `npm run dev` serves it. HMR is off across the playground by design — an edit anywhere in the
32
+ * module graph would otherwise reset the temporal accumulation this page exists to measure.
33
+ */
34
+
35
+ /**
36
+ * The camera the artifact was reported at, and which the report says it is sensitive to.
37
+ *
38
+ * Distance 45 at 0.9 rad above the horizontal: `45·sin(0.9) = 35.25` up and `45·cos(0.9) = 27.97`
39
+ * back. Written out as a position rather than as pitch/yaw so it does not depend on which of the
40
+ * two conventions ("elevation" or "polar angle") a helper happens to mean by *pitch*.
41
+ */
42
+ const CAMERA = {
43
+ position: new Vector3(0, 35.25, 27.97),
44
+ target: new Vector3(0, 0, 0),
45
+ fov_degrees: 45,
46
+ near: 0.1,
47
+ far: 100,
48
+ };
49
+
50
+ /**
51
+ * Viewport the report says the seam is clearest at, at a pixel ratio of 1.
52
+ *
53
+ * Resolution-dependence is one of its claims, so this is a control rather than the window size.
54
+ */
55
+ const VIEWPORT = { width: 1258, height: 566 };
56
+
57
+ /**
58
+ * @param {number} value
59
+ * @param {number} [digits]
60
+ * @returns {string}
61
+ */
62
+ function fixed(value, digits = 4) {
63
+ return Number.isFinite(value) ? value.toFixed(digits) : String(value);
64
+ }
65
+
66
+ export async function start() {
67
+
68
+ const element = id => document.getElementById(id);
69
+
70
+ const report_element = element('report');
71
+
72
+ /**
73
+ * @param {string} text
74
+ */
75
+ function report(text) {
76
+ report_element.textContent = text;
77
+ console.log(text);
78
+ }
79
+
80
+ const canvas = element('view');
81
+ const diff_canvas = element('diff');
82
+
83
+ const context = canvas.getContext('webgpu');
84
+
85
+ if (context === null) {
86
+ report('no WebGPU context — this browser has none, or the page is not a secure context');
87
+ return;
88
+ }
89
+
90
+ const renderer = new Renderer();
91
+
92
+ report('acquiring a device…');
93
+
94
+ await renderer.initialize({ context, pixelRatio: 1 });
95
+
96
+ /*
97
+ Auto-exposure is the single most important thing to pin. It keeps drifting for seconds after
98
+ any change, by more than the signal being measured is worth, and a burst taken while it drifts
99
+ reports the drift rather than the artifact.
100
+ */
101
+ renderer.feature_automatic_exposure_enabled = false;
102
+
103
+ const camera = new PerspectiveCamera();
104
+
105
+ camera.near = CAMERA.near;
106
+ camera.far = CAMERA.far;
107
+ camera.fov = CAMERA.fov_degrees * DEG_TO_RAD;
108
+ camera.transform.position.copy(CAMERA.position);
109
+ camera.transform.lookAt(CAMERA.target);
110
+ camera.transform.updateMatrix();
111
+
112
+ const scene = new Scene();
113
+
114
+ const sun = make_sunlight({
115
+ intensity: 0.9,
116
+ direction: new Vector3(0.17, -1, 0.17),
117
+ temperature: 5500,
118
+ });
119
+
120
+ scene.lights.add(sun);
121
+
122
+ /**
123
+ * Two textures differing only in whether the sampler is granted anisotropy, built up front so
124
+ * switching between them costs no upload and cannot perturb the temporal history by more than
125
+ * the swap itself.
126
+ *
127
+ * @type {Record<string, ShadeTexture>}
128
+ */
129
+ const albedos = {
130
+ anisotropic: await make_grid_texture(256, true),
131
+ isotropic: await make_grid_texture(256, false),
132
+ };
133
+
134
+ /** @type {Mesh|undefined} */
135
+ let ground;
136
+
137
+ /**
138
+ * Swap the ground for one built with different topology. Everything else — the camera, the
139
+ * light, the texture, the material constants — is untouched, so a difference between two builds
140
+ * is a difference in how the quad is cut up and nothing else.
141
+ *
142
+ * @param {number} segments
143
+ * @param {string} triangulation key of {@link GROUND_TRIANGULATION}
144
+ * @param {number} [yaw_degrees] rotation about +Y; 180 swaps the two triangles without moving
145
+ * the seam they share
146
+ * @param {boolean} [anisotropy] whether the sampler is granted 16× anisotropy
147
+ * @param {number} [extent] edge length of the quad, which is what sets how badly conditioned the
148
+ * reconstruction is
149
+ */
150
+ function rebuild_ground(segments, triangulation, yaw_degrees = 0, anisotropy = true, extent = 425) {
151
+ if (ground !== undefined) {
152
+ scene.remove(ground);
153
+ }
154
+
155
+ ground = make_ground({
156
+ albedo: albedos[anisotropy ? 'anisotropic' : 'isotropic'],
157
+ extent,
158
+ segments,
159
+ indices: GROUND_TRIANGULATION[triangulation],
160
+ yaw_degrees,
161
+ });
162
+
163
+ scene.add(ground);
164
+
165
+ /*
166
+ Proof the index buffer actually reached the GPU differently, published so a null result on
167
+ an index-order A/B cannot be mistaken for 'the reconstruction does not care'. The meshlet
168
+ builder is free to reorder, and a builder that normalised the order away would make every
169
+ such test silently vacuous.
170
+ */
171
+ globalThis.__meshlet_signature = Array.from(
172
+ new Uint8Array(ground.geometry.meshlets.data_buffer)
173
+ ).join(',');
174
+
175
+ renderer.indicate_view_change();
176
+ }
177
+
178
+ rebuild_ground(1, 'default');
179
+
180
+ const measurement = attach_measurement({ renderer, scene, camera });
181
+
182
+ // handy from the console
183
+ Object.assign(globalThis, { renderer, scene, camera, measurement, classify_ground });
184
+
185
+ // ---- controls ---------------------------------------------------------------------------
186
+
187
+ let paused = false;
188
+
189
+ /**
190
+ * Edge length of the quad currently in the scene. Held here because the pixel classifier needs
191
+ * the half-extent to decide what is ground, and a stale value there would silently label the
192
+ * far half of a big quad as sky.
193
+ *
194
+ * @type {number}
195
+ */
196
+ let ground_extent = 425;
197
+
198
+ function apply_settings() {
199
+ renderer.feature_taa_enabled = element('taa').checked;
200
+ renderer.feature_ssao_enabled = element('ssao').checked;
201
+ renderer.feature_shadows_enabled = element('shadows').checked;
202
+ renderer.feature_sharpening_enabled = element('sharpening').checked;
203
+ renderer.feature_automatic_exposure_enabled = element('exposure').checked;
204
+
205
+ /*
206
+ Not a look control. The readback is 8-bit `bgra8unorm` — the display is not HDR — so one
207
+ code value is 1/255, and a ground sitting at 0.06 luma leaves the whole measurement two
208
+ code values wide. Lifting the fixed exposure puts the image in the middle of the range the
209
+ readback can actually resolve, without touching the light, the material or the sampler.
210
+ */
211
+ renderer.exposure_compensation = Number(element('exposure_compensation').value);
212
+
213
+ VIEWPORT.width = Math.max(2, Number(element('width').value) | 0);
214
+ VIEWPORT.height = Math.max(2, Number(element('height').value) | 0);
215
+
216
+ ground_extent = Number(element('extent').value);
217
+
218
+ rebuild_ground(
219
+ Number(element('segments').value),
220
+ element('triangulation').value,
221
+ Number(element('yaw').value),
222
+ element('anisotropy').checked,
223
+ ground_extent
224
+ );
225
+ }
226
+
227
+ element('apply').addEventListener('click', () => {
228
+ apply_settings();
229
+ report('applied — the temporal history was dropped, so settle before measuring');
230
+ });
231
+
232
+ /**
233
+ * Set controls by name and apply them, for driving an A/B from a script rather than by hand.
234
+ *
235
+ * The controls are the single source of truth for what the page is configured as — a script
236
+ * that reached past them and assigned renderer fields directly would leave the panel describing
237
+ * a state the renderer is not in, and the report is built from the panel.
238
+ *
239
+ * @param {Record<string, string|number|boolean>} settings keyed by input id
240
+ * @returns {Promise<void>}
241
+ */
242
+ globalThis.configure = async settings => {
243
+ for (const [id, value] of Object.entries(settings)) {
244
+ const input = element(id);
245
+
246
+ if (input === null) {
247
+ throw new Error(`no control named '${id}'`);
248
+ }
249
+
250
+ if (input.type === 'checkbox') {
251
+ if (input.checked !== Boolean(value)) {
252
+ input.checked = Boolean(value);
253
+ input.dispatchEvent(new Event('change'));
254
+ }
255
+ } else {
256
+ input.value = String(value);
257
+ }
258
+ }
259
+
260
+ apply_settings();
261
+
262
+ // the environment handler is async and fires off `change`; give it a turn to land
263
+ await new Promise(resolve => setTimeout(resolve, 0));
264
+ };
265
+
266
+ let environment_loaded = false;
267
+
268
+ element('environment').addEventListener('change', async event => {
269
+ if (event.target.checked) {
270
+ if (!environment_loaded) {
271
+ environment_loaded = await load_default_environment(scene);
272
+ }
273
+ } else {
274
+ scene.lights.environment = undefined;
275
+ }
276
+
277
+ renderer.indicate_view_change();
278
+ });
279
+
280
+ // ---- measurement ------------------------------------------------------------------------
281
+
282
+ /**
283
+ * Settle, take a burst, and describe the spread across it — overall, and split by which of the
284
+ * ground quad's triangles covers each pixel.
285
+ *
286
+ * @returns {Promise<object>} also written to `globalThis.__last_measurement`
287
+ */
288
+ async function measure() {
289
+ paused = true;
290
+
291
+ const settle_frames = Number(element('settle').value) | 0;
292
+ const frames = Number(element('frames').value) | 0;
293
+ const gain = Number(element('gain').value);
294
+
295
+ const { spread, mean, width, height } = await measurement.burst({ settle_frames, frames });
296
+
297
+ /*
298
+ The camera's matrices are whatever the last frame left them as, which is what the pixels
299
+ were drawn with — so the classification is taken from the same state, not recomputed from
300
+ the authored numbers.
301
+ */
302
+ const {
303
+ side,
304
+ side_flipped,
305
+ round_trip_error_px,
306
+ covered
307
+ } = classify_ground(camera, width, height, ground_extent * 0.5);
308
+
309
+ const triangulation = element('triangulation').value;
310
+
311
+ /**
312
+ * Split the frame by a labelling and describe the spread on each side.
313
+ *
314
+ * Both the *absolute* spread and the spread *relative to the local mean* are reported. The
315
+ * two halves of this view are not equally lit — one is more oblique than the other, so it
316
+ * is dimmer and more anisotropically filtered whatever the index buffer says — and an
317
+ * absolute ratio would charge that difference to the artifact. The relative figure is the
318
+ * one that survives it.
319
+ *
320
+ * @param {Int8Array} labels
321
+ * @returns {object}
322
+ */
323
+ function split(labels) {
324
+ const buckets = {
325
+ negative: { count: 0, total: 0, relative_total: 0, max: 0, luma_total: 0 },
326
+ positive: { count: 0, total: 0, relative_total: 0, max: 0, luma_total: 0 },
327
+ sky: { count: 0, total: 0, relative_total: 0, max: 0, luma_total: 0 },
328
+ };
329
+
330
+ for (let p = 0; p < labels.length; p++) {
331
+ const bucket = labels[p] === 0
332
+ ? buckets.sky
333
+ : (labels[p] < 0 ? buckets.negative : buckets.positive);
334
+
335
+ bucket.count++;
336
+ bucket.total += spread[p];
337
+ bucket.luma_total += mean[p];
338
+
339
+ // guard against a pixel that is exactly black, where the ratio is meaningless
340
+ if (mean[p] > 1e-4) {
341
+ bucket.relative_total += spread[p] / mean[p];
342
+ }
343
+
344
+ if (spread[p] > bucket.max) {
345
+ bucket.max = spread[p];
346
+ }
347
+ }
348
+
349
+ for (const bucket of Object.values(buckets)) {
350
+ bucket.mean = bucket.count > 0 ? bucket.total / bucket.count : 0;
351
+ bucket.relative = bucket.count > 0 ? bucket.relative_total / bucket.count : 0;
352
+ bucket.mean_luma = bucket.count > 0 ? bucket.luma_total / bucket.count : 0;
353
+ }
354
+
355
+ const { negative, positive } = buckets;
356
+
357
+ return {
358
+ negative,
359
+ positive,
360
+ sky: buckets.sky,
361
+ ratio: negative.mean > 0 && positive.mean > 0
362
+ ? Math.max(negative.mean, positive.mean) / Math.min(negative.mean, positive.mean)
363
+ : null,
364
+ relative_ratio: negative.relative > 0 && positive.relative > 0
365
+ ? Math.max(negative.relative, positive.relative)
366
+ / Math.min(negative.relative, positive.relative)
367
+ : null,
368
+ };
369
+ }
370
+
371
+ /*
372
+ Both diagonals, always. The claim under test is that the instability is bound to the index
373
+ buffer, so the interesting comparison is between the split along the diagonal that *is* in
374
+ the mesh and the split along the one that is not — the second is a control cut through the
375
+ same picture, and if it shows the same asymmetry then the asymmetry is about the view, not
376
+ about the triangulation.
377
+ */
378
+ const along_default = split(side);
379
+ const along_flipped = split(side_flipped);
380
+
381
+ const stats = triangulation === 'flipped' ? along_flipped : along_default;
382
+
383
+ // ---- the difference map
384
+
385
+ diff_canvas.width = width;
386
+ diff_canvas.height = height;
387
+
388
+ const ctx = diff_canvas.getContext('2d');
389
+ const image = ctx.createImageData(width, height);
390
+
391
+ for (let p = 0; p < spread.length; p++) {
392
+ const v = Math.min(255, Math.round(spread[p] * gain * 255));
393
+
394
+ const i = p * 4;
395
+
396
+ image.data[i] = v;
397
+ image.data[i + 1] = v;
398
+ image.data[i + 2] = v;
399
+ image.data[i + 3] = 255;
400
+ }
401
+
402
+ ctx.putImageData(image, 0, 0);
403
+
404
+ const result = {
405
+ viewport: [width, height],
406
+ internal_resolution_scale: renderer.internal_resolution_scale,
407
+ segments: Number(element('segments').value),
408
+ triangulation,
409
+ taa: renderer.feature_taa_enabled,
410
+ settle_frames,
411
+ frames,
412
+ round_trip_error_px,
413
+ ground_pixels: covered,
414
+ stats,
415
+ along_default,
416
+ along_flipped,
417
+ ratio: stats.ratio,
418
+ relative_ratio: stats.relative_ratio,
419
+ };
420
+
421
+ globalThis.__last_measurement = result;
422
+
423
+ /*
424
+ The raw fields, kept off the summary object so a script can do its own analysis — a profile
425
+ across the seam, a different labelling, a histogram — without the page having to anticipate
426
+ it. Summaries are where a real structure goes to hide: two halves that differ by 2× in the
427
+ mean can be a step at the seam or a smooth gradient, and only the field tells you which.
428
+ */
429
+ globalThis.__last_fields = { spread, mean, side, side_flipped, width, height };
430
+
431
+ /*
432
+ The profile is what separates "a seam" from "one half of the frame is more oblique than the
433
+ other". A half-plane mean reports the same 2× either way; a step at ±1 px does not.
434
+ */
435
+ result.profile = {
436
+ 'x = -z': profile_across([1, -1] /* world direction of the default seam */),
437
+ 'x = +z': profile_across([1, 1]),
438
+ };
439
+
440
+ /**
441
+ * Mean spread and mean relative spread in 1-pixel bands of signed screen distance from a
442
+ * world line on the ground plane.
443
+ *
444
+ * @param {number[]} direction the line's XZ direction
445
+ * @returns {object}
446
+ */
447
+ function profile_across(direction) {
448
+ const distance = screen_line_distance(camera, width, height, direction);
449
+
450
+ const RANGE = 40;
451
+
452
+ const bins = new Float64Array(2 * RANGE);
453
+ const bins_relative = new Float64Array(2 * RANGE);
454
+ const counts = new Float64Array(2 * RANGE);
455
+
456
+ for (let py = 0; py < height; py++) {
457
+ for (let px = 0; px < width; px++) {
458
+ const d = distance(px, py);
459
+
460
+ const bin = Math.floor(d) + RANGE;
461
+
462
+ if (bin < 0 || bin >= bins.length) {
463
+ continue;
464
+ }
465
+
466
+ const p = py * width + px;
467
+
468
+ bins[bin] += spread[p];
469
+
470
+ if (mean[p] > 1e-4) {
471
+ bins_relative[bin] += spread[p] / mean[p];
472
+ }
473
+
474
+ counts[bin]++;
475
+ }
476
+ }
477
+
478
+ const relative = [];
479
+
480
+ for (let i = 0; i < bins.length; i++) {
481
+ relative.push(counts[i] > 0 ? bins_relative[i] / counts[i] : null);
482
+ }
483
+
484
+ return { offset: -RANGE, relative, counts: Array.from(counts) };
485
+ }
486
+
487
+ report([
488
+ `viewport ${width} × ${height}`,
489
+ `ground pixels ${covered}`,
490
+ `unproject round-trip ${fixed(round_trip_error_px, 5)} px (must be ≪ 1)`,
491
+ `segments ${result.segments} triangulation ${triangulation}`,
492
+ `TAA ${result.taa}`,
493
+ ``,
494
+ `spread, side − ${fixed(stats.negative.mean, 6)} rel ${fixed(stats.negative.relative, 5)} luma ${fixed(stats.negative.mean_luma, 4)}`,
495
+ `spread, side + ${fixed(stats.positive.mean, 6)} rel ${fixed(stats.positive.relative, 5)} luma ${fixed(stats.positive.mean_luma, 4)}`,
496
+ `spread, sky ${fixed(stats.sky.mean, 6)} (n=${stats.sky.count})`,
497
+ ``,
498
+ `asymmetry, x = −z ${along_default.relative_ratio === null ? 'n/a' : fixed(along_default.relative_ratio, 3)} (absolute ${fixed(along_default.ratio, 3)})`,
499
+ `asymmetry, x = +z ${along_flipped.relative_ratio === null ? 'n/a' : fixed(along_flipped.relative_ratio, 3)} (absolute ${fixed(along_flipped.ratio, 3)})`,
500
+ ].join('\n'));
501
+
502
+ paused = false;
503
+
504
+ return result;
505
+ }
506
+
507
+ /**
508
+ * Read the depth buffer and ask whether the two triangles' planes agree along the edge they
509
+ * share, and whether the depth a pixel is written with is stable frame to frame.
510
+ *
511
+ * Both halves matter and they answer different questions. A *step* at the seam would mean the
512
+ * rasterizer interpolated the shared edge two different ways, which is what the brief's
513
+ * candidate chain needs in order to start. *Instability* across frames is what a depth-driven
514
+ * history rejection would actually be reacting to — and with the camera still, the only thing
515
+ * that moves a pixel's depth is the sub-pixel jitter, so the two triangles should wobble by the
516
+ * same amount unless something about them differs.
517
+ *
518
+ * @param {number} [frames] how many frames of depth to compare
519
+ * @returns {Promise<object>}
520
+ */
521
+ async function probe_depth(frames = 16) {
522
+ paused = true;
523
+
524
+ await measurement.settle(Number(element('settle').value) | 0);
525
+
526
+ const first = await measurement.read_depth();
527
+
528
+ const { width, height } = first;
529
+
530
+ const { side, side_flipped } = classify_ground(camera, width, height, ground_extent * 0.5);
531
+
532
+ const triangulation = element('triangulation').value;
533
+ const labels = triangulation === 'flipped' ? side_flipped : side;
534
+
535
+ const distance = screen_line_distance(
536
+ camera, width, height,
537
+ triangulation === 'flipped' ? [1, 1] : [1, -1]
538
+ );
539
+
540
+ const step = depth_step_across_seam(first.depth, labels, distance, width, height);
541
+
542
+ // per-pixel spread of depth across a burst, split the same way the colour spread is
543
+ const min = Float32Array.from(first.depth);
544
+ const max = Float32Array.from(first.depth);
545
+
546
+ for (let i = 1; i < frames; i++) {
547
+ measurement.frame();
548
+
549
+ const { depth } = await measurement.read_depth();
550
+
551
+ for (let p = 0; p < depth.length; p++) {
552
+ if (depth[p] < min[p]) {
553
+ min[p] = depth[p];
554
+ }
555
+
556
+ if (depth[p] > max[p]) {
557
+ max[p] = depth[p];
558
+ }
559
+ }
560
+ }
561
+
562
+ const wobble = { negative: 0, positive: 0 };
563
+ const counts = { negative: 0, positive: 0 };
564
+
565
+ for (let p = 0; p < min.length; p++) {
566
+ if (labels[p] === 0) {
567
+ continue;
568
+ }
569
+
570
+ const bucket = labels[p] < 0 ? 'negative' : 'positive';
571
+
572
+ // relative to the depth itself: an absolute wobble means nothing without the magnitude
573
+ // it sits on, and depth here runs over two orders of magnitude across the frame
574
+ if (max[p] > 0) {
575
+ wobble[bucket] += (max[p] - min[p]) / max[p];
576
+ counts[bucket]++;
577
+ }
578
+ }
579
+
580
+ const result = {
581
+ resolution: [width, height],
582
+ triangulation,
583
+ segments: Number(element('segments').value),
584
+ depth_range: [Math.min(...first.depth.subarray(0, 4096)), Math.max(...first.depth.subarray(0, 4096))],
585
+ step,
586
+ depth_wobble: {
587
+ negative: counts.negative > 0 ? wobble.negative / counts.negative : 0,
588
+ positive: counts.positive > 0 ? wobble.positive / counts.positive : 0,
589
+ },
590
+ };
591
+
592
+ globalThis.__last_depth = result;
593
+
594
+ paused = false;
595
+
596
+ return result;
597
+ }
598
+
599
+ globalThis.probe_depth = probe_depth;
600
+
601
+ /**
602
+ * Two captures of the same record at two phases: the temporal filter's input and its output.
603
+ *
604
+ * Registered separately rather than re-phased in place — the registry resolves ordering at
605
+ * registration, so a phase assigned afterwards would be ignored.
606
+ *
607
+ * @type {Record<string, CaptureSceneColor>}
608
+ */
609
+ const captures = {
610
+ pre_taa: new CaptureSceneColor(renderer.device),
611
+ post_taa: new CaptureSceneColor(renderer.device),
612
+ };
613
+
614
+ captures.pre_taa.name = 'ground_seam/capture pre-TAA';
615
+ captures.pre_taa.phase = FramePhase.AfterTransparency;
616
+
617
+ captures.post_taa.name = 'ground_seam/capture post-TAA';
618
+ captures.post_taa.phase = FramePhase.BeforePresent;
619
+
620
+ renderer.add_extension(captures.pre_taa);
621
+ renderer.add_extension(captures.post_taa);
622
+
623
+ const capture_scene_color = captures.pre_taa;
624
+
625
+ /**
626
+ * The same burst measurement, taken on the temporal filter's **input** instead of its output.
627
+ *
628
+ * This is the experiment that splits the seam in two. If the pre-TAA frame already differs
629
+ * across the edge — one triangle's shading swinging more from jitter phase to jitter phase than
630
+ * the other's — then the fault is in what the material pass reconstructs per triangle, and TAA
631
+ * is only making a real difference visible. If the pre-TAA variation is flat across the edge
632
+ * and the post-TAA one is not, the shading agrees and the temporal filter is treating the two
633
+ * triangles differently.
634
+ *
635
+ * The capture is HDR and linear, upstream of the tonemap, so there is no 8-bit floor under it
636
+ * the way there is under the colour readback.
637
+ *
638
+ * @param {number} [frames]
639
+ * @returns {Promise<object>}
640
+ */
641
+ async function probe_pre_taa(frames = 16, stage = 'pre_taa') {
642
+ paused = true;
643
+
644
+ const capture_scene_color = captures[stage];
645
+
646
+ capture_scene_color.enabled = true;
647
+
648
+ try {
649
+ await measurement.settle(Number(element('settle').value) | 0);
650
+
651
+ const first = await capture_scene_color.read_luminance();
652
+
653
+ const { width, height } = first;
654
+
655
+ const min = Float32Array.from(first.luma);
656
+ const max = Float32Array.from(first.luma);
657
+ const total = Float64Array.from(first.luma);
658
+
659
+ /*
660
+ Total variation along the burst — the sum of the absolute frame-to-frame steps. A range
661
+ cannot tell a value that sweeps once from one that alternates every frame, and those
662
+ two defeat a neighbourhood clamp very differently: the clamp rebuilds its box from the
663
+ current frame, so a value that jumps to the far end each frame keeps the history outside
664
+ the box while a slow sweep never does. Divided by the range it is 1 for a single sweep
665
+ and (frames − 1) for a full alternation.
666
+ */
667
+ const variation = new Float64Array(first.luma.length);
668
+
669
+ let previous = Float32Array.from(first.luma);
670
+
671
+ for (let i = 1; i < frames; i++) {
672
+ measurement.frame();
673
+
674
+ const { luma } = await capture_scene_color.read_luminance();
675
+
676
+ for (let p = 0; p < luma.length; p++) {
677
+ if (luma[p] < min[p]) {
678
+ min[p] = luma[p];
679
+ }
680
+
681
+ if (luma[p] > max[p]) {
682
+ max[p] = luma[p];
683
+ }
684
+
685
+ total[p] += luma[p];
686
+ variation[p] += Math.abs(luma[p] - previous[p]);
687
+ }
688
+
689
+ previous = luma;
690
+ }
691
+
692
+ const { side, side_flipped } = classify_ground(camera, width, height, ground_extent * 0.5);
693
+
694
+ const triangulation = element('triangulation').value;
695
+ const labels = triangulation === 'flipped' ? side_flipped : side;
696
+
697
+ const distance = screen_line_distance(
698
+ camera, width, height,
699
+ triangulation === 'flipped' ? [1, 1] : [1, -1]
700
+ );
701
+
702
+ /*
703
+ The 3×3 standard deviation of the *last* frame captured, which is the quantity TAA's
704
+ neighbourhood box is built from: `taa_history_color_bounding_box_YCoCg` takes the mean
705
+ and σ over exactly this footprint and clips the history to `mean ± γ·σ`. How hard the
706
+ history gets clipped — and therefore how much of the jittering current frame is let
707
+ through — is the ratio of the frame-to-frame swing to that σ, so it is measured beside
708
+ it rather than left to be inferred.
709
+ */
710
+ const { luma: last } = await capture_scene_color.read_luminance();
711
+
712
+ const sigma = new Float32Array(width * height);
713
+
714
+ for (let py = 1; py < height - 1; py++) {
715
+ for (let px = 1; px < width - 1; px++) {
716
+ let m1 = 0;
717
+ let m2 = 0;
718
+
719
+ for (let dy = -1; dy <= 1; dy++) {
720
+ for (let dx = -1; dx <= 1; dx++) {
721
+ const v = last[(py + dy) * width + px + dx];
722
+
723
+ m1 += v;
724
+ m2 += v * v;
725
+ }
726
+ }
727
+
728
+ m1 /= 9;
729
+ m2 /= 9;
730
+
731
+ sigma[py * width + px] = Math.sqrt(Math.max(0, m2 - m1 * m1));
732
+ }
733
+ }
734
+
735
+ const buckets = {
736
+ negative: { relative: 0, count: 0, luma: 0, sigma: 0, jaggedness: 0 },
737
+ positive: { relative: 0, count: 0, luma: 0, sigma: 0, jaggedness: 0 },
738
+ };
739
+
740
+ const RANGE = 20;
741
+
742
+ const band_total = new Float64Array(2 * RANGE);
743
+ const band_sigma = new Float64Array(2 * RANGE);
744
+ const band_jagged = new Float64Array(2 * RANGE);
745
+ const band_count = new Float64Array(2 * RANGE);
746
+
747
+ for (let py = 1; py < height - 1; py++) {
748
+ for (let px = 1; px < width - 1; px++) {
749
+ const p = py * width + px;
750
+
751
+ if (labels[p] === 0) {
752
+ continue;
753
+ }
754
+
755
+ const m = total[p] / frames;
756
+
757
+ if (!(m > 1e-6)) {
758
+ continue;
759
+ }
760
+
761
+ const swing = max[p] - min[p];
762
+ const relative = swing / m;
763
+ const relative_sigma = sigma[p] / m;
764
+
765
+ /*
766
+ How many times the value crosses its own range over the burst. 1 is a single
767
+ sweep, `frames − 1` is a full alternation every frame. This is the shape a
768
+ range cannot see and the one a neighbourhood clamp cares about most.
769
+ */
770
+ const jaggedness = swing > 1e-9 ? variation[p] / swing : 0;
771
+
772
+ const bucket = labels[p] < 0 ? buckets.negative : buckets.positive;
773
+
774
+ bucket.relative += relative;
775
+ bucket.luma += m;
776
+ bucket.sigma += relative_sigma;
777
+ bucket.jaggedness += jaggedness;
778
+ bucket.count++;
779
+
780
+ const bin = Math.floor(distance(px, py)) + RANGE;
781
+
782
+ if (bin >= 0 && bin < band_total.length) {
783
+ band_total[bin] += relative;
784
+ band_sigma[bin] += relative_sigma;
785
+ band_jagged[bin] += jaggedness;
786
+ band_count[bin]++;
787
+ }
788
+ }
789
+ }
790
+
791
+ for (const bucket of Object.values(buckets)) {
792
+ bucket.mean_relative = bucket.count > 0 ? bucket.relative / bucket.count : 0;
793
+ bucket.mean_luma = bucket.count > 0 ? bucket.luma / bucket.count : 0;
794
+ bucket.mean_sigma = bucket.count > 0 ? bucket.sigma / bucket.count : 0;
795
+ bucket.mean_jaggedness = bucket.count > 0 ? bucket.jaggedness / bucket.count : 0;
796
+ }
797
+
798
+ const profile = [];
799
+ const profile_sigma = [];
800
+ const profile_jagged = [];
801
+
802
+ for (let i = 0; i < band_total.length; i++) {
803
+ profile.push(band_count[i] > 0 ? band_total[i] / band_count[i] : null);
804
+ profile_sigma.push(band_count[i] > 0 ? band_sigma[i] / band_count[i] : null);
805
+ profile_jagged.push(band_count[i] > 0 ? band_jagged[i] / band_count[i] : null);
806
+ }
807
+
808
+ const result = {
809
+ resolution: [width, height],
810
+ stage,
811
+ triangulation,
812
+ segments: Number(element('segments').value),
813
+ frames,
814
+ negative: buckets.negative,
815
+ positive: buckets.positive,
816
+ ratio: buckets.positive.mean_relative > 0
817
+ ? buckets.negative.mean_relative / buckets.positive.mean_relative
818
+ : null,
819
+ sigma_ratio: buckets.positive.mean_sigma > 0
820
+ ? buckets.negative.mean_sigma / buckets.positive.mean_sigma
821
+ : null,
822
+ jaggedness_ratio: buckets.positive.mean_jaggedness > 0
823
+ ? buckets.negative.mean_jaggedness / buckets.positive.mean_jaggedness
824
+ : null,
825
+ profile: {
826
+ offset: -RANGE,
827
+ relative: profile,
828
+ sigma: profile_sigma,
829
+ jaggedness: profile_jagged,
830
+ },
831
+ };
832
+
833
+ globalThis.__last_pre_taa = result;
834
+
835
+ return result;
836
+ } finally {
837
+ capture_scene_color.enabled = false;
838
+ paused = false;
839
+ }
840
+ }
841
+
842
+ globalThis.probe_pre_taa = probe_pre_taa;
843
+
844
+ element('measure').addEventListener('click', () => {
845
+ element('measure').disabled = true;
846
+
847
+ measure()
848
+ .catch(error => {
849
+ report(String(error?.stack ?? error));
850
+ })
851
+ .finally(() => {
852
+ element('measure').disabled = false;
853
+ });
854
+ });
855
+
856
+ globalThis.measure = measure;
857
+
858
+ // ---- frame loop -------------------------------------------------------------------------
859
+
860
+ apply_settings();
861
+
862
+ create_frame_loop(time_delta_s => {
863
+ if (paused) {
864
+ return;
865
+ }
866
+
867
+ renderer.pixel_ratio = 1;
868
+ renderer.resize(VIEWPORT.width, VIEWPORT.height);
869
+
870
+ canvas.style.width = `${VIEWPORT.width}px`;
871
+ canvas.style.height = `${VIEWPORT.height}px`;
872
+
873
+ camera.aspect = VIEWPORT.width / VIEWPORT.height;
874
+
875
+ renderer.render(camera, scene, time_delta_s);
876
+ });
877
+
878
+ report('ready — press Measure');
879
+ }