@woosh/meep-engine 3.27.0 → 3.28.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 (255) hide show
  1. package/build/bundle-worker-terrain.js +1 -1
  2. package/package.json +1 -1
  3. package/src/core/geom/3d/mat4/m4_linear_determinant.d.ts +15 -0
  4. package/src/core/geom/3d/mat4/m4_linear_determinant.d.ts.map +1 -0
  5. package/src/core/geom/3d/mat4/m4_linear_determinant.js +24 -0
  6. package/src/core/geom/3d/mat4/m4_normal_matrix3.d.ts +4 -2
  7. package/src/core/geom/3d/mat4/m4_normal_matrix3.d.ts.map +1 -1
  8. package/src/core/geom/3d/mat4/m4_normal_matrix3.js +21 -11
  9. package/src/engine/Engine.d.ts.map +1 -1
  10. package/src/engine/Engine.js +38 -1
  11. package/src/engine/graphics3/GraphicsEngine.d.ts +5 -1
  12. package/src/engine/graphics3/GraphicsEngine.d.ts.map +1 -1
  13. package/src/engine/graphics3/GraphicsEngine.js +834 -830
  14. package/src/shade/RENDERER_CONTRACT.md +19 -10
  15. package/src/shade/device/timing/GPU_PROFILER_PROPOSAL_2026_08_28.md +1 -1
  16. package/src/shade/playground/vgeo_runtime/README.md +27 -13
  17. package/src/shade/playground/vgeo_runtime/main.js +18 -5
  18. package/src/shade/playground/vgeo_runtime/sample_asset.d.ts +18 -0
  19. package/src/shade/playground/vgeo_runtime/sample_asset.d.ts.map +1 -0
  20. package/src/shade/playground/{vgeo_viewer → vgeo_runtime}/sample_asset.js +9 -8
  21. package/src/shade/playground/vgeo_scene/README.md +408 -0
  22. package/src/shade/playground/vgeo_scene/index.html +144 -0
  23. package/src/shade/playground/vgeo_scene/main.d.ts +2 -0
  24. package/src/shade/playground/vgeo_scene/main.d.ts.map +1 -0
  25. package/src/shade/playground/vgeo_scene/main.js +1716 -0
  26. package/src/shade/renderer/Renderer.d.ts +4 -1
  27. package/src/shade/renderer/Renderer.d.ts.map +1 -1
  28. package/src/shade/renderer/Renderer.js +5 -2
  29. package/src/shade/renderer/extension/RENDER_EXTENSION_DESIGN.md +1 -2
  30. package/src/shade/renderer/geometry/meshlet/MeshletBatch.d.ts.map +1 -1
  31. package/src/shade/renderer/geometry/meshlet/MeshletBatch.js +3 -29
  32. package/src/shade/renderer/geometry/meshlet/encoding/ENCODED_ATTRIBUTE_DEFAULT_VALUES.d.ts +22 -0
  33. package/src/shade/renderer/geometry/meshlet/encoding/ENCODED_ATTRIBUTE_DEFAULT_VALUES.d.ts.map +1 -0
  34. package/src/shade/renderer/geometry/meshlet/encoding/ENCODED_ATTRIBUTE_DEFAULT_VALUES.js +49 -0
  35. package/src/shade/renderer/geometry/virtual/VGEO_FORMAT.md +4 -2
  36. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_DESIGN.md +10 -8
  37. package/src/shade/renderer/geometry/virtual/VIRTUAL_GEOMETRY_PLAN.md +12 -11
  38. package/src/shade/renderer/geometry/virtual/build/VGeoBuildOptions.d.ts +1 -1
  39. package/src/shade/renderer/geometry/virtual/build/VGeoBuildOptions.d.ts.map +1 -1
  40. package/src/shade/renderer/geometry/virtual/build/VGeoBuildOptions.js +1 -1
  41. package/src/shade/renderer/geometry/virtual/build/gltf/gltf_open_document.d.ts.map +1 -1
  42. package/src/shade/renderer/geometry/virtual/build/gltf/gltf_open_document.js +5 -9
  43. package/src/shade/renderer/geometry/virtual/build/level/VGeoLevel.d.ts +32 -0
  44. package/src/shade/renderer/geometry/virtual/build/level/VGeoLevel.d.ts.map +1 -0
  45. package/src/shade/renderer/geometry/virtual/build/level/VGeoLevelOptions.d.ts +59 -0
  46. package/src/shade/renderer/geometry/virtual/build/level/VGeoLevelOptions.d.ts.map +1 -0
  47. package/src/shade/renderer/geometry/virtual/build/level/VGeoLevelSummary.d.ts +44 -0
  48. package/src/shade/renderer/geometry/virtual/build/level/VGeoLevelSummary.d.ts.map +1 -0
  49. package/src/shade/renderer/geometry/virtual/build/level/vgeo_build_levels.d.ts +36 -0
  50. package/src/shade/renderer/geometry/virtual/build/level/vgeo_build_levels.d.ts.map +1 -0
  51. package/src/shade/renderer/geometry/virtual/build/{vgeo_build_levels.js → level/vgeo_build_levels.js} +61 -95
  52. package/src/shade/renderer/geometry/virtual/build/level/vgeo_build_root_group.d.ts +19 -0
  53. package/src/shade/renderer/geometry/virtual/build/level/vgeo_build_root_group.d.ts.map +1 -0
  54. package/src/shade/renderer/geometry/virtual/build/{vgeo_build_root_group.js → level/vgeo_build_root_group.js} +1 -1
  55. package/src/shade/renderer/geometry/virtual/build/mesh/VirtualGeometryMesh.d.ts +73 -0
  56. package/src/shade/renderer/geometry/virtual/build/mesh/VirtualGeometryMesh.d.ts.map +1 -0
  57. package/src/shade/renderer/geometry/virtual/build/{VirtualGeometryMesh.js → mesh/VirtualGeometryMesh.js} +7 -7
  58. package/src/shade/renderer/geometry/virtual/build/mesh/vgeo_compute_vertex_normals.d.ts +29 -0
  59. package/src/shade/renderer/geometry/virtual/build/mesh/vgeo_compute_vertex_normals.d.ts.map +1 -0
  60. package/src/shade/renderer/geometry/virtual/build/{vgeo_compute_vertex_normals.js → mesh/vgeo_compute_vertex_normals.js} +3 -3
  61. package/src/shade/renderer/geometry/virtual/build/mesh/vgeo_compute_vertex_tangents.d.ts +31 -0
  62. package/src/shade/renderer/geometry/virtual/build/mesh/vgeo_compute_vertex_tangents.d.ts.map +1 -0
  63. package/src/shade/renderer/geometry/virtual/build/{vgeo_compute_vertex_tangents.js → mesh/vgeo_compute_vertex_tangents.js} +5 -5
  64. package/src/shade/renderer/geometry/virtual/build/mesh/vgeo_normalize_source.d.ts +46 -0
  65. package/src/shade/renderer/geometry/virtual/build/mesh/vgeo_normalize_source.d.ts.map +1 -0
  66. package/src/shade/renderer/geometry/virtual/build/{vgeo_normalize_source.js → mesh/vgeo_normalize_source.js} +9 -9
  67. package/src/shade/renderer/geometry/virtual/build/page/vgeo_assemble_pages.d.ts +13 -0
  68. package/src/shade/renderer/geometry/virtual/build/page/vgeo_assemble_pages.d.ts.map +1 -0
  69. package/src/shade/renderer/geometry/virtual/build/{vgeo_assemble_pages.js → page/vgeo_assemble_pages.js} +9 -9
  70. package/src/shade/renderer/geometry/virtual/build/page/vgeo_build_page_nodes.d.ts +13 -0
  71. package/src/shade/renderer/geometry/virtual/build/page/vgeo_build_page_nodes.d.ts.map +1 -0
  72. package/src/shade/renderer/geometry/virtual/build/{vgeo_build_page_nodes.js → page/vgeo_build_page_nodes.js} +4 -4
  73. package/src/shade/renderer/geometry/virtual/build/page/vgeo_page_node_count.d.ts +22 -0
  74. package/src/shade/renderer/geometry/virtual/build/page/vgeo_page_node_count.d.ts.map +1 -0
  75. package/src/shade/renderer/geometry/virtual/build/{vgeo_page_node_count.js → page/vgeo_page_node_count.js} +2 -2
  76. package/src/shade/renderer/geometry/virtual/build/partition/VGeoFaceGraph.d.ts +52 -0
  77. package/src/shade/renderer/geometry/virtual/build/partition/VGeoFaceGraph.d.ts.map +1 -0
  78. package/src/shade/renderer/geometry/virtual/build/{VGeoFaceGraph.js → partition/VGeoFaceGraph.js} +1 -1
  79. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_graph_components.d.ts +14 -0
  80. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_graph_components.d.ts.map +1 -0
  81. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_partition_graph.d.ts +29 -0
  82. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_partition_graph.d.ts.map +1 -0
  83. package/src/shade/renderer/geometry/virtual/build/{vgeo_partition_graph.js → partition/vgeo_partition_graph.js} +2 -2
  84. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_partition_subgraph.d.ts +41 -0
  85. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_partition_subgraph.d.ts.map +1 -0
  86. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_partition_subgraph.js +92 -0
  87. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_split_face_set.d.ts +10 -0
  88. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_split_face_set.d.ts.map +1 -0
  89. package/src/shade/renderer/geometry/virtual/build/{vgeo_split_face_set.js → partition/vgeo_split_face_set.js} +12 -55
  90. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_split_face_set_packed.d.ts +15 -0
  91. package/src/shade/renderer/geometry/virtual/build/partition/vgeo_split_face_set_packed.d.ts.map +1 -0
  92. package/src/shade/renderer/geometry/virtual/build/{vgeo_split_face_set_packed.js → partition/vgeo_split_face_set_packed.js} +3 -3
  93. package/src/shade/renderer/geometry/virtual/build/sphere/vgeo_bounding_sphere.d.ts +12 -0
  94. package/src/shade/renderer/geometry/virtual/build/sphere/vgeo_bounding_sphere.d.ts.map +1 -0
  95. package/src/shade/renderer/geometry/virtual/build/{vgeo_bounding_sphere.js → sphere/vgeo_bounding_sphere.js} +3 -3
  96. package/src/shade/renderer/geometry/virtual/build/sphere/vgeo_containing_sphere.d.ts +18 -0
  97. package/src/shade/renderer/geometry/virtual/build/sphere/vgeo_containing_sphere.d.ts.map +1 -0
  98. package/src/shade/renderer/geometry/virtual/build/{vgeo_containing_sphere.js → sphere/vgeo_containing_sphere.js} +3 -3
  99. package/src/shade/renderer/geometry/virtual/build/sphere/vgeo_sphere_inflate_to_contain.d.ts +9 -0
  100. package/src/shade/renderer/geometry/virtual/build/sphere/vgeo_sphere_inflate_to_contain.d.ts.map +1 -0
  101. package/src/shade/renderer/geometry/virtual/build/{vgeo_sphere_inflate_to_contain.js → sphere/vgeo_sphere_inflate_to_contain.js} +1 -1
  102. package/src/shade/renderer/geometry/virtual/build/submesh/VGeoGatheredFaces.d.ts +24 -0
  103. package/src/shade/renderer/geometry/virtual/build/submesh/VGeoGatheredFaces.d.ts.map +1 -0
  104. package/src/shade/renderer/geometry/virtual/build/submesh/VGeoSubmesh.d.ts +20 -0
  105. package/src/shade/renderer/geometry/virtual/build/submesh/VGeoSubmesh.d.ts.map +1 -0
  106. package/src/shade/renderer/geometry/virtual/build/submesh/vgeo_extract_submesh.d.ts +14 -0
  107. package/src/shade/renderer/geometry/virtual/build/submesh/vgeo_extract_submesh.d.ts.map +1 -0
  108. package/src/shade/renderer/geometry/virtual/build/{vgeo_extract_submesh.js → submesh/vgeo_extract_submesh.js} +5 -5
  109. package/src/shade/renderer/geometry/virtual/build/submesh/vgeo_gather_faces.d.ts +12 -0
  110. package/src/shade/renderer/geometry/virtual/build/submesh/vgeo_gather_faces.d.ts.map +1 -0
  111. package/src/shade/renderer/geometry/virtual/build/{vgeo_gather_faces.js → submesh/vgeo_gather_faces.js} +2 -2
  112. package/src/shade/renderer/geometry/virtual/build/submesh/vgeo_narrow_indices.d.ts +11 -0
  113. package/src/shade/renderer/geometry/virtual/build/submesh/vgeo_narrow_indices.d.ts.map +1 -0
  114. package/src/shade/renderer/geometry/virtual/build/{vgeo_narrow_indices.js → submesh/vgeo_narrow_indices.js} +3 -3
  115. package/src/shade/renderer/geometry/virtual/build/vgeo_build.d.ts.map +1 -1
  116. package/src/shade/renderer/geometry/virtual/build/vgeo_build.js +9 -11
  117. package/src/shade/renderer/geometry/virtual/format/attribute/vgeo_attributes_read.d.ts +8 -2
  118. package/src/shade/renderer/geometry/virtual/format/attribute/vgeo_attributes_read.d.ts.map +1 -1
  119. package/src/shade/renderer/geometry/virtual/format/attribute/vgeo_attributes_read.js +8 -2
  120. package/src/shade/renderer/geometry/virtual/format/page/VGEO_GROUP_OFFSET.d.ts +15 -0
  121. package/src/shade/renderer/geometry/virtual/format/page/VGEO_GROUP_OFFSET.d.ts.map +1 -0
  122. package/src/shade/renderer/geometry/virtual/format/page/VGEO_GROUP_OFFSET.js +15 -0
  123. package/src/shade/renderer/geometry/virtual/format/payload/vgeo_encode_cluster_payload.d.ts +28 -0
  124. package/src/shade/renderer/geometry/virtual/format/payload/vgeo_encode_cluster_payload.d.ts.map +1 -1
  125. package/src/shade/renderer/geometry/virtual/format/payload/vgeo_encode_cluster_payload.js +7 -3
  126. package/src/shade/renderer/geometry/virtual/format/read/vgeo_read_page.d.ts.map +1 -1
  127. package/src/shade/renderer/geometry/virtual/format/read/vgeo_read_page.js +9 -4
  128. package/src/shade/renderer/geometry/virtual/format/vgeo_validate_container.d.ts.map +1 -1
  129. package/src/shade/renderer/geometry/virtual/format/vgeo_validate_container.js +18 -5
  130. package/src/shade/renderer/geometry/virtual/format/vgeo_write_container.d.ts.map +1 -1
  131. package/src/shade/renderer/geometry/virtual/format/vgeo_write_container.js +9 -1
  132. package/src/shade/renderer/geometry/virtual/runtime/CUT_PLAN_2026_09_14.md +21 -2
  133. package/src/shade/renderer/geometry/virtual/runtime/GPUVirtualGeometryRuntime.d.ts +52 -2
  134. package/src/shade/renderer/geometry/virtual/runtime/GPUVirtualGeometryRuntime.d.ts.map +1 -1
  135. package/src/shade/renderer/geometry/virtual/runtime/GPUVirtualGeometryRuntime.js +97 -1
  136. package/src/shade/renderer/geometry/virtual/runtime/RUNTIME_PLAN.md +4 -4
  137. package/src/shade/renderer/geometry/virtual/runtime/VGeoFeedbackLoop.d.ts.map +1 -1
  138. package/src/shade/renderer/geometry/virtual/runtime/VGeoFeedbackLoop.js +10 -0
  139. package/src/shade/renderer/geometry/virtual/runtime/VGeoResidency.d.ts.map +1 -1
  140. package/src/shade/renderer/geometry/virtual/runtime/VGeoResidency.js +59 -34
  141. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_access.d.ts +8 -8
  142. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_access.d.ts.map +1 -1
  143. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_access.js +11 -88
  144. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_arena.d.ts +19 -0
  145. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_arena.d.ts.map +1 -0
  146. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_arena.js +98 -0
  147. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_claim.d.ts +31 -0
  148. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_claim.d.ts.map +1 -0
  149. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_claim.js +99 -0
  150. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_queue.d.ts +17 -0
  151. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_queue.d.ts.map +1 -0
  152. package/src/shade/renderer/geometry/virtual/runtime/cut/chunk_vgeo_cut_queue.js +39 -0
  153. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_reseed.d.ts.map +1 -1
  154. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_reseed.js +7 -63
  155. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_seed.d.ts.map +1 -1
  156. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_seed.js +13 -30
  157. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_traverse.d.ts.map +1 -1
  158. package/src/shade/renderer/geometry/virtual/runtime/cut/shader_vgeo_cut_traverse.js +6 -52
  159. package/src/shade/renderer/loader/gltf/load_gltf.d.ts.map +1 -1
  160. package/src/shade/renderer/loader/gltf/load_gltf.js +0 -19
  161. package/src/shade/renderer/material/chunk_material_draw_side.d.ts +14 -0
  162. package/src/shade/renderer/material/chunk_material_draw_side.d.ts.map +1 -0
  163. package/src/shade/renderer/material/chunk_material_draw_side.js +34 -0
  164. package/src/shade/renderer/material/standard/fragment_gbuffer.d.ts.map +1 -1
  165. package/src/shade/renderer/material/standard/fragment_gbuffer.js +8 -1
  166. package/src/shade/renderer/path_tracer/shader/chunk_sample_material_data.d.ts.map +1 -1
  167. package/src/shade/renderer/path_tracer/shader/chunk_sample_material_data.js +13 -1
  168. package/src/shade/renderer/rasterize/bucket/construct_primitive_state.d.ts +19 -5
  169. package/src/shade/renderer/rasterize/bucket/construct_primitive_state.d.ts.map +1 -1
  170. package/src/shade/renderer/rasterize/bucket/construct_primitive_state.js +23 -5
  171. package/src/shade/renderer/rasterize/bucket/filter_materials_by_bucket.d.ts.map +1 -1
  172. package/src/shade/renderer/rasterize/bucket/filter_materials_by_bucket.js +12 -5
  173. package/src/shade/renderer/rasterize/bucket/rasterization_opaque_buckets.d.ts +22 -0
  174. package/src/shade/renderer/rasterize/bucket/rasterization_opaque_buckets.d.ts.map +1 -0
  175. package/src/shade/renderer/rasterize/bucket/rasterization_opaque_buckets.js +36 -0
  176. package/src/shade/renderer/rasterize/chunk_triangle_winding_is_reversed.d.ts +15 -0
  177. package/src/shade/renderer/rasterize/chunk_triangle_winding_is_reversed.d.ts.map +1 -0
  178. package/src/shade/renderer/rasterize/chunk_triangle_winding_is_reversed.js +35 -0
  179. package/src/shade/renderer/rasterize/expand/bucket/RASTERIZATION_BUCKET_LIMIT.d.ts +10 -1
  180. package/src/shade/renderer/rasterize/expand/bucket/RASTERIZATION_BUCKET_LIMIT.d.ts.map +1 -1
  181. package/src/shade/renderer/rasterize/expand/bucket/RASTERIZATION_BUCKET_LIMIT.js +38 -29
  182. package/src/shade/renderer/rasterize/expand/bucket/chunk_rasterization_material_bucket.d.ts +5 -0
  183. package/src/shade/renderer/rasterize/expand/bucket/chunk_rasterization_material_bucket.d.ts.map +1 -1
  184. package/src/shade/renderer/rasterize/expand/bucket/chunk_rasterization_material_bucket.js +63 -55
  185. package/src/shade/renderer/rasterize/expand/graph_meshlets_rasterize.d.ts.map +1 -1
  186. package/src/shade/renderer/rasterize/expand/graph_meshlets_rasterize.js +1 -0
  187. package/src/shade/renderer/rasterize/fast/graph_rasterize_scene_fast.d.ts.map +1 -1
  188. package/src/shade/renderer/rasterize/fast/graph_rasterize_scene_fast.js +44 -53
  189. package/src/shade/renderer/rasterize/forward/chunk_forward_shade_standard_fragment_brick4.d.ts.map +1 -1
  190. package/src/shade/renderer/rasterize/forward/chunk_forward_shade_standard_fragment_brick4.js +6 -2
  191. package/src/shade/renderer/rasterize/forward/chunk_forward_shade_standard_fragment_ibl.d.ts.map +1 -1
  192. package/src/shade/renderer/rasterize/forward/chunk_forward_shade_standard_fragment_ibl.js +6 -2
  193. package/src/shade/renderer/rasterize/forward/vertex_shader_chunk.d.ts.map +1 -1
  194. package/src/shade/renderer/rasterize/forward/vertex_shader_chunk.js +19 -2
  195. package/src/shade/renderer/rasterize/native/avboit/AVBOIT_PLAN_2026_09_03.md +7 -4
  196. package/src/shade/renderer/rasterize/native/avboit/graph_rasterize_meshes_transparent_avboit.d.ts.map +1 -1
  197. package/src/shade/renderer/rasterize/native/avboit/graph_rasterize_meshes_transparent_avboit.js +3 -2
  198. package/src/shade/renderer/rasterize/native/chunk_rasterize_triangle.d.ts.map +1 -1
  199. package/src/shade/renderer/rasterize/native/chunk_rasterize_triangle.js +15 -4
  200. package/src/shade/renderer/rasterize/native/depth/shadow_rasterization_alpha_tested_pass_descriptor.d.ts.map +1 -1
  201. package/src/shade/renderer/rasterize/native/depth/shadow_rasterization_alpha_tested_pass_descriptor.js +16 -5
  202. package/src/shade/renderer/rasterize/native/depth/shadow_rasterization_pass_descriptor.d.ts.map +1 -1
  203. package/src/shade/renderer/rasterize/native/depth/shadow_rasterization_pass_descriptor.js +16 -5
  204. package/src/shade/renderer/rasterize/native/oit/graph_rasterize_meshes_transparent_oit.d.ts.map +1 -1
  205. package/src/shade/renderer/rasterize/native/oit/graph_rasterize_meshes_transparent_oit.js +2 -1
  206. package/src/shade/renderer/rasterize/native/rasterization_shader_resources.d.ts.map +1 -1
  207. package/src/shade/renderer/rasterize/native/rasterization_shader_resources.js +4 -0
  208. package/src/shade/renderer/rasterize/native/viz/viz_rasterization_alpha_tested_pass_descriptor.d.ts.map +1 -1
  209. package/src/shade/renderer/rasterize/native/viz/viz_rasterization_alpha_tested_pass_descriptor.js +16 -7
  210. package/src/shade/renderer/rasterize/standard/graph_rasterize_partial_opaque.d.ts.map +1 -1
  211. package/src/shade/renderer/rasterize/standard/graph_rasterize_partial_opaque.js +78 -88
  212. package/src/shade/renderer/rasterize/standard/graph_rasterize_remaining_opaque.d.ts.map +1 -1
  213. package/src/shade/renderer/rasterize/standard/graph_rasterize_remaining_opaque.js +21 -31
  214. package/src/shade/renderer/scene/GPUSceneContext.d.ts.map +1 -1
  215. package/src/shade/renderer/scene/GPUSceneContext.js +2 -0
  216. package/src/shade/renderer/scene/NODE3D_STRUCT.d.ts +19 -0
  217. package/src/shade/renderer/scene/NODE3D_STRUCT.d.ts.map +1 -1
  218. package/src/shade/renderer/scene/NODE3D_STRUCT.js +34 -0
  219. package/src/shade/renderer/scene/chunk_node3d_flags.d.ts +9 -0
  220. package/src/shade/renderer/scene/chunk_node3d_flags.d.ts.map +1 -0
  221. package/src/shade/renderer/scene/chunk_node3d_flags.js +23 -0
  222. package/src/shade/renderer/scene/chunk_scene_database_access.d.ts.map +1 -1
  223. package/src/shade/renderer/scene/chunk_scene_database_access.js +15 -1
  224. package/src/shade/renderer/scene/rows/GPUSceneRows.d.ts.map +1 -1
  225. package/src/shade/renderer/scene/rows/GPUSceneRows.js +9 -1
  226. package/src/shade/renderer/shader/chunk/geometry/mat4/chunk_compute_normal_matrix_from_m4.d.ts +7 -0
  227. package/src/shade/renderer/shader/chunk/geometry/mat4/chunk_compute_normal_matrix_from_m4.d.ts.map +1 -1
  228. package/src/shade/renderer/shader/chunk/geometry/mat4/chunk_compute_normal_matrix_from_m4.js +16 -4
  229. package/src/shade/renderer/shader/chunk/geometry/mat4/chunk_mat4_linear_determinant.d.ts +12 -0
  230. package/src/shade/renderer/shader/chunk/geometry/mat4/chunk_mat4_linear_determinant.d.ts.map +1 -0
  231. package/src/shade/renderer/shader/chunk/geometry/mat4/chunk_mat4_linear_determinant.js +18 -0
  232. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_triangle_list_reverse_winding.d.ts +24 -0
  233. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_triangle_list_reverse_winding.d.ts.map +1 -0
  234. package/src/shade/renderer/shader/chunk/geometry/triangle/chunk_triangle_list_reverse_winding.js +33 -0
  235. package/src/shade/playground/vgeo_viewer/README.md +0 -150
  236. package/src/shade/playground/vgeo_viewer/cut_geometry.js +0 -460
  237. package/src/shade/playground/vgeo_viewer/index.html +0 -77
  238. package/src/shade/playground/vgeo_viewer/main.js +0 -1343
  239. package/src/shade/playground/vgeo_viewer/select_cut.js +0 -481
  240. package/src/shade/renderer/geometry/virtual/residency/VGeoResidencyManager.d.ts +0 -258
  241. package/src/shade/renderer/geometry/virtual/residency/VGeoResidencyManager.d.ts.map +0 -1
  242. package/src/shade/renderer/geometry/virtual/residency/VGeoResidencyOptions.d.ts +0 -49
  243. package/src/shade/renderer/geometry/virtual/residency/VGeoResidencyOptions.d.ts.map +0 -1
  244. package/src/shade/renderer/loader/gltf/fix_up_material_sides.js +0 -109
  245. package/src/shade/renderer/rasterize/expand/graph_triangles_filter_by_material_side.js +0 -78
  246. package/src/shade/renderer/rasterize/expand/shader_filter_triangle_by_material_side.js +0 -100
  247. package/src/shade/renderer/rasterize/forward/chunk_material_side_should_draw.js +0 -26
  248. package/src/shade/renderer/shader/chunk/jobs/chunk_job_system.d.ts +0 -3
  249. package/src/shade/renderer/shader/chunk/jobs/chunk_job_system.d.ts.map +0 -1
  250. /package/src/shade/renderer/geometry/virtual/build/{VGeoLevel.js → level/VGeoLevel.js} +0 -0
  251. /package/src/shade/renderer/geometry/virtual/build/{VGeoLevelOptions.js → level/VGeoLevelOptions.js} +0 -0
  252. /package/src/shade/renderer/geometry/virtual/build/{VGeoLevelSummary.js → level/VGeoLevelSummary.js} +0 -0
  253. /package/src/shade/renderer/geometry/virtual/build/{vgeo_graph_components.js → partition/vgeo_graph_components.js} +0 -0
  254. /package/src/shade/renderer/geometry/virtual/build/{VGeoGatheredFaces.js → submesh/VGeoGatheredFaces.js} +0 -0
  255. /package/src/shade/renderer/geometry/virtual/build/{VGeoSubmesh.js → submesh/VGeoSubmesh.js} +0 -0
@@ -0,0 +1,1716 @@
1
+ import { Color } from "../../../core/color/Color.js";
2
+ import { m4_linear_determinant } from "../../../core/geom/3d/mat4/m4_linear_determinant.js";
3
+ import Vector3 from "../../../core/geom/Vector3.js";
4
+ import { EntityComponentDataset } from "../../../engine/ecs/EntityComponentDataset.js";
5
+ import { Transform64 } from "../../../engine/ecs/transform/Transform64.js";
6
+ import { OrbitalCameraController } from "../../renderer/camera/OrbitalCameraController.js";
7
+ import { PerspectiveCamera } from "../../renderer/camera/PerspectiveCamera.js";
8
+ import { FramePhase } from "../../renderer/extension/FramePhase.js";
9
+ import { RenderExtension } from "../../renderer/extension/RenderExtension.js";
10
+ import {
11
+ vgeo_byte_source_over_url
12
+ } from "../../renderer/geometry/virtual/format/read/vgeo_fetch_byte_source.js";
13
+ import {
14
+ VGeoContainerReader
15
+ } from "../../renderer/geometry/virtual/format/read/VGeoContainerReader.js";
16
+ import {
17
+ VGeoReadOptions
18
+ } from "../../renderer/geometry/virtual/format/read/VGeoReadOptions.js";
19
+ import { VGEO_CUT_STAT } from "../../renderer/geometry/virtual/runtime/layout/VGEO_CUT_STAT.js";
20
+ import {
21
+ VGeoResidencyOptions
22
+ } from "../../renderer/geometry/virtual/runtime/VGeoResidencyOptions.js";
23
+ import { VirtualGeometry } from "../../renderer/geometry/virtual/runtime/VirtualGeometry.js";
24
+ import { make_sunlight } from "../../renderer/light/make_sunlight.js";
25
+ import { place_light } from "../../renderer/light/place_light.js";
26
+ import { ShadeDrawSide } from "../../renderer/material/ShadeDrawSide.js";
27
+ import { StandardShadeMaterial } from "../../renderer/material/StandardShadeMaterial.js";
28
+ import {
29
+ RasterizationCollectionType
30
+ } from "../../renderer/rasterize/expand/RasterizationCollectionType.js";
31
+ import { Renderer } from "../../renderer/Renderer.js";
32
+ import { place_primitive } from "../../renderer/scene/rows/place_primitive.js";
33
+ import { Scene } from "../../renderer/scene/Scene.js";
34
+ import { load_default_environment } from "../load_default_environment.js";
35
+ import { install_profile_hotkey } from "../profile_hotkey.js";
36
+
37
+ /**
38
+ * A **whole converted scene** of virtual geometry: thousands of containers, tens of thousands of
39
+ * instances, one cut.
40
+ *
41
+ * `playground/vgeo_runtime/` draws one `.vgeo` and is the page that proves the engine seam. This is
42
+ * the other question that seam raises and it cannot answer: a container placed once by a page that
43
+ * frames the camera on its own bounding sphere is a *viewer*, and design §1's claim is about a
44
+ * **scene** — that rendering cost is a function of the threshold and the resolution rather than of
45
+ * the source, across every asset in view at once, with instances sharing pages and one residency
46
+ * budget deciding between all of them.
47
+ *
48
+ * So everything here that is not in the runtime page is about breadth rather than depth:
49
+ *
50
+ * - **One residency, N containers.** Every asset registers with the same `VGeoResidency`, whose
51
+ * budgets are scene-wide by construction — the RAM tier's bytes and the VRAM tier's slots are
52
+ * counted across every reader, and a page evicted from one asset is room for another's. That is
53
+ * the thing a single-asset page cannot show, because with one reader there is nothing to
54
+ * arbitrate between.
55
+ * - **One cut, N instances.** `shader_vgeo_cut_collect` runs over the visible mesh rows and emits
56
+ * one instance record per virtual one; the descent is seeded with one item per instance and every
57
+ * cull and error test downstream runs in that instance's own object space. Seventeen thousand
58
+ * instances are seventeen thousand seeds of one wavefront, not seventeen thousand cuts.
59
+ * - **Instances share the asset.** A geometry placed by four hundred nodes is one reader, one
60
+ * geometry row, one page lookup and one set of resident pages. That is what `VirtualGeometry`'s
61
+ * `equals` is for, and it is most of why this is affordable at all.
62
+ *
63
+ * What it takes as input is a **scene manifest** — `tools/geometry/virtual/vgeo_scene.js` writes
64
+ * one out of a builder manifest — naming each container and the matrices it is placed at. The
65
+ * containers are served by ranges: nothing is downloaded, each is opened out of its own head.
66
+ *
67
+ * `__state()` is the whole surface a headless session needs, and the panel shows nothing it does
68
+ * not.
69
+ *
70
+ * @author Alex Goldring
71
+ * @copyright Company Named Limited (c) 2026
72
+ */
73
+
74
+ const element = id => document.getElementById(id);
75
+
76
+ /**
77
+ * Where the containers are served from when the page is opened without a `?scene=`.
78
+ *
79
+ * `tools/geometry/virtual/vgeo_serve.js` binds this by default and answers `/scenes.json` with what
80
+ * it holds, so a page with no parameters finds the scene that is there rather than asking for a URL
81
+ * to be pasted. It is a *separate* origin on purpose: vite refuses `/@fs/` outside the workspace,
82
+ * and a forty-gigabyte directory has no business inside one.
83
+ *
84
+ * @type {string}
85
+ */
86
+ const DEFAULT_ORIGIN = 'http://localhost:5200';
87
+
88
+ /**
89
+ * Head bytes read on the first request of every container.
90
+ *
91
+ * §10's always-resident set is the header, the level table and the root frame, contiguous and in
92
+ * that order, and a reader that guesses short pays a second round trip to grow. The default is
93
+ * 64 KB, which is right for a page that opens one container and wasteful for one that opens three
94
+ * thousand: measured across Zorah's 2,966, the resident head is 2,560 bytes at the median and
95
+ * 6,528 at the very worst, so 8 KB opens **every** container in the set in exactly one range
96
+ * request and reads 7.4 MB in total rather than 190 MB.
97
+ *
98
+ * A container whose head is larger than this is not refused — `#grow_head` fetches the rest — so
99
+ * the number is a throughput choice and not a limit.
100
+ *
101
+ * @type {number}
102
+ */
103
+ const HEAD_PREFIX_BYTES = 8 * 1024;
104
+
105
+ /**
106
+ * Containers being opened at once.
107
+ *
108
+ * A cold scene is one range request per container and they are independent, so the only thing this
109
+ * trades is head-of-line latency against the browser's own per-origin connection pool. Beyond about
110
+ * the pool's width the requests queue in the browser instead of on the wire and the number stops
111
+ * meaning anything.
112
+ *
113
+ * @type {number}
114
+ */
115
+ const OPEN_CONCURRENCY = 24;
116
+
117
+ /**
118
+ * What `?side=` accepts, for a session asking whether a surface is missing or merely culled.
119
+ *
120
+ * @type {Readonly<Record<string, number>>}
121
+ */
122
+ const DRAW_SIDES = Object.freeze({
123
+ front: ShadeDrawSide.Front,
124
+ back: ShadeDrawSide.Back,
125
+ double: ShadeDrawSide.Double,
126
+ });
127
+
128
+ /**
129
+ * Opens the VG frame before anything records a rasterization pass, because the seam inside those
130
+ * passes writes into the feedback buffer this creates.
131
+ */
132
+ class VGeoFrameStart extends RenderExtension {
133
+ name = "vgeo/frame start";
134
+
135
+ phase = FramePhase.FrameStart;
136
+
137
+ /**
138
+ * @param {VGeoScenePlayground} page
139
+ */
140
+ constructor(page) {
141
+ super();
142
+
143
+ this.page = page;
144
+ }
145
+
146
+ record(frame) {
147
+ this.page.runtime.begin_frame({ graph: frame.graph });
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Schedules the readback and closes the frame, after everything that could have cut has cut.
153
+ */
154
+ class VGeoFrameEnd extends RenderExtension {
155
+ name = "vgeo/frame end";
156
+
157
+ phase = FramePhase.BeforePresent;
158
+
159
+ /**
160
+ * @param {VGeoScenePlayground} page
161
+ */
162
+ constructor(page) {
163
+ super();
164
+
165
+ this.page = page;
166
+ }
167
+
168
+ record(frame) {
169
+ this.page.feedback.read(frame.graph);
170
+
171
+ this.page.runtime.end_frame();
172
+
173
+ this.page.frames++;
174
+ }
175
+ }
176
+
177
+ class VGeoScenePlayground {
178
+ /**
179
+ * @type {Renderer}
180
+ */
181
+ renderer;
182
+
183
+ /**
184
+ * @type {HTMLCanvasElement}
185
+ */
186
+ canvas;
187
+
188
+ /**
189
+ * @type {Scene}
190
+ */
191
+ scene;
192
+
193
+ /**
194
+ * @type {GPUSceneContext}
195
+ */
196
+ scene_context;
197
+
198
+ /**
199
+ * @type {PerspectiveCamera}
200
+ */
201
+ camera;
202
+
203
+ /**
204
+ * @type {OrbitalCameraController}
205
+ */
206
+ controller;
207
+
208
+ /**
209
+ * The one material the whole scene is drawn with.
210
+ *
211
+ * Not a simplification this page is making: a `.vgeo` carries **geometry only** — design §7 —
212
+ * so a converted set has no material data in it at all, and the source's 2,714 materials did
213
+ * not survive the conversion because nothing asked them to. Giving each container a tinted
214
+ * material of its own would read better and would be a lie about where the colour came from,
215
+ * and it would also put 2,714 material buckets in front of the draw path to say it with.
216
+ *
217
+ * @type {StandardShadeMaterial}
218
+ */
219
+ material;
220
+
221
+ /**
222
+ * What a **reflected** instance is drawn with — one whose transform has a negative determinant,
223
+ * which in Zorah is 2,722 of 17,424.
224
+ *
225
+ * By default this is {@link material}, and the scene is one bucket. `?mirrored=` splits it, and
226
+ * the split is the only way to ask the question at all: a reflection reverses the screen-space
227
+ * winding of every triangle of the instance, `cullMode` is pipeline state chosen per material
228
+ * bucket, and nothing between `construct_primitive_state` and the rasterizer sees a transform.
229
+ * So the sign of the determinant cannot reach the cull unless the instance is put in a
230
+ * different bucket, and putting it in one is both the diagnosis and, for a page, the fix.
231
+ *
232
+ * @type {StandardShadeMaterial}
233
+ */
234
+ mirror_material;
235
+
236
+ /**
237
+ * Instances placed with a negative-determinant transform.
238
+ * @type {number}
239
+ */
240
+ mirrored = 0;
241
+
242
+ /**
243
+ * The scene manifest as loaded, or `undefined` before it arrives.
244
+ * @type {object|undefined}
245
+ */
246
+ manifest;
247
+
248
+ /**
249
+ * Where the manifest was loaded from, which every container's URL is resolved against.
250
+ * @type {string}
251
+ */
252
+ base = '';
253
+
254
+ /**
255
+ * One entry per container that opened, held so nothing collects the reader out from under the
256
+ * residency and so a session can be told what is in the scene.
257
+ * @type {Array<{reader: VGeoContainerReader, geometry: VirtualGeometry, file: string, instances: number}>}
258
+ */
259
+ assets = [];
260
+
261
+ /**
262
+ * @type {GPUVirtualGeometryRuntime}
263
+ */
264
+ runtime;
265
+
266
+ /**
267
+ * @type {VGeoFeedbackLoop}
268
+ */
269
+ feedback;
270
+
271
+ /**
272
+ * @type {VGeoResidency}
273
+ */
274
+ residency;
275
+
276
+ /**
277
+ * @type {number}
278
+ */
279
+ frames = 0;
280
+
281
+ /**
282
+ * Containers opened, refused, and how many the manifest names.
283
+ * @type {number}
284
+ */
285
+ opened = 0;
286
+
287
+ /**
288
+ * @type {number}
289
+ */
290
+ refused = 0;
291
+
292
+ /**
293
+ * @type {number}
294
+ */
295
+ placed = 0;
296
+
297
+ /**
298
+ * @type {number}
299
+ */
300
+ total_containers = 0;
301
+
302
+ /**
303
+ * Wall-clock milliseconds the whole scene took to open, or 0 while it is still opening.
304
+ * @type {number}
305
+ */
306
+ open_ms = 0;
307
+
308
+ /**
309
+ * @type {number}
310
+ */
311
+ screen_height = 1;
312
+
313
+ /**
314
+ * @type {number}
315
+ */
316
+ screen_width = 1;
317
+
318
+ /**
319
+ * Frame time, exponentially smoothed — a scene page's one reading that is a cost rather than a
320
+ * count, and coarse enough to be honest about being one. A capture is what measures.
321
+ * @type {number}
322
+ */
323
+ frame_ms = 0;
324
+
325
+ /**
326
+ * @type {string}
327
+ */
328
+ status_text = '';
329
+
330
+ /**
331
+ * The dropout catcher, when `?catch=` armed one.
332
+ * @type {FlickerCatcher|undefined}
333
+ */
334
+ catcher;
335
+
336
+ /**
337
+ * Named viewpoints, so a session that cannot drag has somewhere to stand and a reader of the
338
+ * panel can get back to a reading. Seeded from the manifest's own camera.
339
+ * @type {Array<{name: string, eye: number[], target: number[]}>}
340
+ */
341
+ viewpoints = [];
342
+
343
+ /**
344
+ * @param {string} text
345
+ * @param {boolean} [error]
346
+ */
347
+ status(text, error = false) {
348
+ this.status_text = text;
349
+
350
+ const node = element('status');
351
+
352
+ node.textContent = text;
353
+ node.classList.toggle('error', error);
354
+ }
355
+
356
+ /**
357
+ * Bring the canvas, the renderer and the camera up to the size the window actually is.
358
+ *
359
+ * **Measured from the document, never from the canvas.** `Renderer` writes back both of the
360
+ * canvas's sizes when it configures the context, including an *inline* `style.width` in CSS
361
+ * pixels — so a `fit` that read `clientWidth` back would be reading the renderer its own last
362
+ * answer and the canvas would never change size again.
363
+ *
364
+ * The cut's screen height and field of view follow from the renderer rather than from the
365
+ * window because τ is a count of **render-target** pixels: `GPUViewCut.set_projection` takes
366
+ * them off the view every frame, so nothing here has to push them.
367
+ */
368
+ fit() {
369
+ const renderer = this.renderer;
370
+
371
+ const viewport = document.documentElement;
372
+
373
+ renderer.pixel_ratio = globalThis.devicePixelRatio || 1;
374
+
375
+ renderer.resize(
376
+ Math.max(1, Math.floor(viewport.clientWidth)),
377
+ Math.max(1, Math.floor(viewport.clientHeight))
378
+ );
379
+
380
+ this.camera.aspect = renderer.aspect_ratio;
381
+
382
+ const resolution = renderer.output_resolution;
383
+ const scale = renderer.internal_resolution_scale;
384
+
385
+ this.screen_width = Math.max(1, Math.floor(resolution.x * scale));
386
+ this.screen_height = Math.max(1, Math.floor(resolution.y * scale));
387
+ }
388
+
389
+ /**
390
+ * Open every container the manifest names and place its instances, as each one lands.
391
+ *
392
+ * **Placed as they arrive rather than at the end**, and that is the whole difference between a
393
+ * page you can look at in a second and one you wait a minute for. It costs nothing: a geometry
394
+ * that is not registered yet is a geometry the cut does not collect, and the residency's budgets
395
+ * are recomputed per reading rather than at registration, so a scene that grows under the camera
396
+ * is the case both tiers were designed for.
397
+ *
398
+ * A container that refuses — the wrong page size, a tier with no room for another root — leaves
399
+ * every other asset exactly as it was and is counted. Refusing one container out of three
400
+ * thousand is a hole in the scene, and a hole is what the count is for.
401
+ *
402
+ * @param {object} manifest
403
+ * @returns {Promise<void>}
404
+ */
405
+ async open_scene(manifest) {
406
+ const geometries = manifest.geometries;
407
+
408
+ this.total_containers = geometries.length;
409
+
410
+ const options = new VGeoReadOptions();
411
+
412
+ /*
413
+ Frame verification on, for the same reason the runtime page has it on: these are bytes off
414
+ a network rather than bytes a tool just wrote, and §10 leaves it off by default precisely
415
+ because a builder reading back its own output is not defending against one.
416
+ */
417
+ options.verify_frames = true;
418
+
419
+ // the runtime answers activation out of the arena; the reader's own counters would be
420
+ // tens of megabytes at seventeen thousand pages, read by nothing
421
+ options.track_selectability = false;
422
+
423
+ options.head_prefix_bytes = HEAD_PREFIX_BYTES;
424
+
425
+ const started = performance.now();
426
+
427
+ let next = 0;
428
+
429
+ const worker = async () => {
430
+ while (next < geometries.length) {
431
+ const record = geometries[next++];
432
+
433
+ await this.open_one(record, options);
434
+ }
435
+ };
436
+
437
+ const workers = [];
438
+
439
+ for (let i = 0; i < Math.min(OPEN_CONCURRENCY, geometries.length); i++) {
440
+ workers.push(worker());
441
+ }
442
+
443
+ await Promise.all(workers);
444
+
445
+ this.open_ms = performance.now() - started;
446
+
447
+ this.status(
448
+ `${this.opened.toLocaleString()} containers`
449
+ + `, ${this.placed.toLocaleString()} instances`
450
+ + `, opened in ${(this.open_ms / 1000).toFixed(1)} s`
451
+ + (this.refused > 0 ? ` — ${this.refused} refused` : '')
452
+ );
453
+ }
454
+
455
+ /**
456
+ * @param {object} record one manifest geometry
457
+ * @param {VGeoReadOptions} options
458
+ * @returns {Promise<void>}
459
+ */
460
+ async open_one(record, options) {
461
+ const url = new URL(record.file, this.base).href;
462
+
463
+ try {
464
+ /*
465
+ The length comes from the manifest rather than from a `HEAD`. `vgeo_fetch_byte_source`
466
+ probes for it because a URL is usually all a caller has; here the builder wrote it down
467
+ beside the file name, and taking it saves 2,966 round trips that would tell this page
468
+ something it was already holding.
469
+ */
470
+ const source = vgeo_byte_source_over_url(url, record.bytes);
471
+
472
+ const reader = await VGeoContainerReader.open(source, options);
473
+
474
+ /*
475
+ Only the root page is resident. Everything below it arrives because the cut asked for
476
+ it, which at this scale is the whole point: the manifest names 46.7 GiB of geometry and
477
+ what is fetched is what a threshold in pixels selected out of it.
478
+ */
479
+ const geometry_row = this.residency.register(reader);
480
+
481
+ const geometry = new VirtualGeometry(reader);
482
+
483
+ geometry.vg_row = geometry_row;
484
+
485
+ const matrices = record.instances;
486
+
487
+ const transform = new Transform64();
488
+
489
+ for (let i = 0; i < matrices.length; i += 16) {
490
+ /*
491
+ The source matrix, decomposed. A transform row carries a TRS — the GPU composes the
492
+ world from it — so an instance carrying **shear** cannot be represented exactly and
493
+ is placed at the closest rigid-plus-scale fit instead. 391 of Zorah's 17,424 have
494
+ any, and the worst is about eleven degrees off orthogonal; the other 17,033 are
495
+ exact. That is a property of the engine's transform model rather than of this page,
496
+ and it is worth knowing when a wall looks subtly wrong.
497
+ */
498
+ const matrix = matrices.slice(i, i + 16);
499
+
500
+ transform.fromMatrix(matrix);
501
+
502
+ /*
503
+ The engine reverses a reflected instance's winding in the vertex shader, off the
504
+ row's own `NODE3D_GLOBAL_FLAG_MIRRORED`, so nothing here has to be done for the
505
+ picture to be right. Splitting the bucket is what `?mirrored=` is for: a
506
+ diagnostic that can tint the reflected half, or reproduce the old bug by reversing
507
+ it a second time. See the README.
508
+ */
509
+ const reflected = m4_linear_determinant(matrix) < 0;
510
+
511
+ if (reflected) {
512
+ this.mirrored++;
513
+ }
514
+
515
+ place_primitive(
516
+ this.scene_context, geometry,
517
+ reflected ? this.mirror_material : this.material,
518
+ transform
519
+ );
520
+
521
+ this.placed++;
522
+ }
523
+
524
+ this.assets.push({
525
+ reader,
526
+ geometry,
527
+ file: record.file,
528
+ instances: matrices.length / 16,
529
+ });
530
+
531
+ this.opened++;
532
+ } catch (error) {
533
+ this.refused++;
534
+
535
+ // one line per refusal rather than a thrown scene: 2,965 of 2,966 is a scene
536
+ console.warn(`${record.file} — ${error.message ?? error}`);
537
+ }
538
+ }
539
+
540
+ /**
541
+ * Stand the camera somewhere, in world units.
542
+ *
543
+ * Everything with a length in it is taken from the distance between the two points rather than
544
+ * from the scene's extent, which is the difference between a camera that works in a town and
545
+ * one that works in a hundred-kilometre bounding box: a near plane derived from Zorah's world
546
+ * radius is fifty metres, and fifty metres puts every building the viewer is standing in behind
547
+ * it.
548
+ *
549
+ * @param {number[]} eye
550
+ * @param {number[]} target
551
+ */
552
+ look(eye, target) {
553
+ const from = new Vector3(eye[0], eye[1], eye[2]);
554
+ const to = new Vector3(target[0], target[1], target[2]);
555
+
556
+ const distance = Math.max(Vector3.distance(from, to), 1e-3);
557
+
558
+ this.camera.near = distance * 0.002;
559
+ this.camera.far = Math.max(distance * 2000, this.world_radius * 4);
560
+
561
+ this.controller.distanceLimits.min = distance * 0.01;
562
+ this.controller.distanceLimits.max = Math.max(distance * 2000, this.world_radius * 4);
563
+ this.controller.movement_speed_scale = distance;
564
+
565
+ this.controller.look(from, to);
566
+
567
+ // the pixels the camera is about to cover have no correspondence in a TAA history written
568
+ // from where it was standing
569
+ this.renderer.indicate_view_change();
570
+ }
571
+
572
+ /**
573
+ * Half the diagonal of every instance's world box, which is the only length the manifest offers
574
+ * that is about the scene rather than about a viewpoint.
575
+ * @type {number}
576
+ */
577
+ world_radius = 1;
578
+
579
+ /**
580
+ * Everything a headless session needs, and everything the panel shows.
581
+ *
582
+ * @returns {object}
583
+ */
584
+ state() {
585
+ const stats = this.feedback.stats;
586
+ const pool = this.runtime.pool;
587
+
588
+ let bytes_read = this.residency.fetcher.worker_bytes_read;
589
+
590
+ for (const asset of this.assets) {
591
+ bytes_read += asset.reader.source.bytes_read;
592
+ }
593
+
594
+ return {
595
+ frames: this.frames,
596
+ frame_ms: this.frame_ms,
597
+ tau: this.runtime.tau,
598
+ status: this.status_text,
599
+ screen_width: this.screen_width,
600
+ screen_height: this.screen_height,
601
+ feedback_reads: this.feedback.read_count,
602
+ feedback_skipped: this.feedback.skipped_frames,
603
+ camera: {
604
+ eye: Array.from(this.camera.transform.translation),
605
+ target: this.controller.target.toJSON(),
606
+ distance: this.controller.distance,
607
+ },
608
+ scene: {
609
+ name: this.manifest?.source ?? '',
610
+ containers: this.opened,
611
+ declared: this.total_containers,
612
+ refused: this.refused,
613
+ instances: this.placed,
614
+ mirrored: this.mirrored,
615
+ faces: this.manifest === undefined ? 0 : scene_faces(this.manifest),
616
+ bytes: this.manifest?.total_bytes ?? 0,
617
+ bytes_read,
618
+ pages: this.manifest?.total_pages ?? 0,
619
+ open_ms: this.open_ms,
620
+ },
621
+ cut: {
622
+ drawn: stats[VGEO_CUT_STAT.DRAWN],
623
+ clamped: stats[VGEO_CUT_STAT.CLAMPED],
624
+ clamped_on_activation: stats[VGEO_CUT_STAT.CLAMPED_ON_ACTIVATION],
625
+ pages: stats[VGEO_CUT_STAT.PAGES],
626
+ items: stats[VGEO_CUT_STAT.ITEMS],
627
+ clusters_tested: stats[VGEO_CUT_STAT.CLUSTERS_TESTED],
628
+ culled_frustum: stats[VGEO_CUT_STAT.CULLED_FRUSTUM],
629
+ culled_cluster: stats[VGEO_CUT_STAT.CULLED_CLUSTER],
630
+ culled_lod: stats[VGEO_CUT_STAT.CULLED_LOD],
631
+ meshlets_dropped: stats[VGEO_CUT_STAT.MESHLETS_DROPPED],
632
+ wants_dropped: stats[VGEO_CUT_STAT.WANTS_DROPPED],
633
+ queue_dropped: stats[VGEO_CUT_STAT.QUEUE_DROPPED],
634
+ instances_dropped: stats[VGEO_CUT_STAT.INSTANCES_DROPPED],
635
+ wavefronts: stats[VGEO_CUT_STAT.WAVEFRONTS],
636
+ queue_remaining: stats[VGEO_CUT_STAT.QUEUE_REMAINING],
637
+ /*
638
+ The bound beside the use of it, because one is meaningless without the other: eight
639
+ wavefronts is a drained descent under a bound of 32 and a truncated one under a
640
+ bound of 8, and the panel could not tell them apart while it showed only the count.
641
+ */
642
+ max_iterations: this.runtime.max_iterations,
643
+ iteration_escalations: this.runtime.iteration_escalations,
644
+ },
645
+ wants: this.feedback.last_want_count,
646
+ touches: this.feedback.last_touch_count,
647
+ vram: {
648
+ pages: pool.resident_page_count,
649
+ bytes: pool.resident_bytes,
650
+ capacity: pool.tables.page_capacity,
651
+ evicted: this.residency.evicted_vram_count,
652
+ refused: this.residency.refused_install_count,
653
+ },
654
+ ram: {
655
+ pages: this.residency.ram_page_count,
656
+ bytes: this.residency.ram_resident_bytes,
657
+ evicted: this.residency.evicted_ram_count,
658
+ refused: this.residency.refused_fetch_count,
659
+ },
660
+ fetches_in_flight: this.residency.fetches_in_flight,
661
+ worker_fetches: this.residency.fetcher.worker_fetch_count,
662
+ in_process_fetches: this.residency.fetcher.in_process_fetch_count,
663
+ deferred: this.residency.deferred_want_count,
664
+ };
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Catches a frame that is **not like its neighbours** and keeps it.
670
+ *
671
+ * A cut re-selected every frame has a shimmer floor set by TAA (see the README), and that is a
672
+ * property of the content. A *dropout* — a single frame missing a large part of what the frames
673
+ * either side of it drew — is not, and the two are nothing alike to measure even though both read
674
+ * as "flicker": the first moves a tenth of a per cent of the pixels, the second moves tens of per
675
+ * cent. This looks only for the second.
676
+ *
677
+ * **It has to sample the canvas, not the counters.** `__state()`'s cut statistics come from the
678
+ * feedback readback, which is capped at two in flight and is skipped on more than half the frames —
679
+ * so on any given frame `drawn` is a reading from one to three frames ago, and a one-frame
680
+ * disappearance need never appear in it at all. The pixels are the only per-frame truth available,
681
+ * and `drawImage` into a 2D canvas is the only way to get at them: the swap chain is not
682
+ * `preserveDrawingBuffer`, so `getImageData` cannot reach it.
683
+ *
684
+ * A frame is judged against the **median of its two neighbours on each side** rather than against a
685
+ * running average, because the thing being looked for is one frame wide and a mean that includes it
686
+ * hides it. That costs a two-frame delay before a verdict, which is why the full-resolution ring
687
+ * below is five deep: by the time a frame is convicted it is two frames old, and it still has to be
688
+ * there to be looked at.
689
+ */
690
+ class FlickerCatcher {
691
+ /**
692
+ * @param {HTMLCanvasElement} canvas the page's own, which is read and never written
693
+ * @param {number} threshold relative departure from the neighbours that counts, e.g. 0.15
694
+ * @param {boolean} freeze whether to stop on the first one and put it on screen
695
+ * @param {function(): string} describe answers a link that reopens the page where the camera
696
+ * stands — held per event, because where it happened is the one thing a report of one
697
+ * cannot be reconstructed without and the camera may have moved by the time it is read
698
+ */
699
+ constructor(canvas, threshold, freeze, describe) {
700
+ this.source = canvas;
701
+ this.threshold = threshold;
702
+ this.freeze = freeze;
703
+ this.describe = describe;
704
+
705
+ this.width = 128;
706
+ this.height = 72;
707
+
708
+ this.scratch = document.createElement('canvas');
709
+ this.scratch.width = this.width;
710
+ this.scratch.height = this.height;
711
+
712
+ this.context = this.scratch.getContext('2d', { willReadFrequently: true });
713
+
714
+ /** Full-resolution frames, so a convicted one can be looked at rather than only counted. */
715
+ this.ring = [];
716
+
717
+ for (let i = 0; i < 5; i++) {
718
+ const frame = document.createElement('canvas');
719
+
720
+ this.ring.push({ canvas: frame, context: frame.getContext('2d'), frame: -1, mean: 0 });
721
+ }
722
+
723
+ this.at = 0;
724
+ this.recent = [];
725
+ this.rows = [];
726
+
727
+ this.frames = 0;
728
+ this.events = [];
729
+ this.worst = 0;
730
+ this.frozen = false;
731
+
732
+ /**
733
+ * Frames the scene has to have been drawing for before anything is judged.
734
+ *
735
+ * The first lit frame departs from the black ones before it by 103%, which is a real
736
+ * observation about a page that has just started and nothing at all about a dropout. Five
737
+ * is enough to clear it and short enough that arming is not something you wait for.
738
+ * @type {number}
739
+ */
740
+ this.armed_after = 5;
741
+
742
+ this.drawing = 0;
743
+ }
744
+
745
+ /**
746
+ * One frame. Called after the render, from the page's own loop.
747
+ *
748
+ * @param {object} state what {@link VGeoScenePlayground.state} answered this frame
749
+ */
750
+ sample(state) {
751
+ if (this.frozen) {
752
+ return;
753
+ }
754
+
755
+ const source = this.source;
756
+
757
+ /*
758
+ The full-resolution copy is the expensive half — a whole-canvas `drawImage` every frame,
759
+ measured at rather more than the 128x72 read below — and it buys exactly one thing: a frame
760
+ that can be looked at after the fact. A run that is only counting does not need it, and
761
+ paying for it would slow the page enough to change the timing of whatever is being hunted.
762
+ */
763
+ if (this.freeze) {
764
+ const slot = this.ring[this.at % this.ring.length];
765
+
766
+ if (slot.canvas.width !== source.width || slot.canvas.height !== source.height) {
767
+ slot.canvas.width = source.width;
768
+ slot.canvas.height = source.height;
769
+ }
770
+
771
+ slot.context.drawImage(source, 0, 0);
772
+ slot.frame = state.frames;
773
+
774
+ this.at++;
775
+ }
776
+
777
+ this.context.drawImage(source, 0, 0, this.width, this.height);
778
+
779
+ const pixels = this.context.getImageData(0, 0, this.width, this.height).data;
780
+
781
+ let sum = 0;
782
+
783
+ for (let i = 0; i < pixels.length; i += 4) {
784
+ sum += 0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2];
785
+ }
786
+
787
+ const mean = sum / (this.width * this.height);
788
+
789
+ this.frames++;
790
+
791
+ if (state.cut.drawn > 0) {
792
+ this.drawing++;
793
+ }
794
+
795
+ this.rows.push([
796
+ state.frames, Math.round(mean * 100) / 100, state.cut.drawn, state.cut.clamped,
797
+ state.cut.queue_remaining, state.cut.meshlets_dropped, state.cut.instances_dropped,
798
+ state.cut.wavefronts, state.vram.pages, state.vram.refused, state.vram.evicted,
799
+ ]);
800
+
801
+ if (this.rows.length > 4096) {
802
+ this.rows.shift();
803
+ }
804
+
805
+ this.recent.push(mean);
806
+
807
+ if (this.recent.length > 5) {
808
+ this.recent.shift();
809
+ }
810
+
811
+ if (this.recent.length < 5 || this.drawing < this.armed_after) {
812
+ return;
813
+ }
814
+
815
+ const middle = this.recent[2];
816
+
817
+ /*
818
+ **Both sides, same direction, and the smaller of the two departures.** Judging the middle
819
+ against the median of all four neighbours looks right and is not: when the picture takes a
820
+ persistent *step* — the scene streaming in, a coarse level replaced by a fine one — two of
821
+ the four neighbours sit on the far side of it, the median lands between the two levels, and
822
+ the frame *before* the step is convicted for being where it always was. Every one of
823
+ fourteen close-range poses "caught" one that way, and not one of them was a dropout.
824
+
825
+ A frame that is genuinely alone is above (or below) the pair before it *and* the pair after
826
+ it. Taking the smaller of the two departures is what makes a step score zero: on a step one
827
+ of the two is ~0 by construction, whichever side the middle belongs to.
828
+ */
829
+ const before = (this.recent[0] + this.recent[1]) / 2;
830
+ const after = (this.recent[3] + this.recent[4]) / 2;
831
+
832
+ const from_before = (middle - before) / Math.max(before, 1e-6);
833
+ const from_after = (middle - after) / Math.max(after, 1e-6);
834
+
835
+ const departure = Math.sign(from_before) === Math.sign(from_after)
836
+ ? Math.min(Math.abs(from_before), Math.abs(from_after))
837
+ : 0;
838
+
839
+ const local = (before + after) / 2;
840
+
841
+ this.worst = Math.max(this.worst, departure);
842
+
843
+ if (departure < this.threshold) {
844
+ return;
845
+ }
846
+
847
+ const convicted = this.rows[this.rows.length - 3];
848
+
849
+ this.events.push({
850
+ frame: convicted[0],
851
+ departure: Math.round(departure * 10000) / 10000,
852
+ mean: Math.round(middle * 100) / 100,
853
+ neighbours: Math.round(local * 100) / 100,
854
+ url: this.describe(),
855
+ rows: this.rows.slice(Math.max(0, this.rows.length - 5)),
856
+ });
857
+
858
+ if (this.freeze) {
859
+ this.show(convicted[0]);
860
+ }
861
+ }
862
+
863
+ /**
864
+ * @returns {boolean} whether a frame that was caught can still be put on screen
865
+ */
866
+ get holds_frames() {
867
+ return this.freeze;
868
+ }
869
+
870
+ /**
871
+ * Put one of the held frames on screen and stop sampling, so what is on the page is that frame
872
+ * and a screenshot of the page is a screenshot of it.
873
+ *
874
+ * @param {number} frame
875
+ * @returns {string}
876
+ */
877
+ show(frame) {
878
+ const slot = this.ring.find(entry => entry.frame === frame);
879
+
880
+ if (slot === undefined) {
881
+ return `frame ${frame} is no longer held; the ring has `
882
+ + this.ring.map(entry => entry.frame).join(', ');
883
+ }
884
+
885
+ this.frozen = true;
886
+
887
+ for (const entry of this.ring) {
888
+ entry.canvas.remove();
889
+ }
890
+
891
+ /*
892
+ **Under the panel, not over it.** At a z-index above it the held frame covered the readings
893
+ that explain it and the camera-link button that says where it happened — which is the one
894
+ moment both are wanted. The panel is at 10.
895
+ */
896
+ slot.canvas.style.cssText = 'position:absolute;top:0;left:0;z-index:5;'
897
+ + `width:${this.source.style.width};height:${this.source.style.height}`;
898
+
899
+ document.body.append(slot.canvas);
900
+
901
+ return `frozen on frame ${frame}`;
902
+ }
903
+
904
+ /**
905
+ * @returns {object}
906
+ */
907
+ report() {
908
+ return {
909
+ frames: this.frames,
910
+ threshold: this.threshold,
911
+ worst: Math.round(this.worst * 10000) / 10000,
912
+ events: this.events.length,
913
+ frozen: this.frozen,
914
+ held: this.ring.map(entry => entry.frame).filter(frame => frame >= 0),
915
+ last: this.events[this.events.length - 1],
916
+ };
917
+ }
918
+ }
919
+
920
+ /**
921
+ * @param {object} manifest
922
+ * @returns {number}
923
+ */
924
+ function scene_faces(manifest) {
925
+ let faces = 0;
926
+
927
+ for (const geometry of manifest.geometries) {
928
+ faces += geometry.faces * (geometry.instances.length / 16);
929
+ }
930
+
931
+ return faces;
932
+ }
933
+
934
+ /**
935
+ * @param {number} bytes
936
+ * @returns {string}
937
+ */
938
+ function human_bytes(bytes) {
939
+ if (bytes < 1024) {
940
+ return `${bytes} B`;
941
+ }
942
+
943
+ if (bytes < 1024 * 1024) {
944
+ return `${(bytes / 1024).toFixed(0)} KB`;
945
+ }
946
+
947
+ if (bytes < 1024 * 1024 * 1024) {
948
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
949
+ }
950
+
951
+ return `${(bytes / (1024 ** 3)).toFixed(2)} GB`;
952
+ }
953
+
954
+ /**
955
+ * Where the scene manifest is, out of `?scene=` or out of the asset server's own listing.
956
+ *
957
+ * The listing is what makes the page turn-key: `vgeo_serve.js` answers `/scenes.json` with the
958
+ * manifests in the directory it is serving, so a page opened with no parameters at all finds the
959
+ * one scene that is there. More than one and it takes the first and says which, because a page
960
+ * that refuses to choose between two is a page that shows nothing.
961
+ *
962
+ * @returns {Promise<string>}
963
+ */
964
+ async function resolve_scene_url() {
965
+ const requested = new URL(globalThis.location.href).searchParams.get('scene');
966
+
967
+ if (requested !== null) {
968
+ return new URL(requested, globalThis.location.href).href;
969
+ }
970
+
971
+ const listing = await fetch(`${DEFAULT_ORIGIN}/scenes.json`);
972
+
973
+ if (!listing.ok) {
974
+ throw new Error(
975
+ `no ?scene= was given and ${DEFAULT_ORIGIN} answered HTTP ${listing.status};`
976
+ + ` start the asset server with`
977
+ + ` "node tools/geometry/virtual/vgeo_serve.js <container directory>"`
978
+ );
979
+ }
980
+
981
+ const scenes = await listing.json();
982
+
983
+ if (scenes.length === 0) {
984
+ throw new Error(
985
+ `${DEFAULT_ORIGIN} is serving no .vgeoscene.json;`
986
+ + ` build one with "node tools/geometry/virtual/vgeo_scene.js <manifest.vgeo.json>"`
987
+ );
988
+ }
989
+
990
+ return `${DEFAULT_ORIGIN}/${scenes[0]}`;
991
+ }
992
+
993
+ /**
994
+ * A number that is the same for the same scene and comfortably above what it will ever need.
995
+ *
996
+ * Three of the runtime's capacities are properties of the *scene* rather than of the engine, and
997
+ * all three are knowable before a byte is fetched:
998
+ *
999
+ * - **`geometry_capacity`** is one row per container, and the default of 256 is an order of
1000
+ * magnitude short of a converted set.
1001
+ * - **`lookup_words`** is one word per page of every registered container, out of one arena. The
1002
+ * default of 65,536 holds a fourteenth of Zorah's 947,664, and it is allocated out of an
1003
+ * `OffsetAllocator` whose bins round *down* — it can refuse an allocation that fits — so the
1004
+ * slack here is the difference between a scene that registers and one that refuses its own
1005
+ * largest container for no visible reason.
1006
+ * - **`claim_word_capacity`** is one bit per (collected instance, page ordinal of its geometry).
1007
+ * The engine derives it from a hint of a thousand instances times the whole tier, which at this
1008
+ * scale is both far too small and far too large: the real bound is
1009
+ * `max instances of any one geometry × page capacity`, because ordinals come out of the tier and
1010
+ * the sum of `instances × ordinals` is maximised by giving every slot to the most-instanced
1011
+ * asset. For Zorah that is 381 × the tier, which is a fifth of what the default derivation asks
1012
+ * for and provably enough — doubled here, because an ordinal capacity is a high-water mark and a
1013
+ * scene that has churned can hold more of them than the tier holds pages.
1014
+ *
1015
+ * @param {object} manifest
1016
+ * @param {number} page_capacity
1017
+ * @returns {{geometry_capacity: number, lookup_words: number, claim_word_capacity: number, max_instances: number}}
1018
+ */
1019
+ function scene_capacities(manifest, page_capacity) {
1020
+ let max_instances = 1;
1021
+
1022
+ for (const geometry of manifest.geometries) {
1023
+ max_instances = Math.max(max_instances, geometry.instances.length / 16);
1024
+ }
1025
+
1026
+ return {
1027
+ geometry_capacity: manifest.geometry_count + 64,
1028
+ lookup_words: 1 << Math.ceil(Math.log2(Math.max(1 << 16, manifest.total_pages * 1.5))),
1029
+ claim_word_capacity: Math.ceil(max_instances * page_capacity / 32) * 2,
1030
+ max_instances,
1031
+ };
1032
+ }
1033
+
1034
+ async function start() {
1035
+ const canvas = element('view');
1036
+
1037
+ const page = new VGeoScenePlayground();
1038
+
1039
+ page.canvas = canvas;
1040
+
1041
+ const parameters = new URL(globalThis.location.href).searchParams;
1042
+
1043
+ page.status('reading the scene manifest…');
1044
+
1045
+ const scene_url = await resolve_scene_url();
1046
+
1047
+ const manifest_response = await fetch(scene_url);
1048
+
1049
+ if (!manifest_response.ok) {
1050
+ throw new Error(`${scene_url} answered HTTP ${manifest_response.status}`);
1051
+ }
1052
+
1053
+ const manifest = await manifest_response.json();
1054
+
1055
+ if (manifest.format !== 1) {
1056
+ throw new Error(
1057
+ `${scene_url} is a format-${manifest.format} scene and this page reads format 1`
1058
+ );
1059
+ }
1060
+
1061
+ page.manifest = manifest;
1062
+ page.base = scene_url;
1063
+
1064
+ const box = manifest.world_bounds;
1065
+
1066
+ page.world_radius = 0.5 * Math.hypot(box[3] - box[0], box[4] - box[1], box[5] - box[2]);
1067
+
1068
+ /*
1069
+ Page slots the VRAM tier is built with, over the engine's default of 1,024 and the runtime
1070
+ page's 4,096. A slot holds a page's *runtime rows* rather than its payload — an eighth of the
1071
+ page size — so this is a 96 MB arena; what it really spends is the decoded budget the pool
1072
+ derives from it, which is 12,288 pages of payload in the meshlet pool, or 768 MB.
1073
+
1074
+ **The number is what the τ slider costs, measured.** Zorah from its authored viewpoint at
1075
+ 1578×746 settles at 6,387 resident pages at τ=1 and 9,412 at τ=0.5, which is the slider's
1076
+ busiest end; at 8,192 slots that end fills the tier, `refused` climbs past two hundred
1077
+ thousand, `clamped` stops falling to zero and the cut is being selected by a **budget** rather
1078
+ than by τ — which is the one thing this page is not meant to be showing. A bigger window wants
1079
+ more, because the cut is a function of the resolution, so it is a URL parameter and `refused`
1080
+ is in the panel to say when it was the wrong one.
1081
+ */
1082
+ const page_capacity = Number(parameters.get('pages') ?? 12288);
1083
+
1084
+ const capacities = scene_capacities(manifest, page_capacity);
1085
+
1086
+ const renderer = new Renderer();
1087
+
1088
+ page.renderer = renderer;
1089
+
1090
+ await renderer.initialize({
1091
+ context: canvas.getContext('webgpu'),
1092
+ pixelRatio: globalThis.devicePixelRatio || 1,
1093
+ });
1094
+
1095
+ /*
1096
+ TAA on. A cut is re-selected every frame against a threshold in pixels, so the seams between
1097
+ two levels of detail land on different pixels from one frame to the next and every one of them
1098
+ is an aliased edge that crawls without a temporal filter — and the crawl reads as the cut being
1099
+ unstable when it is the rasterizer being point-sampled. It costs the readings nothing: TAA is
1100
+ downstream of the G-buffer, long after the cut has been selected and drawn.
1101
+ */
1102
+ renderer.feature_taa_enabled = true;
1103
+ renderer.feature_ssao_enabled = false;
1104
+ renderer.feature_bloom_enabled = false;
1105
+
1106
+ const scene = new Scene();
1107
+
1108
+ scene.dataset = new EntityComponentDataset();
1109
+
1110
+ page.scene = scene;
1111
+
1112
+ /*
1113
+ The scene's own sun when the manifest names one, and the engine's default otherwise.
1114
+
1115
+ A converted container carries geometry and nothing else — no material authoring, no lights —
1116
+ so the sun is most of what there is to shade it with, and which way it points is not
1117
+ decoration: the engine's default comes down from above and slightly behind the world axes,
1118
+ which over an outdoor scene authored around a different one puts every façade a viewer would
1119
+ stand in front of into ambient. `--sundirection` in Zorah's own config is the direction it
1120
+ shines *from*, which is the opposite of the direction a light points.
1121
+ */
1122
+ const sun = manifest.sun === undefined
1123
+ ? make_sunlight()
1124
+ : make_sunlight({
1125
+ direction: new Vector3(-manifest.sun[0], -manifest.sun[1], -manifest.sun[2]),
1126
+ });
1127
+
1128
+ place_light(scene, sun);
1129
+
1130
+ /*
1131
+ Image-based lighting, and for the geometry rather than for the picture: a sun alone puts every
1132
+ surface facing away from it at ambient zero, which is exactly the silhouettes and the creases a
1133
+ cut is worth looking at. Not awaited — `test_assets/` is gitignored, so a checkout without it
1134
+ gets the sun alone and still draws every triangle the cut selected.
1135
+ */
1136
+ load_default_environment(scene);
1137
+
1138
+ const camera = new PerspectiveCamera();
1139
+
1140
+ camera.fov_degrees = manifest.camera?.fov ?? 60;
1141
+
1142
+ page.camera = camera;
1143
+ page.controller = new OrbitalCameraController(camera, document.body);
1144
+
1145
+ page.fit();
1146
+
1147
+ globalThis.addEventListener('resize', () => page.fit());
1148
+
1149
+ // one frame to let the renderer build its scene context, which is what owns the geometry manager
1150
+ renderer.render(camera, scene, 1 / 60);
1151
+
1152
+ const scene_context = renderer.scenes.obtain(scene);
1153
+
1154
+ page.scene_context = scene_context;
1155
+
1156
+ /*
1157
+ The residency's knobs, which are per-reading budgets rather than sizes — nothing here scales
1158
+ with how much is resident. What a scene changes about them is the breadth each reading has to
1159
+ cover: the wants of one reading now come from thousands of containers rather than from one, so
1160
+ a fetch budget sized for a single asset spends the whole reading on whichever asset happened to
1161
+ be first in the want list.
1162
+ */
1163
+ const residency = new VGeoResidencyOptions();
1164
+
1165
+ residency.ram_budget_bytes = 1024 * 1024 * 1024;
1166
+
1167
+ /*
1168
+ VRAM is a strict subset of RAM, so the RAM tier has to be able to hold the whole VRAM tier and
1169
+ every container's pinned root besides — 8,192 and 2,966 for Zorah, against a default of 16,384
1170
+ slots that the two together are already within sight of.
1171
+ */
1172
+ residency.ram_slot_capacity = Math.max(1 << 14, (page_capacity + manifest.geometry_count) * 2);
1173
+
1174
+ residency.max_fetches_per_reading = 64;
1175
+ residency.max_concurrent_fetches = 24;
1176
+ residency.max_promotions_per_reading = 128;
1177
+
1178
+ page.runtime = scene_context.enable_virtual_geometry({
1179
+ page_size: manifest.page_size,
1180
+ table_options: {
1181
+ page_capacity,
1182
+ geometry_capacity: capacities.geometry_capacity,
1183
+ lookup_words: capacities.lookup_words,
1184
+ },
1185
+ claim_word_capacity: capacities.claim_word_capacity,
1186
+ /*
1187
+ Pages one frame may ask for, across every view. A single asset's cut wants a handful once
1188
+ it has settled; a cold scene of three thousand containers wants from all of them at once,
1189
+ and a want the frame had no room for is one more reading before that page arrives.
1190
+ */
1191
+ want_capacity: 8192,
1192
+ /*
1193
+ Traversal items one wavefront may hold, over the engine's 1 << 16. This is the capacity a
1194
+ *scene* reaches first and the only one whose overflow is invisible without looking: at
1195
+ τ=0.5 with 17,424 instances the descent pushed past 65,536 items in flight and
1196
+ `VGEO_CUT_STAT.QUEUE_DROPPED` came back at 32,676 — a third of the frontier thrown away,
1197
+ which showed up not as an error but as `drawn` wandering over ten thousand clusters
1198
+ between one frame and the next as different subtrees lost the race.
1199
+
1200
+ It is two buffers of `capacity × 16` bytes per expansion site, so this is 4 MiB a site
1201
+ against the 1 MiB the default costs. At 1 << 18 the same view drops nothing.
1202
+ */
1203
+ queue_capacity: 1 << 18,
1204
+ /*
1205
+ The wavefront bound is the engine's and is deliberately **not** set here. It starts at 8
1206
+ and `GPUVirtualGeometryRuntime.note_incomplete_descent` raises it from what the cut
1207
+ reports, so this page gets the same escalation any host does — and pinning it here would
1208
+ hide the one behaviour worth watching from the page best placed to watch it.
1209
+
1210
+ `?iterations=` overrides it for an experiment. At 8 with no escalation, this scene at
1211
+ 3419×1286 left up to 13,467 items queued on 750 frames of 900 and `drawn` swung 11,857
1212
+ clusters; the panel's `dropped: queue N` row is that reading.
1213
+ */
1214
+ ...(parameters.has('iterations')
1215
+ ? { max_iterations: Number(parameters.get('iterations')), max_iteration_ceiling: Number(parameters.get('iterations')) }
1216
+ : {}),
1217
+ residency,
1218
+ });
1219
+
1220
+ page.residency = scene_context.virtual_geometry_residency;
1221
+ page.feedback = scene_context.virtual_geometry_feedback;
1222
+
1223
+ /*
1224
+ What the rasterization collections are primed to expect. Both of these grow on their own from
1225
+ what the frame reports, so priming is about the first seconds rather than the steady state —
1226
+ but the first seconds are when the scene is being placed, and a `Meshes` limit of 1e4 against
1227
+ 17,424 instances drops the difference on the floor and counts them as `instances_dropped` while
1228
+ the collection catches up.
1229
+ */
1230
+ const limits = renderer.graphics.collection_limits;
1231
+
1232
+ limits.prime(
1233
+ RasterizationCollectionType.Meshes,
1234
+ Math.max(1e4, Math.ceil(manifest.instance_count * 1.25))
1235
+ );
1236
+
1237
+ limits.prime(RasterizationCollectionType.Meshlets, 5e5);
1238
+
1239
+ const material = new StandardShadeMaterial();
1240
+
1241
+ material.diffuse_color.copy(new Color(0.72, 0.74, 0.78));
1242
+ material.roughness_factor = 0.55;
1243
+ material.metallic_factor = 0;
1244
+
1245
+ /*
1246
+ `?side=front|back|double` sets the whole scene's draw side, which is the cheapest way to ask
1247
+ whether what you are looking at is a winding problem: `double` culls nothing, so any surface
1248
+ that appears under it and not under `front` was being culled rather than being absent.
1249
+ */
1250
+ material.draw_side = DRAW_SIDES[parameters.get('side')] ?? ShadeDrawSide.Front;
1251
+
1252
+ page.material = material;
1253
+
1254
+ /*
1255
+ `?mirrored=flip|tint|double` puts reflected instances in a bucket of their own — see
1256
+ {@link VGeoScenePlayground.mirror_material}. Off by default: the engine reverses their winding
1257
+ per instance, so the default is already the correct picture.
1258
+
1259
+ - **tint** leaves the side alone and colours them, so you can see which geometry in the frame
1260
+ is reflected without changing what is drawn.
1261
+ - **flip** draws them `Back`-sided. This was the page's own fix before the engine had one; now
1262
+ it reverses a second time and reproduces the old inside-out picture on demand.
1263
+ - **double** draws both sides of them, which fills the holes and gets the shading wrong, and is
1264
+ here because it is the fix people reach for first.
1265
+ */
1266
+ const mirrored_mode = parameters.get('mirrored');
1267
+
1268
+ const mirror_material = new StandardShadeMaterial();
1269
+
1270
+ mirror_material.copy(material);
1271
+
1272
+ if (mirrored_mode === 'flip') {
1273
+ mirror_material.draw_side = ShadeDrawSide.Back;
1274
+ } else if (mirrored_mode === 'double') {
1275
+ mirror_material.draw_side = ShadeDrawSide.Double;
1276
+ } else if (mirrored_mode === 'tint') {
1277
+ mirror_material.diffuse_color.copy(new Color(0.85, 0.16, 0.12));
1278
+ }
1279
+
1280
+ page.mirror_material = mirrored_mode === null ? material : mirror_material;
1281
+
1282
+ globalThis.addEventListener('pagehide', () => page.feedback.destroy());
1283
+
1284
+ renderer.add_extension(new VGeoFrameStart(page));
1285
+ renderer.add_extension(new VGeoFrameEnd(page));
1286
+
1287
+ element('tau').addEventListener('input', event => {
1288
+ page.runtime.tau = Number(event.target.value);
1289
+
1290
+ element('tau-value').textContent = page.runtime.tau.toFixed(2);
1291
+ });
1292
+
1293
+ /*
1294
+ τ before the first container, for a session measuring one. Residency is a **high-water mark** —
1295
+ the tier only evicts under pressure — so a page swept downwards through the thresholds reports,
1296
+ at every step, the pages the *busiest* setting it has already been at asked for. A row of a τ
1297
+ table is only about τ if the page has been at nothing else, which means a load per row.
1298
+ */
1299
+ const requested_tau = Number(parameters.get('tau'));
1300
+
1301
+ if (Number.isFinite(requested_tau) && requested_tau > 0) {
1302
+ page.runtime.tau = requested_tau;
1303
+
1304
+ element('tau').value = String(requested_tau);
1305
+ element('tau-value').textContent = requested_tau.toFixed(2);
1306
+ }
1307
+
1308
+ // a drag on the τ slider is a drag on the body, and the camera controller listens there
1309
+ element('panel').addEventListener('pointerdown', event => event.stopPropagation());
1310
+
1311
+ install_profile_hotkey({
1312
+ renderer,
1313
+ name: "vgeo_scene",
1314
+ status: element('profile-status'),
1315
+ settings: () => {
1316
+ const state = page.state();
1317
+
1318
+ return [
1319
+ `τ=${state.tau}px`,
1320
+ state.scene.name,
1321
+ `${state.scene.containers} containers`,
1322
+ `${state.scene.instances} instances`,
1323
+ `${state.cut.drawn} clusters`,
1324
+ `${state.vram.pages} of ${state.vram.capacity} pages resident`,
1325
+ `${state.screen_width}×${state.screen_height}`,
1326
+ ];
1327
+ },
1328
+ });
1329
+
1330
+ globalThis.__state = () => page.state();
1331
+ globalThis.__page = page;
1332
+ globalThis.__look = (eye, target) => page.look(eye, target);
1333
+ globalThis.__camera_url = () => camera_url(page);
1334
+
1335
+ /*
1336
+ The camera before the first container, so the cut that runs while the scene is arriving is the
1337
+ one the viewpoint asked for rather than a cut of everything from the origin.
1338
+
1339
+ The manifest's camera when it has one — a converted scene's own authored pose, which is the
1340
+ only thing that knows which forty metres of a hundred-kilometre box the scene is *about*. With
1341
+ no camera, the box: wrong for Zorah and right for a scene that is its own extent, and the
1342
+ parameter overrides both.
1343
+ */
1344
+ const eye = read_vector(parameters.get('eye')) ?? manifest.camera?.eye;
1345
+ const target = read_vector(parameters.get('at')) ?? manifest.camera?.target;
1346
+
1347
+ const center = [
1348
+ (box[0] + box[3]) / 2, (box[1] + box[4]) / 2, (box[2] + box[5]) / 2,
1349
+ ];
1350
+
1351
+ page.viewpoints = [
1352
+ {
1353
+ name: 'scene',
1354
+ eye: [
1355
+ center[0] + page.world_radius * 0.9,
1356
+ center[1] + page.world_radius * 0.5,
1357
+ center[2] + page.world_radius * 0.9,
1358
+ ],
1359
+ target: center,
1360
+ },
1361
+ ];
1362
+
1363
+ if (manifest.camera !== undefined) {
1364
+ page.viewpoints.unshift({
1365
+ name: 'authored',
1366
+ eye: manifest.camera.eye,
1367
+ target: manifest.camera.target,
1368
+ });
1369
+ }
1370
+
1371
+ const start_view = eye !== undefined && target !== undefined
1372
+ ? { name: 'url', eye, target }
1373
+ : page.viewpoints[0];
1374
+
1375
+ page.look(start_view.eye, start_view.target);
1376
+
1377
+ wire_viewpoints(page);
1378
+ wire_camera_capture(page);
1379
+
1380
+ /*
1381
+ `?catch=0.15` arms the dropout catcher — see {@link FlickerCatcher} for why it reads pixels
1382
+ rather than counters. Off by default: it is a `drawImage` and a `getImageData` every frame, and
1383
+ a page that is a measurement harness should not pay for a diagnostic nobody asked for.
1384
+
1385
+ `&freeze=1` stops on the first one and leaves it on screen, which is what makes a screenshot of
1386
+ the page a screenshot of the offending frame. Without it the catcher keeps counting and the
1387
+ panel shows the running tally, which is the mode for flying around looking for one.
1388
+ */
1389
+ const catch_threshold = Number(parameters.get('catch'));
1390
+
1391
+ if (Number.isFinite(catch_threshold) && catch_threshold > 0) {
1392
+ page.catcher = new FlickerCatcher(
1393
+ canvas, catch_threshold, parameters.get('freeze') !== null, () => camera_url(page)
1394
+ );
1395
+
1396
+ globalThis.__caught = () => page.catcher.report();
1397
+ globalThis.__show_frame = frame => page.catcher.show(frame);
1398
+ globalThis.__catch_rows = () => page.catcher.rows;
1399
+ }
1400
+
1401
+ let stopped = false;
1402
+ let last_time;
1403
+
1404
+ function tick(time) {
1405
+ if (stopped) {
1406
+ return;
1407
+ }
1408
+
1409
+ const dt = last_time === undefined ? 1 / 60 : Math.min((time - last_time) / 1000, 0.05);
1410
+
1411
+ if (last_time !== undefined) {
1412
+ page.frame_ms += ((time - last_time) - page.frame_ms) * 0.1;
1413
+ }
1414
+
1415
+ last_time = time;
1416
+
1417
+ try {
1418
+ page.controller.update(dt);
1419
+
1420
+ renderer.render(camera, scene, dt);
1421
+ } catch (error) {
1422
+ stopped = true;
1423
+
1424
+ page.status(error.message ?? String(error), true);
1425
+
1426
+ console.error(error);
1427
+
1428
+ return;
1429
+ }
1430
+
1431
+ const state = page.state();
1432
+
1433
+ // after the render, so the canvas it reads is this frame's
1434
+ page.catcher?.sample(state);
1435
+
1436
+ paint(page, capacities, state);
1437
+
1438
+ requestAnimationFrame(tick);
1439
+ }
1440
+
1441
+ requestAnimationFrame(tick);
1442
+
1443
+ page.status(`opening ${manifest.geometry_count.toLocaleString()} containers…`);
1444
+
1445
+ /*
1446
+ Not awaited: the frame loop above is already running, and a scene that placed its first
1447
+ container two hundred frames before its last is the loop this page exists to show.
1448
+ */
1449
+ page.open_scene(manifest);
1450
+ }
1451
+
1452
+ /**
1453
+ * `"x,y,z"` out of a URL parameter.
1454
+ *
1455
+ * @param {string|null} text
1456
+ * @returns {number[]|undefined}
1457
+ */
1458
+ function read_vector(text) {
1459
+ if (text === null) {
1460
+ return undefined;
1461
+ }
1462
+
1463
+ const numbers = text.split(',').map(Number);
1464
+
1465
+ if (numbers.length !== 3 || numbers.some(n => !Number.isFinite(n))) {
1466
+ return undefined;
1467
+ }
1468
+
1469
+ return numbers;
1470
+ }
1471
+
1472
+ /**
1473
+ * A link that reopens the page **where it is standing now**, with everything else about how it was
1474
+ * opened left alone.
1475
+ *
1476
+ * This exists because a viewpoint is the one piece of state that a person has and a session does
1477
+ * not. Everything else this page can be told — the scene, τ, the tier's size, which side to draw —
1478
+ * is a URL parameter already; where the camera got dragged to is not, and a phenomenon that happens
1479
+ * "in many places" is useless to chase without one of them. So the button hands over a URL rather
1480
+ * than three numbers: paste it back and the page opens on the same view, at the same threshold,
1481
+ * with the same tier, and with whatever diagnostic was already armed still armed.
1482
+ *
1483
+ * `?eye=`/`?at=` were already the way in; this is the way out.
1484
+ *
1485
+ * @param {VGeoScenePlayground} page
1486
+ * @returns {string}
1487
+ */
1488
+ function camera_url(page) {
1489
+ const url = new URL(globalThis.location.href);
1490
+
1491
+ const state = page.state();
1492
+
1493
+ // nine significant digits, which is exact for a float and about half the characters an f64
1494
+ // print of one costs — the same trim `vgeo_scene.js` writes its matrices with
1495
+ const trim = value => Number(value.toPrecision(9));
1496
+
1497
+ const triple = values => values.map(trim).join(',');
1498
+
1499
+ url.searchParams.set('eye', triple(state.camera.eye));
1500
+ url.searchParams.set('at', triple([
1501
+ state.camera.target.x, state.camera.target.y, state.camera.target.z,
1502
+ ]));
1503
+
1504
+ /*
1505
+ τ as it stands rather than as it was asked for. The slider moves and the parameter does not
1506
+ follow it, so a link that carried only what the page was opened with would reopen at a
1507
+ different cut from the one being looked at — which for a page about a cut is the whole thing.
1508
+ */
1509
+ url.searchParams.set('tau', String(trim(state.tau)));
1510
+
1511
+ /*
1512
+ `URLSearchParams` percent-encodes the separators, and `eye=27.9%2C8.9%2C-4` is correct, ugly,
1513
+ and about to be read by a person and pasted into a message. A comma is legal unencoded in a
1514
+ query string, so it goes back.
1515
+ */
1516
+ return url.href.replace(/%2C/g, ',');
1517
+ }
1518
+
1519
+ /**
1520
+ * @param {VGeoScenePlayground} page
1521
+ */
1522
+ function wire_camera_capture(page) {
1523
+ const button = element('copy-camera');
1524
+ const output = element('camera-url');
1525
+
1526
+ button.addEventListener('click', async () => {
1527
+ const url = camera_url(page);
1528
+
1529
+ output.textContent = url;
1530
+ output.hidden = false;
1531
+
1532
+ /*
1533
+ The clipboard is a permission and a secure context away, and this page is served over
1534
+ plain HTTP from a dev server on whatever host someone pointed it at. So the text is put on
1535
+ screen first and copied second: the button works either way, and the label says which
1536
+ happened rather than failing silently.
1537
+ */
1538
+ try {
1539
+ await navigator.clipboard.writeText(url);
1540
+
1541
+ button.textContent = 'copied to clipboard';
1542
+
1543
+ // the controller's keyboard device listens on the body, and a click leaves focus here.
1544
+ // Only on the happy path: the fallback below has just made a selection, and moving
1545
+ // focus would drop it.
1546
+ document.body.focus();
1547
+ } catch {
1548
+ /*
1549
+ Selected rather than merely shown, so the fallback is one Ctrl+C rather than a drag
1550
+ across four lines of wrapped URL. `user-select: all` makes a click do the same thing,
1551
+ and this makes the click unnecessary.
1552
+ */
1553
+ const range = document.createRange();
1554
+
1555
+ range.selectNodeContents(output);
1556
+
1557
+ const selection = globalThis.getSelection();
1558
+
1559
+ selection.removeAllRanges();
1560
+ selection.addRange(range);
1561
+
1562
+ button.textContent = 'selected below — Ctrl+C';
1563
+ }
1564
+
1565
+ setTimeout(() => { button.textContent = 'copy camera link'; }, 2500);
1566
+ });
1567
+ }
1568
+
1569
+ /**
1570
+ * @param {VGeoScenePlayground} page
1571
+ */
1572
+ function wire_viewpoints(page) {
1573
+ const container = element('views');
1574
+
1575
+ container.innerHTML = '';
1576
+
1577
+ for (const viewpoint of page.viewpoints) {
1578
+ const button = document.createElement('button');
1579
+
1580
+ button.textContent = viewpoint.name;
1581
+
1582
+ button.addEventListener('click', () => {
1583
+ page.look(viewpoint.eye, viewpoint.target);
1584
+
1585
+ // the controller's keyboard device listens on the body, and a click leaves focus here
1586
+ document.body.focus();
1587
+ });
1588
+
1589
+ container.append(button);
1590
+ }
1591
+ }
1592
+
1593
+ /**
1594
+ * @param {VGeoScenePlayground} page
1595
+ * @param {object} capacities
1596
+ */
1597
+ function paint(page, capacities, state) {
1598
+ const scene = state.scene;
1599
+
1600
+ element('progress').firstElementChild.style.width =
1601
+ `${scene.declared === 0 ? 0 : (100 * scene.containers / scene.declared).toFixed(1)}%`;
1602
+
1603
+ rows('scene', [
1604
+ ['source', escape_html(scene.name)],
1605
+ ['containers', `${scene.containers.toLocaleString()} / ${scene.declared.toLocaleString()}`],
1606
+ ['instances', scene.instances.toLocaleString()],
1607
+ ['reflected', scene.mirrored.toLocaleString()],
1608
+ ['source triangles', scene.faces.toLocaleString()],
1609
+ ['on the server', human_bytes(scene.bytes)],
1610
+ ['read', human_bytes(scene.bytes_read)],
1611
+ ['view', `${state.screen_width}×${state.screen_height}`],
1612
+ ['frame', `${state.frame_ms.toFixed(1)} ms`],
1613
+ ]);
1614
+
1615
+ rows('cut', [
1616
+ ['drawn clusters', state.cut.drawn.toLocaleString()],
1617
+ ['clamped', state.cut.clamped.toLocaleString()],
1618
+ ['pages visited', state.cut.pages.toLocaleString()],
1619
+ ['clusters tested', state.cut.clusters_tested.toLocaleString()],
1620
+ ['subtrees culled', (state.cut.culled_frustum + state.cut.culled_lod).toLocaleString()],
1621
+ ['clusters culled', state.cut.culled_cluster.toLocaleString()],
1622
+ [
1623
+ 'wavefronts',
1624
+ `${state.cut.wavefronts} / ${state.cut.max_iterations}`
1625
+ + (state.cut.iteration_escalations > 0 ? ` (${state.cut.iteration_escalations}↑)` : ''),
1626
+ ],
1627
+ ['wants', state.wants],
1628
+ ]);
1629
+
1630
+ /*
1631
+ Four counts of the cut giving up, kept apart from the six of it succeeding, because at this
1632
+ scale every one of them names a different capacity and a scene is the first thing big enough
1633
+ to reach any of them.
1634
+ */
1635
+ const dropped = [
1636
+ ['meshlets', state.cut.meshlets_dropped],
1637
+ ['instances', state.cut.instances_dropped],
1638
+ ['queue', state.cut.queue_dropped + state.cut.queue_remaining],
1639
+ ['wants', state.cut.wants_dropped],
1640
+ ].filter(([, count]) => count > 0);
1641
+
1642
+ rows('residency', [
1643
+ ['VRAM pages', `${state.vram.pages.toLocaleString()} / ${state.vram.capacity.toLocaleString()}`],
1644
+ ['VRAM bytes', human_bytes(state.vram.bytes)],
1645
+ ['RAM pages', state.ram.pages.toLocaleString()],
1646
+ ['RAM bytes', human_bytes(state.ram.bytes)],
1647
+ ['evicted', `${state.vram.evicted.toLocaleString()} / ${state.ram.evicted.toLocaleString()}`],
1648
+ ['fetching', state.fetches_in_flight],
1649
+ [
1650
+ 'refused',
1651
+ `<span class="${state.vram.refused > 0 ? 'bad' : 'good'}">`
1652
+ + `${state.vram.refused.toLocaleString()}</span>`,
1653
+ ],
1654
+ [
1655
+ 'dropped',
1656
+ dropped.length === 0
1657
+ ? '<span class="good">0</span>'
1658
+ : `<span class="bad">${dropped.map(([n, c]) => `${n} ${c.toLocaleString()}`).join(', ')}</span>`,
1659
+ ],
1660
+ ['claim words', capacities.claim_word_capacity.toLocaleString()],
1661
+ ]);
1662
+
1663
+ const catcher = page.catcher;
1664
+
1665
+ if (catcher === undefined) {
1666
+ return;
1667
+ }
1668
+
1669
+ const report = catcher.report();
1670
+
1671
+ element('catch-section').hidden = false;
1672
+
1673
+ rows('catch', [
1674
+ ['sampled', report.frames.toLocaleString()],
1675
+ ['threshold', `${(100 * report.threshold).toFixed(0)}%`],
1676
+ ['worst so far', `${(100 * report.worst).toFixed(1)}%`],
1677
+ [
1678
+ 'caught',
1679
+ `<span class="${report.events > 0 ? 'bad' : 'good'}">${report.events}</span>`,
1680
+ ],
1681
+ ['frozen', report.frozen ? `<span class="bad">yes</span>` : 'no'],
1682
+ ['last at frame', report.last === undefined ? '—' : report.last.frame],
1683
+ ]);
1684
+ }
1685
+
1686
+ /**
1687
+ * @param {string} id
1688
+ * @param {Array<[string, string|number]>} entries
1689
+ */
1690
+ function rows(id, entries) {
1691
+ element(id).innerHTML = entries
1692
+ .map(([name, value]) => `<div class="row"><span>${name}</span><span>${value}</span></div>`)
1693
+ .join('');
1694
+ }
1695
+
1696
+ /**
1697
+ * A manifest's source name lands in the panel's markup, so it is a string from outside the page in
1698
+ * the one place a string from outside the page can do something.
1699
+ *
1700
+ * @param {string} text
1701
+ * @returns {string}
1702
+ */
1703
+ function escape_html(text) {
1704
+ return text.replace(/[&<>"]/g, character => ({
1705
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;',
1706
+ })[character]);
1707
+ }
1708
+
1709
+ start().catch(error => {
1710
+ element('status').textContent = error.message ?? String(error);
1711
+ element('status').classList.add('error');
1712
+
1713
+ globalThis.__state = () => ({ error: error.message ?? String(error) });
1714
+
1715
+ console.error(error);
1716
+ });