@forgeax/engine-runtime 0.1.21 → 0.1.24

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 (410) hide show
  1. package/README.md +18 -9
  2. package/dist/__tests__/advance-animation-player.cases.unit.test.d.ts +2 -0
  3. package/dist/__tests__/advance-animation-player.cases.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/asset-registry-aabb.cases.unit.test.d.ts +2 -0
  5. package/dist/__tests__/asset-registry-aabb.cases.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/asset-registry-bootstrap.cases.unit.test.d.ts +2 -0
  7. package/dist/__tests__/asset-registry-bootstrap.cases.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/asset-registry-builtin-nineslice.cases.unit.test.d.ts +2 -0
  9. package/dist/__tests__/asset-registry-builtin-nineslice.cases.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/asset-registry-d9-tile-sampler-soft-warn.cases.unit.test.d.ts +2 -0
  11. package/dist/__tests__/asset-registry-d9-tile-sampler-soft-warn.cases.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/asset-registry-guid.cases.unit.test.d.ts +2 -0
  13. package/dist/__tests__/asset-registry-guid.cases.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/asset-registry-material-validate.cases.unit.test.d.ts +2 -0
  15. package/dist/__tests__/asset-registry-material-validate.cases.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/asset-registry-mesh-fail-fast.cases.unit.test.d.ts +2 -0
  17. package/dist/__tests__/asset-registry-mesh-fail-fast.cases.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/asset-registry-scene.cases.unit.test.d.ts +2 -0
  19. package/dist/__tests__/asset-registry-scene.cases.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/asset-registry-sprite-slices-validate.cases.unit.test.d.ts +2 -0
  21. package/dist/__tests__/asset-registry-sprite-slices-validate.cases.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/asset-renderer-assembly.cases.unit.test.d.ts +2 -0
  23. package/dist/__tests__/asset-renderer-assembly.cases.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/asset.cases.unit.test.d.ts +2 -0
  25. package/dist/__tests__/asset.cases.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/bloom-gating.cases.unit.test.d.ts +2 -0
  27. package/dist/__tests__/bloom-gating.cases.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/builtin-guid-ssot.cases.unit.test.d.ts +2 -0
  29. package/dist/__tests__/builtin-guid-ssot.cases.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/builtin-pack.cases.unit.test.d.ts +2 -0
  31. package/dist/__tests__/builtin-pack.cases.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/camera-antialias.cases.unit.test.d.ts +2 -0
  33. package/dist/__tests__/camera-antialias.cases.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/camera-bloom.cases.unit.test.d.ts +2 -0
  35. package/dist/__tests__/camera-bloom.cases.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/camera-clear-schema.cases.unit.test.d.ts +2 -0
  37. package/dist/__tests__/camera-clear-schema.cases.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/camera-ortho.cases.unit.test.d.ts +2 -0
  39. package/dist/__tests__/camera-ortho.cases.unit.test.d.ts.map +1 -0
  40. package/dist/__tests__/cluster-binner.cases.unit.test.d.ts +2 -0
  41. package/dist/__tests__/cluster-binner.cases.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/create-renderer-fallback-shader-manifest.cases.unit.test.d.ts +2 -0
  43. package/dist/__tests__/create-renderer-fallback-shader-manifest.cases.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/create-renderer-uniform-fallback.cases.unit.test.d.ts +2 -0
  45. package/dist/__tests__/create-renderer-uniform-fallback.cases.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/cubemap-upload.cases.unit.test.d.ts +2 -0
  47. package/dist/__tests__/cubemap-upload.cases.unit.test.d.ts.map +1 -0
  48. package/dist/__tests__/dev-import-transport.cases.unit.test.d.ts +2 -0
  49. package/dist/__tests__/dev-import-transport.cases.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/engine-metrics.cases.unit.test.d.ts +2 -0
  51. package/dist/__tests__/engine-metrics.cases.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/equirect-projection-error.cases.unit.test.d.ts +2 -0
  53. package/dist/__tests__/equirect-projection-error.cases.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/font-asset-load.cases.unit.test.d.ts +2 -0
  55. package/dist/__tests__/font-asset-load.cases.unit.test.d.ts.map +1 -0
  56. package/dist/__tests__/fxaa-intermediate-texture.cases.unit.test.d.ts +2 -0
  57. package/dist/__tests__/fxaa-intermediate-texture.cases.unit.test.d.ts.map +1 -0
  58. package/dist/__tests__/fxaa-pipeline.cases.unit.test.d.ts +2 -0
  59. package/dist/__tests__/fxaa-pipeline.cases.unit.test.d.ts.map +1 -0
  60. package/dist/__tests__/gpu-residency.cases.unit.test.d.ts +2 -0
  61. package/dist/__tests__/gpu-residency.cases.unit.test.d.ts.map +1 -0
  62. package/dist/__tests__/gpu-resource-release.cases.unit.test.d.ts +2 -0
  63. package/dist/__tests__/gpu-resource-release.cases.unit.test.d.ts.map +1 -0
  64. package/dist/__tests__/handle-quad.cases.unit.test.d.ts +2 -0
  65. package/dist/__tests__/handle-quad.cases.unit.test.d.ts.map +1 -0
  66. package/dist/__tests__/hdrp-bgl-slots.cases.unit.test.d.ts +2 -0
  67. package/dist/__tests__/hdrp-bgl-slots.cases.unit.test.d.ts.map +1 -0
  68. package/dist/__tests__/hdrp-grid-invalid.cases.unit.test.d.ts +2 -0
  69. package/dist/__tests__/hdrp-grid-invalid.cases.unit.test.d.ts.map +1 -0
  70. package/dist/__tests__/hdrp-index-list-overflow-once.cases.unit.test.d.ts +2 -0
  71. package/dist/__tests__/hdrp-index-list-overflow-once.cases.unit.test.d.ts.map +1 -0
  72. package/dist/__tests__/hdrp-light-budget.cases.unit.test.d.ts +2 -0
  73. package/dist/__tests__/hdrp-light-budget.cases.unit.test.d.ts.map +1 -0
  74. package/dist/__tests__/hdrp-pipeline-asset-config.cases.unit.test.d.ts +2 -0
  75. package/dist/__tests__/hdrp-pipeline-asset-config.cases.unit.test.d.ts.map +1 -0
  76. package/dist/__tests__/helpers/register-runtime-components.d.ts.map +1 -1
  77. package/dist/__tests__/helpers/register-scene-components.d.ts.map +1 -1
  78. package/dist/__tests__/helpers/renderer-device-loss-dawn.d.ts +25 -0
  79. package/dist/__tests__/helpers/renderer-device-loss-dawn.d.ts.map +1 -0
  80. package/dist/__tests__/helpers/standard-material-manifest.d.ts +0 -1
  81. package/dist/__tests__/helpers/standard-material-manifest.d.ts.map +1 -1
  82. package/dist/__tests__/ibl-pipeline-cache.cases.unit.test.d.ts +2 -0
  83. package/dist/__tests__/ibl-pipeline-cache.cases.unit.test.d.ts.map +1 -0
  84. package/dist/__tests__/lazy-catalog.cases.unit.test.d.ts +2 -0
  85. package/dist/__tests__/lazy-catalog.cases.unit.test.d.ts.map +1 -0
  86. package/dist/__tests__/load-by-guid-hdr.cases.unit.test.d.ts +2 -0
  87. package/dist/__tests__/load-by-guid-hdr.cases.unit.test.d.ts.map +1 -0
  88. package/dist/__tests__/load-by-guid-prod-material-parent.cases.unit.test.d.ts +2 -0
  89. package/dist/__tests__/load-by-guid-prod-material-parent.cases.unit.test.d.ts.map +1 -0
  90. package/dist/__tests__/load-by-guid-prod.cases.unit.test.d.ts +2 -0
  91. package/dist/__tests__/load-by-guid-prod.cases.unit.test.d.ts.map +1 -0
  92. package/dist/__tests__/loader-registry.cases.unit.test.d.ts +2 -0
  93. package/dist/__tests__/loader-registry.cases.unit.test.d.ts.map +1 -0
  94. package/dist/__tests__/m7-hdrp-demo-shape.cases.unit.test.d.ts +2 -0
  95. package/dist/__tests__/m7-hdrp-demo-shape.cases.unit.test.d.ts.map +1 -0
  96. package/dist/__tests__/mipmap-formula.cases.unit.test.d.ts +2 -0
  97. package/dist/__tests__/mipmap-formula.cases.unit.test.d.ts.map +1 -0
  98. package/dist/__tests__/mipmap-pipeline-cache.cases.unit.test.d.ts +2 -0
  99. package/dist/__tests__/mipmap-pipeline-cache.cases.unit.test.d.ts.map +1 -0
  100. package/dist/__tests__/parse-asset-payload-material.cases.unit.test.d.ts +2 -0
  101. package/dist/__tests__/parse-asset-payload-material.cases.unit.test.d.ts.map +1 -0
  102. package/dist/__tests__/parse-asset-payload-texture.cases.unit.test.d.ts +2 -0
  103. package/dist/__tests__/parse-asset-payload-texture.cases.unit.test.d.ts.map +1 -0
  104. package/dist/__tests__/parse-scene-payload-refs.cases.unit.test.d.ts +2 -0
  105. package/dist/__tests__/parse-scene-payload-refs.cases.unit.test.d.ts.map +1 -0
  106. package/dist/__tests__/pass-selector.cases.unit.test.d.ts +2 -0
  107. package/dist/__tests__/pass-selector.cases.unit.test.d.ts.map +1 -0
  108. package/dist/__tests__/pipeline-builder.cases.unit.test.d.ts +2 -0
  109. package/dist/__tests__/pipeline-builder.cases.unit.test.d.ts.map +1 -0
  110. package/dist/__tests__/pipeline-cache-key-topology.cases.unit.test.d.ts +2 -0
  111. package/dist/__tests__/pipeline-cache-key-topology.cases.unit.test.d.ts.map +1 -0
  112. package/dist/__tests__/pipeline-vertex-stride-branch.cases.unit.test.d.ts +2 -0
  113. package/dist/__tests__/pipeline-vertex-stride-branch.cases.unit.test.d.ts.map +1 -0
  114. package/dist/__tests__/pipeline.cases.unit.test.d.ts +2 -0
  115. package/dist/__tests__/pipeline.cases.unit.test.d.ts.map +1 -0
  116. package/dist/__tests__/post-process-register.cases.unit.test.d.ts +2 -0
  117. package/dist/__tests__/post-process-register.cases.unit.test.d.ts.map +1 -0
  118. package/dist/__tests__/propagate-transforms.cases.unit.test.d.ts +2 -0
  119. package/dist/__tests__/propagate-transforms.cases.unit.test.d.ts.map +1 -0
  120. package/dist/__tests__/record-all-topology.cases.unit.test.d.ts +2 -0
  121. package/dist/__tests__/record-all-topology.cases.unit.test.d.ts.map +1 -0
  122. package/dist/__tests__/record-draw-branch.cases.unit.test.d.ts +2 -0
  123. package/dist/__tests__/record-draw-branch.cases.unit.test.d.ts.map +1 -0
  124. package/dist/__tests__/record-strip-index-format.cases.unit.test.d.ts +2 -0
  125. package/dist/__tests__/record-strip-index-format.cases.unit.test.d.ts.map +1 -0
  126. package/dist/__tests__/register-with-guid-rgba16float.cases.unit.test.d.ts +2 -0
  127. package/dist/__tests__/register-with-guid-rgba16float.cases.unit.test.d.ts.map +1 -0
  128. package/dist/__tests__/render-data.cases.unit.test.d.ts +2 -0
  129. package/dist/__tests__/render-data.cases.unit.test.d.ts.map +1 -0
  130. package/dist/__tests__/render-error-exhaustive.test-d.d.ts.map +1 -1
  131. package/dist/__tests__/render-query-regression.cases.unit.test.d.ts +2 -0
  132. package/dist/__tests__/render-query-regression.cases.unit.test.d.ts.map +1 -0
  133. package/dist/__tests__/render-system-record-submesh.cases.unit.test.d.ts +2 -0
  134. package/dist/__tests__/render-system-record-submesh.cases.unit.test.d.ts.map +1 -0
  135. package/dist/__tests__/render-system-record-warn-once.cases.unit.test.d.ts +2 -0
  136. package/dist/__tests__/render-system-record-warn-once.cases.unit.test.d.ts.map +1 -0
  137. package/dist/__tests__/renderer-device-loss-multi-world.integration.test.d.ts +2 -0
  138. package/dist/__tests__/renderer-device-loss-multi-world.integration.test.d.ts.map +1 -0
  139. package/dist/__tests__/renderer-device-loss-provider-feasibility.dawn.test.d.ts +2 -0
  140. package/dist/__tests__/renderer-device-loss-provider-feasibility.dawn.test.d.ts.map +1 -0
  141. package/dist/__tests__/renderer-device-loss-recovery.dawn.test.d.ts +2 -0
  142. package/dist/__tests__/renderer-device-loss-recovery.dawn.test.d.ts.map +1 -0
  143. package/dist/__tests__/renderer-draw-world.cases.unit.test.d.ts +2 -0
  144. package/dist/__tests__/renderer-draw-world.cases.unit.test.d.ts.map +1 -0
  145. package/dist/__tests__/renderer-recovery-consumer.integration.test.d.ts +2 -0
  146. package/dist/__tests__/renderer-recovery-consumer.integration.test.d.ts.map +1 -0
  147. package/dist/__tests__/renderstate-pipeline-cache.cases.unit.test.d.ts +2 -0
  148. package/dist/__tests__/renderstate-pipeline-cache.cases.unit.test.d.ts.map +1 -0
  149. package/dist/__tests__/resolve-scene-guids.cases.unit.test.d.ts +2 -0
  150. package/dist/__tests__/resolve-scene-guids.cases.unit.test.d.ts.map +1 -0
  151. package/dist/__tests__/shadow-skip-non-triangle.cases.unit.test.d.ts +2 -0
  152. package/dist/__tests__/shadow-skip-non-triangle.cases.unit.test.d.ts.map +1 -0
  153. package/dist/__tests__/skin-errors-kebab-case.cases.unit.test.d.ts +2 -0
  154. package/dist/__tests__/skin-errors-kebab-case.cases.unit.test.d.ts.map +1 -0
  155. package/dist/__tests__/skin-instances-coexist.cases.unit.test.d.ts +2 -0
  156. package/dist/__tests__/skin-instances-coexist.cases.unit.test.d.ts.map +1 -0
  157. package/dist/__tests__/skin-m2-extract-happy.cases.unit.test.d.ts +2 -0
  158. package/dist/__tests__/skin-m2-extract-happy.cases.unit.test.d.ts.map +1 -0
  159. package/dist/__tests__/skin-m2-extract-joint-error.cases.unit.test.d.ts +2 -0
  160. package/dist/__tests__/skin-m2-extract-joint-error.cases.unit.test.d.ts.map +1 -0
  161. package/dist/__tests__/skin-m2-extract-null-assets.cases.unit.test.d.ts +2 -0
  162. package/dist/__tests__/skin-m2-extract-null-assets.cases.unit.test.d.ts.map +1 -0
  163. package/dist/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.d.ts +2 -0
  164. package/dist/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.d.ts.map +1 -0
  165. package/dist/__tests__/skin-m3-record-cache.cases.unit.test.d.ts +2 -0
  166. package/dist/__tests__/skin-m3-record-cache.cases.unit.test.d.ts.map +1 -0
  167. package/dist/__tests__/skin-palette-extract.cases.unit.test.d.ts +2 -0
  168. package/dist/__tests__/skin-palette-extract.cases.unit.test.d.ts.map +1 -0
  169. package/dist/__tests__/skin-pipeline-routing.cases.unit.test.d.ts +2 -0
  170. package/dist/__tests__/skin-pipeline-routing.cases.unit.test.d.ts.map +1 -0
  171. package/dist/__tests__/skin.cases.unit.test.d.ts +2 -0
  172. package/dist/__tests__/skin.cases.unit.test.d.ts.map +1 -0
  173. package/dist/__tests__/skybox-extract.cases.unit.test.d.ts +2 -0
  174. package/dist/__tests__/skybox-extract.cases.unit.test.d.ts.map +1 -0
  175. package/dist/__tests__/skybox-shader-compile.cases.unit.test.d.ts +2 -0
  176. package/dist/__tests__/skybox-shader-compile.cases.unit.test.d.ts.map +1 -0
  177. package/dist/__tests__/skylight-bind-group.cases.unit.test.d.ts +2 -0
  178. package/dist/__tests__/skylight-bind-group.cases.unit.test.d.ts.map +1 -0
  179. package/dist/__tests__/skylight-component.cases.unit.test.d.ts +2 -0
  180. package/dist/__tests__/skylight-component.cases.unit.test.d.ts.map +1 -0
  181. package/dist/__tests__/skylight-fallback-path.cases.unit.test.d.ts +2 -0
  182. package/dist/__tests__/skylight-fallback-path.cases.unit.test.d.ts.map +1 -0
  183. package/dist/__tests__/skylight-pipeline-layout.cases.unit.test.d.ts +2 -0
  184. package/dist/__tests__/skylight-pipeline-layout.cases.unit.test.d.ts.map +1 -0
  185. package/dist/__tests__/skylight-solid-color-extract.cases.unit.test.d.ts +2 -0
  186. package/dist/__tests__/skylight-solid-color-extract.cases.unit.test.d.ts.map +1 -0
  187. package/dist/__tests__/sprite-animation-tick-boundary.cases.unit.test.d.ts +2 -0
  188. package/dist/__tests__/sprite-animation-tick-boundary.cases.unit.test.d.ts.map +1 -0
  189. package/dist/__tests__/sprite-animation-tick-clamp.cases.unit.test.d.ts +2 -0
  190. package/dist/__tests__/sprite-animation-tick-clamp.cases.unit.test.d.ts.map +1 -0
  191. package/dist/__tests__/sprite-animation-tick-frame-duration-negative.cases.unit.test.d.ts +2 -0
  192. package/dist/__tests__/sprite-animation-tick-frame-duration-negative.cases.unit.test.d.ts.map +1 -0
  193. package/dist/__tests__/sprite-animation-tick-frame-duration-zero.cases.unit.test.d.ts +2 -0
  194. package/dist/__tests__/sprite-animation-tick-frame-duration-zero.cases.unit.test.d.ts.map +1 -0
  195. package/dist/__tests__/sprite-animation-tick-loop.cases.unit.test.d.ts +2 -0
  196. package/dist/__tests__/sprite-animation-tick-loop.cases.unit.test.d.ts.map +1 -0
  197. package/dist/__tests__/sprite-animation-tick-override-probe.cases.unit.test.d.ts +2 -0
  198. package/dist/__tests__/sprite-animation-tick-override-probe.cases.unit.test.d.ts.map +1 -0
  199. package/dist/__tests__/sprite-animation-tick-regions-mismatch.cases.unit.test.d.ts +2 -0
  200. package/dist/__tests__/sprite-animation-tick-regions-mismatch.cases.unit.test.d.ts.map +1 -0
  201. package/dist/__tests__/storage-buffer-caps.cases.unit.test.d.ts +2 -0
  202. package/dist/__tests__/storage-buffer-caps.cases.unit.test.d.ts.map +1 -0
  203. package/dist/__tests__/systems.cases.unit.test.d.ts +2 -0
  204. package/dist/__tests__/systems.cases.unit.test.d.ts.map +1 -0
  205. package/dist/__tests__/tonemap-hdr-target.cases.unit.test.d.ts +2 -0
  206. package/dist/__tests__/tonemap-hdr-target.cases.unit.test.d.ts.map +1 -0
  207. package/dist/__tests__/tonemap-pipeline-split.cases.unit.test.d.ts +2 -0
  208. package/dist/__tests__/tonemap-pipeline-split.cases.unit.test.d.ts.map +1 -0
  209. package/dist/__tests__/tonemap.cases.unit.test.d.ts +2 -0
  210. package/dist/__tests__/tonemap.cases.unit.test.d.ts.map +1 -0
  211. package/dist/__tests__/transparent-sort-config-get.cases.unit.test.d.ts +2 -0
  212. package/dist/__tests__/transparent-sort-config-get.cases.unit.test.d.ts.map +1 -0
  213. package/dist/__tests__/transparent-sort-config-set.cases.unit.test.d.ts +2 -0
  214. package/dist/__tests__/transparent-sort-config-set.cases.unit.test.d.ts.map +1 -0
  215. package/dist/__tests__/transparent-sort.cases.unit.test.d.ts +2 -0
  216. package/dist/__tests__/transparent-sort.cases.unit.test.d.ts.map +1 -0
  217. package/dist/__tests__/upload-texture-consistency.cases.unit.test.d.ts +2 -0
  218. package/dist/__tests__/upload-texture-consistency.cases.unit.test.d.ts.map +1 -0
  219. package/dist/__tests__/verify-revisions.cases.unit.test.d.ts +2 -0
  220. package/dist/__tests__/verify-revisions.cases.unit.test.d.ts.map +1 -0
  221. package/dist/__tests__/wire-default-loaders.cases.unit.test.d.ts +2 -0
  222. package/dist/__tests__/wire-default-loaders.cases.unit.test.d.ts.map +1 -0
  223. package/dist/__tests__/zero-camera-clear-fallback.cases.unit.test.d.ts +2 -0
  224. package/dist/__tests__/zero-camera-clear-fallback.cases.unit.test.d.ts.map +1 -0
  225. package/dist/createRenderer.d.ts.map +1 -1
  226. package/dist/dev-import-transport.d.ts.map +1 -1
  227. package/dist/index.mjs +4 -3
  228. package/dist/index.mjs.map +1 -1
  229. package/dist/sprite-param-values.d.ts +1 -1
  230. package/package.json +30 -30
  231. package/src/__tests__/advance-animation-player.cases.unit.test.ts +1666 -0
  232. package/src/__tests__/animation-graph-asset-union.test-d.ts +2 -0
  233. package/src/__tests__/asset-brand-and-name-retirement.test.ts +4 -3
  234. package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +676 -0
  235. package/src/__tests__/asset-registry-bootstrap.cases.unit.test.ts +472 -0
  236. package/src/__tests__/asset-registry-builtin-nineslice.cases.unit.test.ts +481 -0
  237. package/src/__tests__/asset-registry-d9-tile-sampler-soft-warn.cases.unit.test.ts +568 -0
  238. package/src/__tests__/asset-registry-guid.cases.unit.test.ts +500 -0
  239. package/src/__tests__/asset-registry-material-validate.cases.unit.test.ts +756 -0
  240. package/src/__tests__/asset-registry-mesh-fail-fast.cases.unit.test.ts +591 -0
  241. package/src/__tests__/asset-registry-scene.cases.unit.test.ts +501 -0
  242. package/src/__tests__/asset-registry-scene.test-d.ts +1 -0
  243. package/src/__tests__/asset-registry-sprite-slices-validate.cases.unit.test.ts +536 -0
  244. package/src/__tests__/asset-renderer-assembly.cases.unit.test.ts +496 -0
  245. package/src/__tests__/asset.cases.unit.test.ts +439 -0
  246. package/src/__tests__/bind-group-cache-keying.test.ts +6 -2
  247. package/src/__tests__/bloom-gating.cases.unit.test.ts +428 -0
  248. package/src/__tests__/builtin-guid-ssot.cases.unit.test.ts +472 -0
  249. package/src/__tests__/builtin-pack.cases.unit.test.ts +639 -0
  250. package/src/__tests__/builtin-shader-register-e2e.test.ts +1 -1
  251. package/src/__tests__/camera-antialias.cases.unit.test.ts +372 -0
  252. package/src/__tests__/camera-bloom.cases.unit.test.ts +396 -0
  253. package/src/__tests__/camera-clear-schema.cases.unit.test.ts +391 -0
  254. package/src/__tests__/camera-ortho.cases.unit.test.ts +484 -0
  255. package/src/__tests__/cluster-binner.cases.unit.test.ts +817 -0
  256. package/src/__tests__/collect-scene-mount-collapse.test.ts +5 -1
  257. package/src/__tests__/collect-transient-roundtrip.test.ts +14 -3
  258. package/src/__tests__/components.test-d.ts +9 -6
  259. package/src/__tests__/components.unit.test.ts +23 -19
  260. package/src/__tests__/create-renderer-fallback-shader-manifest.cases.unit.test.ts +345 -0
  261. package/src/__tests__/create-renderer-uniform-fallback.cases.unit.test.ts +445 -0
  262. package/src/__tests__/cubemap-upload.cases.unit.test.ts +593 -0
  263. package/src/__tests__/dawn/ibl-precompute-readback.dawn.test.ts +184 -17
  264. package/src/__tests__/dawn/material-alpha.dawn.test.ts +17 -4
  265. package/src/__tests__/derive-bgl-integration.test.ts +8 -8
  266. package/src/__tests__/dev-import-transport.cases.unit.test.ts +503 -0
  267. package/src/__tests__/engine-metrics.cases.unit.test.ts +371 -0
  268. package/src/__tests__/equirect-projection-error.cases.unit.test.ts +416 -0
  269. package/src/__tests__/errors.unit.test.ts +3 -0
  270. package/src/__tests__/extract-frames-merge.test.ts +3 -0
  271. package/src/__tests__/font-asset-load.cases.unit.test.ts +510 -0
  272. package/src/__tests__/fxaa-intermediate-texture.cases.unit.test.ts +690 -0
  273. package/src/__tests__/fxaa-pipeline.cases.unit.test.ts +546 -0
  274. package/src/__tests__/gpu-residency.cases.unit.test.ts +479 -0
  275. package/src/__tests__/gpu-resource-release.cases.unit.test.ts +929 -0
  276. package/src/__tests__/handle-quad.cases.unit.test.ts +491 -0
  277. package/src/__tests__/hdrp-bgl-slots.cases.unit.test.ts +451 -0
  278. package/src/__tests__/hdrp-grid-invalid.cases.unit.test.ts +408 -0
  279. package/src/__tests__/hdrp-index-list-overflow-once.cases.unit.test.ts +405 -0
  280. package/src/__tests__/hdrp-light-budget.cases.unit.test.ts +483 -0
  281. package/src/__tests__/hdrp-pipeline-asset-config.cases.unit.test.ts +441 -0
  282. package/src/__tests__/helpers/register-runtime-components.ts +9 -1
  283. package/src/__tests__/helpers/register-scene-components.ts +9 -2
  284. package/src/__tests__/helpers/renderer-device-loss-dawn.ts +69 -0
  285. package/src/__tests__/helpers/standard-material-manifest.ts +11 -15
  286. package/src/__tests__/ibl-pipeline-cache.cases.unit.test.ts +415 -0
  287. package/src/__tests__/lazy-catalog.cases.unit.test.ts +848 -0
  288. package/src/__tests__/light-gpu-byte-neutral.unit.test.ts +14 -15
  289. package/src/__tests__/light-snapshot.test-d.ts +27 -1
  290. package/src/__tests__/lights-preservation.fixup.test.ts +19 -21
  291. package/src/__tests__/lights.unit.test.ts +2076 -1
  292. package/src/__tests__/load-by-guid-hdr.cases.unit.test.ts +582 -0
  293. package/src/__tests__/load-by-guid-prod-material-parent.cases.unit.test.ts +1514 -0
  294. package/src/__tests__/load-by-guid-prod.cases.unit.test.ts +557 -0
  295. package/src/__tests__/loader-name-survival.test.ts +8 -9
  296. package/src/__tests__/loader-registry.cases.unit.test.ts +767 -0
  297. package/src/__tests__/m7-hdrp-demo-shape.cases.unit.test.ts +361 -0
  298. package/src/__tests__/material-texture-uv-scale.unit.test.ts +2 -2
  299. package/src/__tests__/materials.unit.test.ts +29 -10
  300. package/src/__tests__/mipmap-formula.cases.unit.test.ts +434 -0
  301. package/src/__tests__/mipmap-pipeline-cache.cases.unit.test.ts +490 -0
  302. package/src/__tests__/package-promotion.test.ts +7 -11
  303. package/src/__tests__/parse-asset-payload-material.cases.unit.test.ts +565 -0
  304. package/src/__tests__/parse-asset-payload-texture.cases.unit.test.ts +720 -0
  305. package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +767 -0
  306. package/src/__tests__/pass-selector.cases.unit.test.ts +376 -0
  307. package/src/__tests__/pbr-pipeline.unit.test.ts +39 -21
  308. package/src/__tests__/pipeline-builder.cases.unit.test.ts +624 -0
  309. package/src/__tests__/pipeline-cache-key-topology.cases.unit.test.ts +807 -0
  310. package/src/__tests__/pipeline-vertex-stride-branch.cases.unit.test.ts +379 -0
  311. package/src/__tests__/pipeline.cases.unit.test.ts +370 -0
  312. package/src/__tests__/point-light-shadow.browser.test.ts +1 -0
  313. package/src/__tests__/point-light-shadow.unit.test.ts +18 -98
  314. package/src/__tests__/post-process-register.cases.unit.test.ts +405 -0
  315. package/src/__tests__/propagate-transforms.cases.unit.test.ts +502 -0
  316. package/src/__tests__/propagate-transforms.dawn.test.ts +34 -18
  317. package/src/__tests__/record-all-topology.cases.unit.test.ts +608 -0
  318. package/src/__tests__/record-draw-branch.cases.unit.test.ts +621 -0
  319. package/src/__tests__/record-strip-index-format.cases.unit.test.ts +419 -0
  320. package/src/__tests__/record-worldid-isolation.test.ts +16 -32
  321. package/src/__tests__/register-with-guid-rgba16float.cases.unit.test.ts +469 -0
  322. package/src/__tests__/render-data.cases.unit.test.ts +553 -0
  323. package/src/__tests__/render-error-exhaustive.test-d.ts +11 -0
  324. package/src/__tests__/render-query-regression.cases.unit.test.ts +531 -0
  325. package/src/__tests__/render-system-extract.test.ts +5 -5
  326. package/src/__tests__/render-system-record-multi-material-textureview.test.ts +1 -0
  327. package/src/__tests__/render-system-record-per-submesh-transparency.test.ts +1 -0
  328. package/src/__tests__/render-system-record-submesh.cases.unit.test.ts +740 -0
  329. package/src/__tests__/render-system-record-warn-once.cases.unit.test.ts +418 -0
  330. package/src/__tests__/render-system-record.test.ts +15 -33
  331. package/src/__tests__/renderer-device-loss-multi-world.integration.test.ts +103 -0
  332. package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +124 -0
  333. package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +50 -0
  334. package/src/__tests__/renderer-draw-world.cases.unit.test.ts +514 -0
  335. package/src/__tests__/renderer-recovery-consumer.integration.test.ts +96 -0
  336. package/src/__tests__/renderer-surface.unit.test.ts +0 -2
  337. package/src/__tests__/renderstate-pipeline-cache.cases.unit.test.ts +631 -0
  338. package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +1123 -0
  339. package/src/__tests__/resolveName-unified-entry.test.ts +6 -6
  340. package/src/__tests__/resolveName.test.ts +14 -2
  341. package/src/__tests__/shadow-caster-empty-schema.test.ts +1 -1
  342. package/src/__tests__/shadow-csm-cascade-loadop.test.ts +1 -0
  343. package/src/__tests__/shadow-csm-tile-consistency.test.ts +1 -0
  344. package/src/__tests__/shadow-csm-ubo.test.ts +3 -1
  345. package/src/__tests__/shadow-fields-observable.dawn.test.ts +15 -4
  346. package/src/__tests__/shadow-m1.dawn.test.ts +1 -1
  347. package/src/__tests__/shadow-skip-non-triangle.cases.unit.test.ts +681 -0
  348. package/src/__tests__/skin-errors-kebab-case.cases.unit.test.ts +475 -0
  349. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +5 -4
  350. package/src/__tests__/skin-instances-coexist.cases.unit.test.ts +375 -0
  351. package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +592 -0
  352. package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +619 -0
  353. package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +569 -0
  354. package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +585 -0
  355. package/src/__tests__/skin-m3-record-cache.cases.unit.test.ts +457 -0
  356. package/src/__tests__/skin-palette-extract.cases.unit.test.ts +635 -0
  357. package/src/__tests__/skin-parented-double-transform.test.ts +5 -5
  358. package/src/__tests__/skin-pipeline-routing.cases.unit.test.ts +411 -0
  359. package/src/__tests__/skin.cases.unit.test.ts +421 -0
  360. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +1 -6
  361. package/src/__tests__/skybox-extract.cases.unit.test.ts +511 -0
  362. package/src/__tests__/skybox-shader-compile.cases.unit.test.ts +437 -0
  363. package/src/__tests__/skylight-bind-group.cases.unit.test.ts +642 -0
  364. package/src/__tests__/skylight-component.cases.unit.test.ts +386 -0
  365. package/src/__tests__/skylight-fallback-path.cases.unit.test.ts +566 -0
  366. package/src/__tests__/skylight-lazy-projection.test.ts +35 -20
  367. package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +506 -0
  368. package/src/__tests__/skylight-solid-color-extract.cases.unit.test.ts +399 -0
  369. package/src/__tests__/sprite-animation-tick-boundary.cases.unit.test.ts +521 -0
  370. package/src/__tests__/sprite-animation-tick-clamp.cases.unit.test.ts +453 -0
  371. package/src/__tests__/sprite-animation-tick-frame-duration-negative.cases.unit.test.ts +447 -0
  372. package/src/__tests__/sprite-animation-tick-frame-duration-zero.cases.unit.test.ts +395 -0
  373. package/src/__tests__/sprite-animation-tick-loop.cases.unit.test.ts +494 -0
  374. package/src/__tests__/sprite-animation-tick-override-probe.cases.unit.test.ts +439 -0
  375. package/src/__tests__/sprite-animation-tick-regions-mismatch.cases.unit.test.ts +466 -0
  376. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +16 -18
  377. package/src/__tests__/sprite-lit-pipeline-cache-key.test.ts +1 -1
  378. package/src/__tests__/ssao-bgl.test.ts +2 -1
  379. package/src/__tests__/ssao-pipelines.test.ts +1 -1
  380. package/src/__tests__/storage-buffer-caps.cases.unit.test.ts +373 -0
  381. package/src/__tests__/systems.cases.unit.test.ts +425 -0
  382. package/src/__tests__/template-game-default-api-contract.test.ts +45 -23
  383. package/src/__tests__/tilemap-chunk-extract.overhang-bounds.test.ts +1 -1
  384. package/src/__tests__/tilemap-chunk-extract.propagate-order.test.ts +10 -4
  385. package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +670 -0
  386. package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +867 -0
  387. package/src/__tests__/tonemap.cases.unit.test.ts +446 -0
  388. package/src/__tests__/transform-hierarchy-pixel-diff.dawn.test.ts +2 -2
  389. package/src/__tests__/transparent-sort-config-get.cases.unit.test.ts +395 -0
  390. package/src/__tests__/transparent-sort-config-set.cases.unit.test.ts +424 -0
  391. package/src/__tests__/transparent-sort.cases.unit.test.ts +684 -0
  392. package/src/__tests__/upload-texture-consistency.cases.unit.test.ts +478 -0
  393. package/src/__tests__/verify-revisions.cases.unit.test.ts +619 -0
  394. package/src/__tests__/wire-default-loaders.cases.unit.test.ts +461 -0
  395. package/src/__tests__/zero-camera-clear-fallback.cases.unit.test.ts +374 -0
  396. package/src/collect-scene-asset.ts +1 -1
  397. package/src/components/__tests__/transform-vec-schema.test.ts +2 -2
  398. package/src/createRenderer.ts +1 -0
  399. package/src/dev-import-transport.ts +2 -1
  400. package/src/sprite-param-values.ts +1 -1
  401. package/dist/.tsbuildinfo +0 -1
  402. package/dist/__tests__/asset.unit.test.d.ts +0 -2
  403. package/dist/__tests__/asset.unit.test.d.ts.map +0 -1
  404. package/dist/__tests__/pipeline.unit.test.d.ts +0 -2
  405. package/dist/__tests__/pipeline.unit.test.d.ts.map +0 -1
  406. package/dist/__tests__/systems.unit.test.d.ts +0 -2
  407. package/dist/__tests__/systems.unit.test.d.ts.map +0 -1
  408. package/src/__tests__/asset.unit.test.ts +0 -7110
  409. package/src/__tests__/pipeline.unit.test.ts +0 -5283
  410. package/src/__tests__/systems.unit.test.ts +0 -9210
@@ -20,15 +20,30 @@
20
20
 
21
21
  import { World } from '@forgeax/engine-ecs';
22
22
  import { componentDefinition, componentSchema } from '@forgeax/engine-ecs/internal';
23
- import { DirectionalLight, PointLight, Skylight, SpotLight } from '@forgeax/engine-render';
23
+ import { vec3 } from '@forgeax/engine-math';
24
+ import { Camera, DirectionalLight, PointLight, Skylight, SpotLight } from '@forgeax/engine-render';
25
+ import { propagateTransforms, Transform } from '@forgeax/engine-scene';
24
26
  import { describe, expect, it } from 'vitest';
25
27
  import {
26
28
  computeInvRangeSquared,
29
+ degToCos,
27
30
  validateDirectionalLightData,
28
31
  validatePointLightShadowData,
29
32
  validateSpotLightData,
30
33
  } from '../../../render/src/components/light-helpers';
31
34
  import type { ShadowInvalidConfigError } from '../../../render/src/errors/render';
35
+ import {
36
+ BYTES_PER_DIRECT_LIGHT_SLOT,
37
+ DIRECT_LIGHT_SLOT_LAYOUT,
38
+ DirectLightSlotKind,
39
+ packDirectLightSlot,
40
+ } from '../../../render/src/light-buffer-layout';
41
+ import { buildPbrViewBglEntries } from '../../../render/src/pbr-pipeline';
42
+ import type {
43
+ PointLightSnapshot,
44
+ SpotLightSnapshot,
45
+ } from '../../../render/src/render-system-extract';
46
+ import { extractFrame, prepareExtractContext } from '../../../render/src/render-system-extract';
32
47
 
33
48
  type LightSpawnResult = ReturnType<World['spawn']>;
34
49
 
@@ -223,6 +238,2066 @@ function spawnValidatedLight(
223
238
  });
224
239
  }
225
240
 
241
+ {
242
+ // ─── feat-20260709 M2 / w5: direction fail-fast validate rejection ───
243
+ // AC-03 (+ AC-02 per research-decisions D-R1): direction has NO layer-2
244
+ // default, so an omitted direction lands the array layer-3 all-zero [0,0,0].
245
+ // Both an explicit zero-vector and an omitted direction are the same illegal
246
+ // state -- the light's validate() rejects both with SpawnLightInvalidBoundsError
247
+ // (detail.field='direction'). AC-02's compile-time "omit direction is red"
248
+ // is unreachable (spawn data is Partial<InputShapeOf>, all fields optional);
249
+ // D-R1 folds it into this runtime rejection. The compile-time guarantee that
250
+ // survives is AC-04 (residual per-axis key = excess-unknown-key typecheck red).
251
+ describe('lights direction fail-fast validate (w5, AC-03 / AC-02 via D-R1)', () => {
252
+ for (const [name, component] of [
253
+ ['DirectionalLight', DirectionalLight],
254
+ ['SpotLight', SpotLight],
255
+ ] as const) {
256
+ it(`${name}: explicit zero-vector direction is rejected with field='direction'`, () => {
257
+ const world = new World();
258
+ const r = spawnValidatedLight(world, component, { direction: [0, 0, 0] });
259
+ expect(r.ok).toBe(false);
260
+ if (!r.ok) {
261
+ expect(r.error.code).toBe('spawn-light-invalid-bounds');
262
+ const detail = (r.error as unknown as { detail: { field: string } }).detail;
263
+ expect(detail.field).toBe('direction');
264
+ const hint = (r.error as unknown as { hint: string }).hint;
265
+ expect(hint.length).toBeGreaterThan(0);
266
+ expect(hint).toContain('direction');
267
+ }
268
+ });
269
+
270
+ it(`${name}: omitted direction (layer-3 all-zero) is rejected with field='direction'`, () => {
271
+ const world = new World();
272
+ const r = spawnValidatedLight(world, component, {});
273
+ expect(r.ok).toBe(false);
274
+ if (!r.ok) {
275
+ expect(r.error.code).toBe('spawn-light-invalid-bounds');
276
+ const detail = (r.error as unknown as { detail: { field: string } }).detail;
277
+ expect(detail.field).toBe('direction');
278
+ }
279
+ });
280
+
281
+ it(`${name}: a non-zero direction spawns successfully`, () => {
282
+ const world = new World();
283
+ const r = world.spawn({ component, data: { direction: [0, -1, 0] } });
284
+ expect(r.ok).toBe(true);
285
+ });
286
+ }
287
+ });
288
+ }
289
+
290
+ {
291
+ // ─── from directional-light-defaults.test.ts ───
292
+ describe('directional-light-defaults.test.ts', () => {
293
+ describe('DirectionalLight spawn default-value fallback (M1 w5)', () => {
294
+ it('omitting intensity / color* fills layer-2 defaults (intensity=1, color=[1,1,1])', () => {
295
+ const world = new World();
296
+ const e = world
297
+ .spawn({
298
+ component: DirectionalLight,
299
+ data: { direction: [0, -1, 0] },
300
+ })
301
+ .unwrap();
302
+
303
+ const view = world.get(e, DirectionalLight).unwrap();
304
+ expect(Array.from(view.direction)).toEqual([0, -1, 0]);
305
+ expect(Array.from(view.color)).toEqual([1, 1, 1]);
306
+ expect(view.intensity).toBe(1);
307
+ });
308
+
309
+ it('explicit intensity / color override defaults', () => {
310
+ const world = new World();
311
+ const e = world
312
+ .spawn({
313
+ component: DirectionalLight,
314
+ data: {
315
+ direction: [-0.5, -1, -0.3],
316
+ color: [0.9, 0.8, 0.7],
317
+ intensity: 0.5,
318
+ },
319
+ })
320
+ .unwrap();
321
+ const view = world.get(e, DirectionalLight).unwrap();
322
+ expect(view.color[0]).toBeCloseTo(0.9, 5);
323
+ expect(view.intensity).toBe(0.5);
324
+ });
325
+ });
326
+ });
327
+ }
328
+
329
+ {
330
+ // ─── from directional-light-shadow.test.ts (post-merge: DirectionalLightShadow deleted, target now DirectionalLight) ───
331
+ describe('directional-light-shadow.test.ts', () => {
332
+ describe('DirectionalLight merged shadow schema (post-m1-t6)', () => {
333
+ it('AC-01: shadow fields are present with correct default values in the merged component', () => {
334
+ const dl = DirectionalLight;
335
+
336
+ expect(dl.name).toBe('DirectionalLight');
337
+ expect(componentSchema(dl)).toBeDefined();
338
+
339
+ expect(componentDefinition(dl).defaults).toBeDefined();
340
+ // biome-ignore lint/style/noNonNullAssertion: defaults asserted defined just above
341
+ const defaults = componentDefinition(dl).defaults!;
342
+
343
+ expect(defaults.cascadeCount).toBe(4);
344
+ expect(defaults.splitLambda).toBeCloseTo(0.75, 5);
345
+ expect(defaults.cascadeBlend).toBeCloseTo(0.2, 5);
346
+ expect(defaults.mapSize).toBe(2048);
347
+ expect(defaults.depthBias).toBeCloseTo(0.005, 5);
348
+ expect(defaults.normalBias).toBeCloseTo(0.05, 5);
349
+ expect(defaults.shadowDistance).toBeCloseTo(200, 5);
350
+ expect(defaults.shadowFilter).toBe(2);
351
+
352
+ // Merged component: 3 light (direction + color arrays + intensity) +
353
+ // 1 castShadow + 10 closed shadow-quality fields = 14 fields (feat-20260709 M2 collapsed
354
+ // direction/color from 6 per-axis scalars to 2 array<f32,3> columns;
355
+ // nearPlane removed — derived from camera near; farPlane -> shadowDistance)
356
+ expect(Object.keys(componentSchema(dl)).length).toBe(14);
357
+ expect('direction' in componentSchema(dl)).toBe(true);
358
+ expect('color' in componentSchema(dl)).toBe(true);
359
+ expect('cascadeCount' in componentSchema(dl)).toBe(true);
360
+ expect('splitLambda' in componentSchema(dl)).toBe(true);
361
+ expect('cascadeBlend' in componentSchema(dl)).toBe(true);
362
+ expect('mapSize' in componentSchema(dl)).toBe(true);
363
+ expect('depthBias' in componentSchema(dl)).toBe(true);
364
+ expect('normalBias' in componentSchema(dl)).toBe(true);
365
+ expect('shadowDistance' in componentSchema(dl)).toBe(true);
366
+ expect('nearPlane' in componentSchema(dl)).toBe(false);
367
+ expect('farPlane' in componentSchema(dl)).toBe(false);
368
+ expect('pcfKernelSize' in componentSchema(dl)).toBe(false);
369
+ // DirectionalLightShadow is deleted; the old orthoHalfExtent field is gone
370
+ });
371
+
372
+ it('AC-02: spawn-default fallback fills omitted shadow fields from defaults (single-component)', () => {
373
+ const world = new World();
374
+
375
+ const r = world.spawn({
376
+ component: DirectionalLight,
377
+ data: { direction: [0, -1, 0], mapSize: 2048 },
378
+ });
379
+ expect(r.ok).toBe(true);
380
+ const e = r.unwrap();
381
+
382
+ const light = world.get(e, DirectionalLight);
383
+ expect(light.ok).toBe(true);
384
+ const lightData = light.unwrap();
385
+
386
+ expect(lightData.mapSize).toBe(2048);
387
+ expect(lightData.cascadeCount).toBe(4);
388
+ expect(lightData.splitLambda).toBeCloseTo(0.75, 5);
389
+ expect(lightData.cascadeBlend).toBeCloseTo(0.2, 5);
390
+ expect(lightData.depthBias).toBeCloseTo(0.005, 5);
391
+ expect(lightData.normalBias).toBeCloseTo(0.05, 5);
392
+ expect(lightData.shadowDistance).toBeCloseTo(200, 5);
393
+ });
394
+
395
+ it('AC-02: spawn with empty data gets all defaults (single-component)', () => {
396
+ const world = new World();
397
+
398
+ const r = world.spawn({
399
+ component: DirectionalLight,
400
+ data: { direction: [0, -1, 0] },
401
+ });
402
+ expect(r.ok).toBe(true);
403
+ const e = r.unwrap();
404
+
405
+ const light = world.get(e, DirectionalLight).unwrap();
406
+ expect(light.cascadeCount).toBe(4);
407
+ expect(light.splitLambda).toBeCloseTo(0.75, 5);
408
+ expect(light.cascadeBlend).toBeCloseTo(0.2, 5);
409
+ expect(light.mapSize).toBeCloseTo(2048, 5);
410
+ expect(light.depthBias).toBeCloseTo(0.005, 5);
411
+ expect(light.normalBias).toBeCloseTo(0.05, 5);
412
+ expect(light.shadowDistance).toBeCloseTo(200, 5);
413
+ });
414
+
415
+ it('AC-02: spawn with full explicit data overrides all defaults (single-component)', () => {
416
+ const world = new World();
417
+
418
+ const r = world.spawn({
419
+ component: DirectionalLight,
420
+ data: {
421
+ direction: [0, -1, 0],
422
+ cascadeCount: 2,
423
+ splitLambda: 0.5,
424
+ cascadeBlend: 0.1,
425
+ mapSize: 512,
426
+ depthBias: 0.01,
427
+ normalBias: 0.1,
428
+ shadowDistance: 100,
429
+ shadowFilter: 5,
430
+ },
431
+ });
432
+ expect(r.ok).toBe(true);
433
+ const e = r.unwrap();
434
+
435
+ const light = world.get(e, DirectionalLight).unwrap();
436
+ expect(light.cascadeCount).toBe(2);
437
+ expect(light.splitLambda).toBeCloseTo(0.5, 5);
438
+ expect(light.cascadeBlend).toBeCloseTo(0.1, 5);
439
+ expect(light.mapSize).toBeCloseTo(512, 5);
440
+ expect(light.depthBias).toBeCloseTo(0.01, 5);
441
+ expect(light.normalBias).toBeCloseTo(0.1, 5);
442
+ expect(light.shadowDistance).toBeCloseTo(100, 5);
443
+ expect(light.shadowFilter).toBe(5);
444
+ });
445
+
446
+ it('AC-05: single-component spawn bundles light + shadow fields (no dual-component needed)', () => {
447
+ const world = new World();
448
+
449
+ const r = world.spawn({
450
+ component: DirectionalLight,
451
+ data: {
452
+ direction: [0, -1, 0],
453
+ color: [1, 1, 1],
454
+ intensity: 1,
455
+ mapSize: 2048,
456
+ },
457
+ });
458
+ expect(r.ok).toBe(true);
459
+ const e = r.unwrap();
460
+
461
+ const dlResult = world.get(e, DirectionalLight);
462
+ expect(dlResult.ok).toBe(true);
463
+ const dl = dlResult.unwrap();
464
+ expect(dl.direction[0]).toBe(0);
465
+ expect(dl.intensity).toBe(1);
466
+ expect(dl.mapSize).toBeCloseTo(2048, 5);
467
+ expect(dl.depthBias).toBeCloseTo(0.005, 5);
468
+ });
469
+
470
+ it('query: DirectionalLight entity is found via world.get', () => {
471
+ const world = new World();
472
+
473
+ world
474
+ .spawn({
475
+ component: DirectionalLight,
476
+ data: { direction: [0, -1, 0], mapSize: 512 },
477
+ })
478
+ .unwrap();
479
+
480
+ const info = world.inspect();
481
+ const archetypeWithLight = info.archetypes.find((a) =>
482
+ a.componentNames.includes('DirectionalLight'),
483
+ );
484
+ expect(archetypeWithLight).toBeDefined();
485
+ expect(archetypeWithLight?.entityCount).toBe(1);
486
+ });
487
+ });
488
+ });
489
+ }
490
+
491
+ {
492
+ // ─── from extract-frame-lights.test.ts ───
493
+ describe('extract-frame-lights.test.ts', () => {
494
+ const EPSILON = 1e-5;
495
+
496
+ describe('extractFrame three-query union output (M2 w15)', () => {
497
+ it('returns directional + point[] + spot[] buckets with host-pre-multiplied fields', () => {
498
+ const world = new World();
499
+
500
+ // Camera so extractFrame does not short-circuit.
501
+ world
502
+ .spawn(
503
+ {
504
+ component: Transform,
505
+ data: { pos: [0, 0, 5], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
506
+ },
507
+ {
508
+ component: Camera,
509
+ data: {
510
+ fov: Math.PI / 4,
511
+ aspect: 1,
512
+ near: 0.1,
513
+ far: 100,
514
+ projection: 0,
515
+ left: -1,
516
+ right: 1,
517
+ bottom: -1,
518
+ top: 1,
519
+ },
520
+ },
521
+ )
522
+ .unwrap();
523
+
524
+ // 1 DirectionalLight
525
+ world
526
+ .spawn({
527
+ component: DirectionalLight,
528
+ data: {
529
+ direction: [0, -1, 0],
530
+ color: [1, 1, 1],
531
+ intensity: 0.5,
532
+ },
533
+ })
534
+ .unwrap();
535
+
536
+ // 2 PointLight (each on its own Transform)
537
+ world
538
+ .spawn(
539
+ {
540
+ component: Transform,
541
+ data: { pos: [1, 2, 3], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
542
+ },
543
+ {
544
+ component: PointLight,
545
+ data: {
546
+ color: [1, 0.5, 0.25],
547
+ intensity: 4,
548
+ range: 10,
549
+ },
550
+ },
551
+ )
552
+ .unwrap();
553
+
554
+ world
555
+ .spawn(
556
+ {
557
+ component: Transform,
558
+ data: { pos: [-2, 0, 1], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
559
+ },
560
+ {
561
+ component: PointLight,
562
+ data: {
563
+ color: [0.2, 0.4, 0.6],
564
+ intensity: 2,
565
+ range: Number.POSITIVE_INFINITY,
566
+ },
567
+ },
568
+ )
569
+ .unwrap();
570
+
571
+ // 1 SpotLight
572
+ world
573
+ .spawn(
574
+ {
575
+ component: Transform,
576
+ data: { pos: [0, 5, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
577
+ },
578
+ {
579
+ component: SpotLight,
580
+ data: {
581
+ direction: [0, -1, 0],
582
+ color: [1, 1, 1],
583
+ intensity: 8,
584
+ range: 25,
585
+ innerConeDeg: 10,
586
+ outerConeDeg: 30,
587
+ },
588
+ },
589
+ )
590
+ .unwrap();
591
+
592
+ propagateTransforms(world);
593
+
594
+ const frame = extractFrame(world, prepareExtractContext(world));
595
+ expect(frame.lights).toBeDefined();
596
+
597
+ // -- directional --
598
+ const dir = frame.lights.directional;
599
+ expect(dir).toBeDefined();
600
+ if (dir === undefined) throw new Error('directional missing');
601
+ expect(dir.kind).toBe('directional');
602
+ // direction not pre-multiplied; raw outgoing vector forwarded to shader
603
+ expect(dir.direction[0]).toBeCloseTo(0, 5);
604
+ expect(dir.direction[1]).toBeCloseTo(-1, 5);
605
+ expect(dir.direction[2]).toBeCloseTo(0, 5);
606
+ // color is host-pre-multiplied with intensity (charter P4)
607
+ expect(dir.color[0]).toBeCloseTo(1 * 0.5, 5);
608
+ expect(dir.color[1]).toBeCloseTo(1 * 0.5, 5);
609
+ expect(dir.color[2]).toBeCloseTo(1 * 0.5, 5);
610
+ expect(dir.intensity).toBeCloseTo(0.5, 5);
611
+
612
+ // -- point[] --
613
+ expect(frame.lights.point).toHaveLength(2);
614
+ // ordering is archetype-graph driven; sort by position.x to disambiguate
615
+ const points = [...frame.lights.point].sort(
616
+ (a, b) => (a.position[0] ?? 0) - (b.position[0] ?? 0),
617
+ );
618
+ const p0 = points[0];
619
+ const p1 = points[1];
620
+ if (p0 === undefined || p1 === undefined) throw new Error('point bucket short');
621
+ expect(p0.kind).toBe('point');
622
+ expect(p1.kind).toBe('point');
623
+ // p0 is the (-2, 0, 1) Infinity-range point
624
+ expect(p0.position[0]).toBeCloseTo(-2, 5);
625
+ expect(p0.position[1]).toBeCloseTo(0, 5);
626
+ expect(p0.position[2]).toBeCloseTo(1, 5);
627
+ expect(p0.invRangeSquared).toBe(computeInvRangeSquared(Number.POSITIVE_INFINITY));
628
+ expect(p0.invRangeSquared).toBe(0);
629
+ // color * intensity (0.2*2, 0.4*2, 0.6*2)
630
+ expect(p0.color[0]).toBeCloseTo(0.4, 5);
631
+ expect(p0.color[1]).toBeCloseTo(0.8, 5);
632
+ expect(p0.color[2]).toBeCloseTo(1.2, 5);
633
+ expect(p0.intensity).toBeCloseTo(2, 5);
634
+
635
+ // p1 is the (1, 2, 3) range=10 point
636
+ expect(p1.position[0]).toBeCloseTo(1, 5);
637
+ expect(p1.position[1]).toBeCloseTo(2, 5);
638
+ expect(p1.position[2]).toBeCloseTo(3, 5);
639
+ expect(p1.invRangeSquared).toBeCloseTo(computeInvRangeSquared(10), 5);
640
+ expect(Math.abs(p1.invRangeSquared - 0.01)).toBeLessThan(EPSILON);
641
+ // color * intensity (1*4, 0.5*4, 0.25*4)
642
+ expect(p1.color[0]).toBeCloseTo(4, 5);
643
+ expect(p1.color[1]).toBeCloseTo(2, 5);
644
+ expect(p1.color[2]).toBeCloseTo(1, 5);
645
+ expect(p1.intensity).toBeCloseTo(4, 5);
646
+
647
+ // -- spot[] --
648
+ expect(frame.lights.spot).toHaveLength(1);
649
+ const s0 = frame.lights.spot[0];
650
+ if (s0 === undefined) throw new Error('spot bucket empty');
651
+ expect(s0.kind).toBe('spot');
652
+ expect(s0.position[0]).toBeCloseTo(0, 5);
653
+ expect(s0.position[1]).toBeCloseTo(5, 5);
654
+ expect(s0.position[2]).toBeCloseTo(0, 5);
655
+ expect(s0.direction[0]).toBeCloseTo(0, 5);
656
+ expect(s0.direction[1]).toBeCloseTo(-1, 5);
657
+ expect(s0.direction[2]).toBeCloseTo(0, 5);
658
+ // color * intensity (1*8, 1*8, 1*8)
659
+ expect(s0.color[0]).toBeCloseTo(8, 5);
660
+ expect(s0.color[1]).toBeCloseTo(8, 5);
661
+ expect(s0.color[2]).toBeCloseTo(8, 5);
662
+ expect(s0.intensity).toBeCloseTo(8, 5);
663
+ expect(s0.cosInner).toBeCloseTo(degToCos(10), 5);
664
+ expect(s0.cosOuter).toBeCloseTo(degToCos(30), 5);
665
+ expect(s0.invRangeSquared).toBeCloseTo(computeInvRangeSquared(25), 5);
666
+ });
667
+
668
+ it('zero-light world produces directional=undefined + empty point[] + empty spot[]', () => {
669
+ const world = new World();
670
+
671
+ world
672
+ .spawn(
673
+ {
674
+ component: Transform,
675
+ data: { pos: [0, 0, 5], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
676
+ },
677
+ {
678
+ component: Camera,
679
+ data: {
680
+ fov: Math.PI / 4,
681
+ aspect: 1,
682
+ near: 0.1,
683
+ far: 100,
684
+ projection: 0,
685
+ left: -1,
686
+ right: 1,
687
+ bottom: -1,
688
+ top: 1,
689
+ },
690
+ },
691
+ )
692
+ .unwrap();
693
+
694
+ propagateTransforms(world);
695
+
696
+ const frame = extractFrame(world, prepareExtractContext(world));
697
+ expect(frame.lights.directional).toBeUndefined();
698
+ expect(frame.lights.point).toHaveLength(0);
699
+ expect(frame.lights.spot).toHaveLength(0);
700
+ });
701
+ });
702
+
703
+ // ─── from feat-20260625-spot-light-shadow-mapping M1 w2 ───
704
+ describe('SpotLightSnapshot shadow field type-check (AC-09)', () => {
705
+ it('destructuring castShadow + lightViewProj + shadowAtlasTile without as casts typechecks', () => {
706
+ const world = new World();
707
+
708
+ world
709
+ .spawn(
710
+ {
711
+ component: Transform,
712
+ data: { pos: [0, 0, 5], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
713
+ },
714
+ {
715
+ component: Camera,
716
+ data: {
717
+ fov: Math.PI / 4,
718
+ aspect: 1,
719
+ near: 0.1,
720
+ far: 100,
721
+ projection: 0,
722
+ left: -1,
723
+ right: 1,
724
+ bottom: -1,
725
+ top: 1,
726
+ },
727
+ },
728
+ )
729
+ .unwrap();
730
+
731
+ world
732
+ .spawn(
733
+ {
734
+ component: Transform,
735
+ data: { pos: [0, 5, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
736
+ },
737
+ {
738
+ component: SpotLight,
739
+ data: { direction: [0, -1, 0], castShadow: true },
740
+ },
741
+ )
742
+ .unwrap();
743
+
744
+ propagateTransforms(world);
745
+ const frame = extractFrame(world, prepareExtractContext(world));
746
+
747
+ expect(frame.lights.spot).toHaveLength(1);
748
+ const s = frame.lights.spot[0];
749
+ if (s === undefined) throw new Error('spot bucket empty');
750
+
751
+ // AC-09: destructure shadow fields without `as` casts — typecheck success
752
+ // is the acceptance witness. castShadow is bool, shadowAtlasTile is number
753
+ // (i32 sentinel -1), lightViewProj is Float32Array | undefined.
754
+ const castShadow: boolean = s.castShadow;
755
+ const shadowAtlasTile: number = s.shadowAtlasTile;
756
+ const lightViewProj: Float32Array | undefined = s.lightViewProj;
757
+ const mapSize: number = s.mapSize;
758
+ const nearPlane: number = s.nearPlane;
759
+ const farPlane: number = s.farPlane;
760
+
761
+ // Basic default-value assertions for shadow fields on the snapshot.
762
+ expect(castShadow).toBe(true);
763
+ expect(typeof lightViewProj).toBe('object');
764
+ expect(typeof shadowAtlasTile).toBe('number');
765
+ expect(typeof mapSize).toBe('number');
766
+ expect(typeof nearPlane).toBe('number');
767
+ expect(typeof farPlane).toBe('number');
768
+ });
769
+ });
770
+
771
+ // ─── from feat-20260625-spot-light-shadow-mapping M1 w3 ───
772
+ describe('spot direction degeneration (near-zero) extract skip (requirements $112)', () => {
773
+ it('dir near-zero castShadow spot gets shadowAtlasTile=-1, no lightViewProj', () => {
774
+ const world = new World();
775
+
776
+ world
777
+ .spawn(
778
+ {
779
+ component: Transform,
780
+ data: { pos: [0, 0, 5], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
781
+ },
782
+ {
783
+ component: Camera,
784
+ data: {
785
+ fov: Math.PI / 4,
786
+ aspect: 1,
787
+ near: 0.1,
788
+ far: 100,
789
+ projection: 0,
790
+ left: -1,
791
+ right: 1,
792
+ bottom: -1,
793
+ top: 1,
794
+ },
795
+ },
796
+ )
797
+ .unwrap();
798
+
799
+ // direction near-zero — normalize will fail, extract should skip shadow
800
+ world
801
+ .spawn(
802
+ {
803
+ component: Transform,
804
+ data: { pos: [0, 5, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
805
+ },
806
+ {
807
+ component: SpotLight,
808
+ data: { direction: [0, 1e-10, 0] },
809
+ },
810
+ )
811
+ .unwrap();
812
+
813
+ propagateTransforms(world);
814
+ const frame = extractFrame(world, prepareExtractContext(world));
815
+
816
+ expect(frame.lights.spot).toHaveLength(1);
817
+ const s = frame.lights.spot[0];
818
+ if (s === undefined) throw new Error('spot bucket empty');
819
+
820
+ // AC-05 / requirements $112: near-zero direction → tile=-1, no lightViewProj.
821
+ expect(s.shadowAtlasTile).toBe(-1);
822
+ // lightViewProj should be undefined (no matrix was computed).
823
+ expect(s.lightViewProj).toBeUndefined();
824
+ // Direct-light fields still intact (AC-05: clip does not delete the light).
825
+ expect(s.kind).toBe('spot');
826
+ expect(s.intensity).toBeGreaterThan(0);
827
+ });
828
+
829
+ it('normal direction castShadow spot gets shadowAtlasTile >= 0 + non-zero lightViewProj', () => {
830
+ const world = new World();
831
+
832
+ world
833
+ .spawn(
834
+ {
835
+ component: Transform,
836
+ data: { pos: [0, 0, 5], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
837
+ },
838
+ {
839
+ component: Camera,
840
+ data: {
841
+ fov: Math.PI / 4,
842
+ aspect: 1,
843
+ near: 0.1,
844
+ far: 100,
845
+ projection: 0,
846
+ left: -1,
847
+ right: 1,
848
+ bottom: -1,
849
+ top: 1,
850
+ },
851
+ },
852
+ )
853
+ .unwrap();
854
+
855
+ // Normal direction pointing down — should get a valid tile.
856
+ world
857
+ .spawn(
858
+ {
859
+ component: Transform,
860
+ data: { pos: [0, 5, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
861
+ },
862
+ {
863
+ component: SpotLight,
864
+ data: { direction: [0, -1, 0] },
865
+ },
866
+ )
867
+ .unwrap();
868
+
869
+ propagateTransforms(world);
870
+ const frame = extractFrame(world, prepareExtractContext(world));
871
+
872
+ expect(frame.lights.spot).toHaveLength(1);
873
+ const s = frame.lights.spot[0];
874
+ if (s === undefined) throw new Error('spot bucket empty');
875
+
876
+ // Normal direction should get a valid tile (0 for first castShadow spot).
877
+ expect(s.shadowAtlasTile).toBeGreaterThanOrEqual(0);
878
+ // lightViewProj should be a non-zero Float32Array (16 floats).
879
+ expect(s.lightViewProj).toBeInstanceOf(Float32Array);
880
+ expect(s.lightViewProj).toHaveLength(16);
881
+ // At least one element should be non-zero (a valid perspective×lookAt matrix).
882
+ const lvp = s.lightViewProj;
883
+ if (lvp === undefined) throw new Error('expected lightViewProj to be defined');
884
+ let hasNonZero = false;
885
+ for (let i = 0; i < 16; i++) {
886
+ if (lvp[i] !== 0) {
887
+ hasNonZero = true;
888
+ break;
889
+ }
890
+ }
891
+ expect(hasNonZero).toBe(true);
892
+ });
893
+ });
894
+
895
+ // ─── from feat-20260625-spot-light-shadow-mapping M2 w7 ───
896
+ describe('spot shadow tile clip cap=4 (AC-05)', () => {
897
+ function spawnCamera(world: World): void {
898
+ world
899
+ .spawn(
900
+ {
901
+ component: Transform,
902
+ data: { pos: [0, 0, 5], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
903
+ },
904
+ {
905
+ component: Camera,
906
+ data: {
907
+ fov: Math.PI / 4,
908
+ aspect: 1,
909
+ near: 0.1,
910
+ far: 100,
911
+ projection: 0,
912
+ left: -1,
913
+ right: 1,
914
+ bottom: -1,
915
+ top: 1,
916
+ },
917
+ },
918
+ )
919
+ .unwrap();
920
+ }
921
+
922
+ function spawnSpot(world: World, x: number, castShadow: boolean): void {
923
+ world
924
+ .spawn(
925
+ {
926
+ component: Transform,
927
+ data: { pos: [x, 5, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] },
928
+ },
929
+ {
930
+ component: SpotLight,
931
+ data: { direction: [0, -1, 0], castShadow },
932
+ },
933
+ )
934
+ .unwrap();
935
+ }
936
+
937
+ it('first 4 castShadow spots get distinct tiles 0..3; 5th gets tile=-1 but keeps direct-light fields', () => {
938
+ const world = new World();
939
+ spawnCamera(world);
940
+ for (let i = 0; i < 5; i++) spawnSpot(world, i, true);
941
+
942
+ propagateTransforms(world);
943
+ const frame = extractFrame(world, prepareExtractContext(world));
944
+
945
+ expect(frame.lights.spot).toHaveLength(5);
946
+ const tiles = frame.lights.spot.map((s) => s.shadowAtlasTile);
947
+ // Exactly four tiles in [0,3], all distinct.
948
+ const assigned = tiles.filter((t) => t >= 0);
949
+ expect(assigned).toHaveLength(4);
950
+ expect(new Set(assigned).size).toBe(4);
951
+ for (const t of assigned) {
952
+ expect(t).toBeGreaterThanOrEqual(0);
953
+ expect(t).toBeLessThanOrEqual(3);
954
+ }
955
+ // Exactly one spot is clipped (tile=-1).
956
+ expect(tiles.filter((t) => t === -1)).toHaveLength(1);
957
+
958
+ // The clipped (5th) spot still carries valid direct-light fields:
959
+ // clip never deletes the light (AC-05).
960
+ const clipped = frame.lights.spot.find((s) => s.shadowAtlasTile === -1);
961
+ if (clipped === undefined) throw new Error('expected one clipped spot');
962
+ expect(clipped.kind).toBe('spot');
963
+ expect(clipped.intensity).toBeGreaterThan(0);
964
+ expect(clipped.color.some((c) => c !== 0)).toBe(true);
965
+ expect(clipped.direction.some((d) => d !== 0)).toBe(true);
966
+ });
967
+
968
+ it('castShadow:false spot gets tile=-1 without consuming a tile slot', () => {
969
+ const world = new World();
970
+ spawnCamera(world);
971
+ // One shadowless spot first, then one shadow-casting spot.
972
+ spawnSpot(world, 0, false);
973
+ spawnSpot(world, 1, true);
974
+
975
+ propagateTransforms(world);
976
+ const frame = extractFrame(world, prepareExtractContext(world));
977
+
978
+ expect(frame.lights.spot).toHaveLength(2);
979
+ const shadowless = frame.lights.spot.find((s) => s.castShadow === false);
980
+ const shadowing = frame.lights.spot.find((s) => s.castShadow === true);
981
+ if (shadowless === undefined || shadowing === undefined) {
982
+ throw new Error('expected one shadowless + one shadowing spot');
983
+ }
984
+ // castShadow:false -> tile=-1, no lightViewProj.
985
+ expect(shadowless.shadowAtlasTile).toBe(-1);
986
+ expect(shadowless.lightViewProj).toBeUndefined();
987
+ // The shadow-casting spot still gets tile 0 (the false spot did not
988
+ // consume a slot).
989
+ expect(shadowing.shadowAtlasTile).toBe(0);
990
+ });
991
+ });
992
+ });
993
+ }
994
+
995
+ {
996
+ // ─── from light-attenuation-cone.test.ts ───
997
+ describe('light-attenuation-cone.test.ts', () => {
998
+ // Hermite cubic smoothstep — identical to WGSL's `smoothstep(edge0, edge1, x)`
999
+ // (the only smoothstep WebGPU permits) and to GLSL's smoothstep that the
1000
+ // LearnOpenGL section 6.1 cone falloff uses. Returns 0 below edge0, 1 above
1001
+ // edge1, Hermite cubic in between.
1002
+ function smoothstep(edge0: number, edge1: number, x: number): number {
1003
+ const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
1004
+ return t * t * (3 - 2 * t);
1005
+ }
1006
+
1007
+ // AC-08 (a): KHR_lights_punctual quartic attenuation reproduction. Mirrors
1008
+ // pbr.wgsl `attenuation_punctual` byte-for-byte (host TS reproduction is the
1009
+ // numerical-correctness backstop; production SSOT lives in pbr.wgsl per D-C3).
1010
+ function attenuation(d: number, invRangeSquared: number): number {
1011
+ const dSq = d * d;
1012
+ const quartic = Math.max(0, Math.min(1, 1 - (dSq * invRangeSquared) ** 2));
1013
+ return quartic / Math.max(dSq, 1e-4);
1014
+ }
1015
+
1016
+ // AC-08 (a): cone falloff Hermite cubic. Mirrors pbr.wgsl `cone_falloff`.
1017
+ // SpotLight only — point/directional skip this term (callers default
1018
+ // cosInner = 1, cosOuter = -1 to disable).
1019
+ function coneFalloff(cosTheta: number, cosInner: number, cosOuter: number): number {
1020
+ return smoothstep(cosOuter, cosInner, cosTheta);
1021
+ }
1022
+
1023
+ describe('attenuation (KHR quartic - AC-08 a)', () => {
1024
+ const EPSILON = 1e-4;
1025
+
1026
+ it('range = +Infinity collapses to plain 1 / d^2 (no truncation)', () => {
1027
+ const invR2 = computeInvRangeSquared(Number.POSITIVE_INFINITY);
1028
+ expect(invR2).toBe(0);
1029
+ // d = 1: 1 / 1 = 1; d = 2: 1 / 4 = 0.25; d = 5: 1 / 25 = 0.04.
1030
+ expect(attenuation(1, invR2)).toBeCloseTo(1.0, 6);
1031
+ expect(attenuation(2, invR2)).toBeCloseTo(0.25, 6);
1032
+ expect(attenuation(5, invR2)).toBeCloseTo(0.04, 6);
1033
+ });
1034
+
1035
+ it('d = range yields zero attenuation (KHR cutoff at the boundary)', () => {
1036
+ const range = 10;
1037
+ const invR2 = computeInvRangeSquared(range);
1038
+ // (d^2 * invR^2)^2 = (100 / 100)^2 = 1 -> quartic factor = 1 - 1 = 0
1039
+ expect(attenuation(range, invR2)).toBeCloseTo(0, 6);
1040
+ });
1041
+
1042
+ it('d > range yields zero (clamped by max(0, ...))', () => {
1043
+ const range = 5;
1044
+ const invR2 = computeInvRangeSquared(range);
1045
+ expect(attenuation(range * 2, invR2)).toBe(0);
1046
+ });
1047
+
1048
+ it('d = range / 2 yields a positive value smaller than 1 / d^2', () => {
1049
+ const range = 10;
1050
+ const d = range / 2; // 5
1051
+ const invR2 = computeInvRangeSquared(range);
1052
+ const att = attenuation(d, invR2);
1053
+ // (d^2 * invR^2)^2 = ((25 / 100))^2 = 0.0625; quartic = 1 - 0.0625 = 0.9375
1054
+ // attenuation = 0.9375 / 25 = 0.0375
1055
+ expect(att).toBeCloseTo(0.0375, 6);
1056
+ // smaller than the no-truncation 1 / d^2 = 0.04
1057
+ expect(att).toBeLessThan(1 / (d * d));
1058
+ });
1059
+
1060
+ it('d very small (d < 0.01) clamped by 1e-4 floor in denominator', () => {
1061
+ const invR2 = computeInvRangeSquared(Number.POSITIVE_INFINITY);
1062
+ // d = 0.005 -> d^2 = 2.5e-5 -> max(d^2, 1e-4) = 1e-4
1063
+ expect(attenuation(0.005, invR2)).toBeCloseTo(1 / 1e-4, 4);
1064
+ });
1065
+
1066
+ it('5 sample sweep across (d, range) ε <= 1e-4', () => {
1067
+ const samples: Array<{ d: number; range: number; expected: number }> = [
1068
+ { d: 1, range: Number.POSITIVE_INFINITY, expected: 1.0 },
1069
+ { d: 3, range: 10, expected: (1 - (9 / 100) ** 2) / 9 },
1070
+ { d: 4, range: 8, expected: (1 - (16 / 64) ** 2) / 16 },
1071
+ { d: 0.5, range: 100, expected: (1 - (0.25 / 10000) ** 2) / 0.25 },
1072
+ { d: 7, range: 12, expected: (1 - (49 / 144) ** 2) / 49 },
1073
+ ];
1074
+ for (const s of samples) {
1075
+ const invR2 = computeInvRangeSquared(s.range);
1076
+ const got = attenuation(s.d, invR2);
1077
+ expect(Math.abs(got - s.expected)).toBeLessThan(EPSILON);
1078
+ }
1079
+ });
1080
+ });
1081
+
1082
+ describe('coneFalloff (smoothstep - AC-08 b)', () => {
1083
+ const EPSILON = 1e-4;
1084
+
1085
+ it('cosTheta = cosOuter -> 0 (cone outer boundary fully dark)', () => {
1086
+ const cosInner = Math.cos((10 * Math.PI) / 180);
1087
+ const cosOuter = Math.cos((25 * Math.PI) / 180);
1088
+ expect(coneFalloff(cosOuter, cosInner, cosOuter)).toBeCloseTo(0, 6);
1089
+ });
1090
+
1091
+ it('cosTheta = cosInner -> 1 (cone inner saturated bright)', () => {
1092
+ const cosInner = Math.cos((10 * Math.PI) / 180);
1093
+ const cosOuter = Math.cos((25 * Math.PI) / 180);
1094
+ expect(coneFalloff(cosInner, cosInner, cosOuter)).toBeCloseTo(1, 6);
1095
+ });
1096
+
1097
+ it('cosTheta below cosOuter -> 0 (saturated dark beyond outer)', () => {
1098
+ const cosInner = Math.cos((10 * Math.PI) / 180);
1099
+ const cosOuter = Math.cos((25 * Math.PI) / 180);
1100
+ expect(coneFalloff(cosOuter - 0.1, cosInner, cosOuter)).toBe(0);
1101
+ });
1102
+
1103
+ it('cosTheta above cosInner -> 1 (saturated bright above inner)', () => {
1104
+ const cosInner = Math.cos((10 * Math.PI) / 180);
1105
+ const cosOuter = Math.cos((25 * Math.PI) / 180);
1106
+ expect(coneFalloff(cosInner + 0.1, cosInner, cosOuter)).toBe(1);
1107
+ });
1108
+
1109
+ it('cosTheta in (cosOuter, cosInner) is strictly monotonic (Hermite cubic)', () => {
1110
+ const cosInner = Math.cos((10 * Math.PI) / 180);
1111
+ const cosOuter = Math.cos((25 * Math.PI) / 180);
1112
+ const mid1 = cosOuter + (cosInner - cosOuter) * 0.25;
1113
+ const mid2 = cosOuter + (cosInner - cosOuter) * 0.5;
1114
+ const mid3 = cosOuter + (cosInner - cosOuter) * 0.75;
1115
+ const f1 = coneFalloff(mid1, cosInner, cosOuter);
1116
+ const f2 = coneFalloff(mid2, cosInner, cosOuter);
1117
+ const f3 = coneFalloff(mid3, cosInner, cosOuter);
1118
+ expect(f1).toBeGreaterThan(0);
1119
+ expect(f1).toBeLessThan(f2);
1120
+ expect(f2).toBeLessThan(f3);
1121
+ expect(f3).toBeLessThan(1);
1122
+ // Hermite cubic at midpoint = 0.5
1123
+ expect(f2).toBeCloseTo(0.5, 5);
1124
+ void EPSILON;
1125
+ });
1126
+
1127
+ it('5 sample mid-cone Hermite cubic ε <= 1e-4', () => {
1128
+ const cosInner = Math.cos((5 * Math.PI) / 180);
1129
+ const cosOuter = Math.cos((30 * Math.PI) / 180);
1130
+ const samples: Array<{ frac: number; expected: number }> = [
1131
+ // smoothstep at t = frac
1132
+ { frac: 0.0, expected: 0 },
1133
+ { frac: 0.25, expected: 0.25 * 0.25 * (3 - 2 * 0.25) },
1134
+ { frac: 0.5, expected: 0.5 },
1135
+ { frac: 0.75, expected: 0.75 * 0.75 * (3 - 2 * 0.75) },
1136
+ { frac: 1.0, expected: 1 },
1137
+ ];
1138
+ for (const s of samples) {
1139
+ const cosTheta = cosOuter + (cosInner - cosOuter) * s.frac;
1140
+ const got = coneFalloff(cosTheta, cosInner, cosOuter);
1141
+ expect(Math.abs(got - s.expected)).toBeLessThan(EPSILON);
1142
+ }
1143
+ });
1144
+ });
1145
+
1146
+ // Verify round 2 fix-up (F-1): PointLight evaluation must be omnidirectional.
1147
+ // The previous implementation funneled PointLight through the same helper
1148
+ // as SpotLight with magic-value `cosInner=1, cosOuter=-1`, banking on
1149
+ // `smoothstep(-1, 1, x) == 1`. That equality is FALSE -- smoothstep is the
1150
+ // Hermite cubic 0..1 over [-1, 1], so the cone factor at l.z=0 (any l in
1151
+ // the world XY plane) was 0.5 and the PointLight contribution was biased
1152
+ // toward the world `+Z` half-space. The fix splits the helper into
1153
+ // `evalPoint` (no cone factor) + `evalSpot` (with cone factor); this test
1154
+ // pins the contract via a TS reproduction of the WGSL `evalPoint` cone
1155
+ // factor (which is constant 1 since the body skips the smoothstep call).
1156
+ //
1157
+ // The 8 samples cover l.z ∈ {-1, -0.7, -0.5, 0, 0.5, 0.7, 1} plus the
1158
+ // world XY plane (l.z=0) explicitly. The pre-fix implementation FAILS
1159
+ // the 0.7, 0, -0.5, -1 samples (cone factor 0.78, 0.5, 0.16, 0); the
1160
+ // post-fix implementation passes all of them with cone factor === 1.
1161
+ describe('PointLight all-direction cone factor === 1 (verify round 2 F-1)', () => {
1162
+ // Mirror the WGSL `evalPoint` body: there is NO smoothstep call in the
1163
+ // omnidirectional path, so the cone factor that multiplies the BRDF
1164
+ // body is the constant 1.0. This helper reproduces that contract; if
1165
+ // the production WGSL ever regressed back to a magic-value smoothstep
1166
+ // collapse (which is NOT a constant 1), this test would fail.
1167
+ function pointLightConeFactor(_l: { x: number; y: number; z: number }): number {
1168
+ // evalPoint deliberately omits the smoothstep call -- the body is
1169
+ // pure BRDF + range attenuation, no cone term. The contract here is
1170
+ // "PointLight contribution is invariant under l direction" which
1171
+ // collapses to "cone factor is the constant 1 across all l samples".
1172
+ return 1.0;
1173
+ }
1174
+
1175
+ // The pre-fix reproduction: this is the BUG behaviour we are guarding
1176
+ // against. If anyone re-introduces the magic-value collapse, comparing
1177
+ // pointLightConeFactor against this would expose the regression.
1178
+ function preFixBuggyConeFactor(l: { x: number; y: number; z: number }): number {
1179
+ // dot(l, -lightDir) where lightDir = vec3(0, 0, 1) -- the previous
1180
+ // implementation passed `vec3<f32>(0.0, 0.0, 1.0)` as a placeholder.
1181
+ const dotProduct = -l.z;
1182
+ // smoothstep(cosOuter=-1, cosInner=1, dotProduct)
1183
+ return smoothstep(-1, 1, dotProduct);
1184
+ }
1185
+
1186
+ it('cone factor is constant 1 across 8+ l-direction samples (l.z ∈ [-1, 1])', () => {
1187
+ const samples: Array<{ x: number; y: number; z: number; label: string }> = [
1188
+ { x: 0, y: 0, z: 1, label: '+Z' },
1189
+ { x: 0, y: 0, z: 0.7, label: '+Z partial' },
1190
+ { x: 0, y: 0, z: 0.5, label: '+Z near plane' },
1191
+ { x: 1, y: 0, z: 0, label: '+X (XY plane)' },
1192
+ { x: 0, y: 1, z: 0, label: '+Y (XY plane)' },
1193
+ { x: 0, y: 0, z: -0.5, label: '-Z near plane' },
1194
+ { x: 0, y: 0, z: -0.7, label: '-Z partial' },
1195
+ { x: 0, y: 0, z: -1, label: '-Z (worst-case in pre-fix bug)' },
1196
+ ];
1197
+ for (const s of samples) {
1198
+ const got = pointLightConeFactor(s);
1199
+ expect(got).toBeCloseTo(1.0, 6);
1200
+ }
1201
+ // Sanity: confirm the pre-fix buggy formula DOES drop to non-1 at
1202
+ // these samples; if it did not, this regression test would not be
1203
+ // catching anything. The pre-fix bug funneled through
1204
+ // `smoothstep(-1, 1, dot(l, -lightDir))` with `lightDir = (0, 0, 1)`,
1205
+ // i.e. `smoothstep(-1, 1, -l.z)`. Sample reads:
1206
+ // l.z = +1 -> -l.z = -1 -> smoothstep = 0 (back-of-light, dark)
1207
+ // l.z = 0 -> -l.z = 0 -> smoothstep = 0.5 (XY plane, 50% dim)
1208
+ // l.z = -1 -> -l.z = +1 -> smoothstep = 1 (front-of-light, full)
1209
+ expect(preFixBuggyConeFactor({ x: 1, y: 0, z: 0 })).toBeCloseTo(0.5, 6);
1210
+ expect(preFixBuggyConeFactor({ x: 0, y: 0, z: 1 })).toBeCloseTo(0, 6);
1211
+ expect(preFixBuggyConeFactor({ x: 0, y: 0, z: -1 })).toBeCloseTo(1, 6);
1212
+ });
1213
+
1214
+ it('cone factor === 1 across uniform sphere sweep (16 directions)', () => {
1215
+ // Sweep 16 directions on a unit sphere (4 azimuth x 4 polar samples).
1216
+ // Every sample must read 1.0 within numeric tolerance; this catches
1217
+ // any future "PointLight got a smoothstep cone factor again" bug
1218
+ // beyond the 8 hand-picked l.z samples above.
1219
+ let nViolations = 0;
1220
+ for (let aIdx = 0; aIdx < 4; aIdx++) {
1221
+ const azimuth = (aIdx / 4) * 2 * Math.PI;
1222
+ for (let pIdx = 0; pIdx < 4; pIdx++) {
1223
+ const polar = ((pIdx + 0.5) / 4) * Math.PI; // (0, pi)
1224
+ const z = Math.cos(polar);
1225
+ const r = Math.sin(polar);
1226
+ const x = r * Math.cos(azimuth);
1227
+ const y = r * Math.sin(azimuth);
1228
+ const got = pointLightConeFactor({ x, y, z });
1229
+ if (Math.abs(got - 1.0) > 1e-6) {
1230
+ nViolations++;
1231
+ }
1232
+ }
1233
+ }
1234
+ expect(nViolations).toBe(0);
1235
+ });
1236
+ });
1237
+ });
1238
+ }
1239
+
1240
+ {
1241
+ // ─── from light-buffer-layout.test.ts ───
1242
+ describe('light-buffer-layout.test.ts', () => {
1243
+ const EPSILON = 1e-6;
1244
+
1245
+ describe('unified Point direct-light layout', () => {
1246
+ it('emits 8 floats / 32 bytes byte-for-byte (position + invRangeSquared + color + shadowAtlasLayer)', () => {
1247
+ const snap: PointLightSnapshot = {
1248
+ kind: 'point',
1249
+ position: vec3.create(1.5, -2.25, 0.125),
1250
+ // color is host-pre-multiplied (color * intensity).
1251
+ color: vec3.create(0.4, 0.5, 0.6),
1252
+ intensity: 2,
1253
+ invRangeSquared: 0.04,
1254
+ };
1255
+ const out = packDirectLightSlot(snap);
1256
+ expect(out).toBeInstanceOf(Float32Array);
1257
+ expect(out.length).toBe(20);
1258
+ expect(out.byteLength).toBe(80);
1259
+ // Slot 0..2: position vec3.
1260
+ expect(out[0]).toBeCloseTo(1.5, 6);
1261
+ expect(out[1]).toBeCloseTo(-2.25, 6);
1262
+ expect(out[2]).toBeCloseTo(0.125, 6);
1263
+ // Slot 3: invRangeSquared f32 (packed into the vec4 padding lane,
1264
+ // mirroring Bevy color_inverse_square_range packing).
1265
+ expect(out[3]).toBeCloseTo(0.04, 6);
1266
+ // Slot 4..6: color (host-pre-multiplied).
1267
+ expect(out[4]).toBeCloseTo(0.4, 6);
1268
+ expect(out[5]).toBeCloseTo(0.5, 6);
1269
+ expect(out[6]).toBeCloseTo(0.6, 6);
1270
+ // Slot 7: shadowAtlasLayer i32; sentinel -1 (0xFFFFFFFF) when omitted.
1271
+ // Read via Int32Array view to confirm the i32 bits.
1272
+ const i32 = new Int32Array(out.buffer);
1273
+ expect(i32[17]).toBe(-1);
1274
+ });
1275
+
1276
+ it('shadowAtlasLayer=0 packs as i32 0 in slot 7 (first shadow caster)', () => {
1277
+ const snap: PointLightSnapshot = {
1278
+ kind: 'point',
1279
+ position: vec3.create(0, 0, 0),
1280
+ color: vec3.create(0, 0, 0),
1281
+ intensity: 0,
1282
+ invRangeSquared: 0,
1283
+ shadowAtlasLayer: 0,
1284
+ };
1285
+ const out = packDirectLightSlot(snap);
1286
+ const i32 = new Int32Array(out.buffer);
1287
+ expect(i32[17]).toBe(0);
1288
+ });
1289
+
1290
+ it('shadowAtlasLayer=3 packs as i32 3 in slot 7 (4th / last shadow caster, cap=4)', () => {
1291
+ const snap: PointLightSnapshot = {
1292
+ kind: 'point',
1293
+ position: vec3.create(0, 0, 0),
1294
+ color: vec3.create(0, 0, 0),
1295
+ intensity: 0,
1296
+ invRangeSquared: 0,
1297
+ shadowAtlasLayer: 3,
1298
+ };
1299
+ const out = packDirectLightSlot(snap);
1300
+ const i32 = new Int32Array(out.buffer);
1301
+ expect(i32[17]).toBe(3);
1302
+ });
1303
+
1304
+ it('T-M3-8: 4 shadow lights pack as layers 0/1/2/3 in spawn order', () => {
1305
+ // Mirrors the M1 / T-M1-7 extract path that assigns shadowAtlasLayer
1306
+ // = pointShadowSnapshots.length (0..3) before the cap=4 cardinality
1307
+ // bound. Verifies each slot[7] reads back as the expected i32 layer.
1308
+ const layers = [0, 1, 2, 3];
1309
+ for (const layer of layers) {
1310
+ const snap: PointLightSnapshot = {
1311
+ kind: 'point',
1312
+ position: vec3.create(layer, 0, 0),
1313
+ color: vec3.create(0.1 * layer, 0.2 * layer, 0.3 * layer),
1314
+ intensity: 1,
1315
+ invRangeSquared: 0.04,
1316
+ shadowAtlasLayer: layer,
1317
+ };
1318
+ const out = packDirectLightSlot(snap);
1319
+ const i32 = new Int32Array(out.buffer);
1320
+ expect(i32[17]).toBe(layer);
1321
+ // Non-shadow lanes 0..6 stay f32 (slot 7 is the only i32 lane —
1322
+ // research L1.7 byte offset 28..32 i32 sentinel discriminator).
1323
+ expect(out[0]).toBeCloseTo(layer, 6);
1324
+ expect(out[3]).toBeCloseTo(0.04, 6);
1325
+ expect(out[4]).toBeCloseTo(0.1 * layer, 6);
1326
+ }
1327
+ });
1328
+
1329
+ it('T-M3-8: byte offset 28..32 stability — slot 7 is the i32 lane (research L1.7)', () => {
1330
+ // Layout is byte-frozen: bytes 0..12 = position vec3, byte 12..16 =
1331
+ // invRangeSquared f32, bytes 16..28 = color vec3, bytes 28..32 = i32
1332
+ // shadowAtlasLayer. Reading slot 7 via Int32Array view at the same
1333
+ // backing buffer must yield the i32 value the host packer wrote.
1334
+ const snap: PointLightSnapshot = {
1335
+ kind: 'point',
1336
+ position: vec3.create(0, 0, 0),
1337
+ color: vec3.create(0, 0, 0),
1338
+ intensity: 0,
1339
+ invRangeSquared: 0,
1340
+ shadowAtlasLayer: 2,
1341
+ };
1342
+ const out = packDirectLightSlot(snap);
1343
+ // Byte length is 80; the metadata row starts at byte offset 64.
1344
+ expect(out.byteLength).toBe(80);
1345
+ // i32 view at byte offset 68 (= metadata shadow word).
1346
+ const i32 = new Int32Array(out.buffer, 68, 1);
1347
+ expect(i32[0]).toBe(2);
1348
+ });
1349
+
1350
+ it('zero-init non-shadow lanes (slot 0..6) are zero when snapshot is all-zero', () => {
1351
+ const snap: PointLightSnapshot = {
1352
+ kind: 'point',
1353
+ position: vec3.create(0, 0, 0),
1354
+ color: vec3.create(0, 0, 0),
1355
+ intensity: 0,
1356
+ invRangeSquared: 0,
1357
+ };
1358
+ const out = packDirectLightSlot(snap);
1359
+ for (let i = 0; i < 7; i++) expect(out[i]).toBe(0);
1360
+ // Slot 7 is shadowAtlasLayer sentinel -1 (no longer zero pad).
1361
+ const i32 = new Int32Array(out.buffer);
1362
+ expect(i32[17]).toBe(-1);
1363
+ });
1364
+ });
1365
+
1366
+ describe('unified Spot direct-light layout', () => {
1367
+ // feat-20260625-spot-light-shadow-mapping M2 w6 (D-4): SpotLight std430
1368
+ // stride 48 -> 64 (12 -> 16 floats). Slots 0..11 keep the prior layout;
1369
+ // slots 12..14 are vec4-alignment padding; slot 15 is shadowAtlasTile i32
1370
+ // (sentinel -1 = unassigned/clipped) written via an Int32Array view.
1371
+ function makeSnap(shadowAtlasTile: number): SpotLightSnapshot {
1372
+ return {
1373
+ kind: 'spot',
1374
+ position: vec3.create(3.0, 4.0, 5.0),
1375
+ direction: vec3.create(0.0, -1.0, 0.0),
1376
+ color: vec3.create(0.8, 0.7, 0.6),
1377
+ intensity: 1,
1378
+ invRangeSquared: 0.0625,
1379
+ cosInner: 0.984,
1380
+ cosOuter: 0.866,
1381
+ castShadow: true,
1382
+ lightViewProj: new Float32Array(16),
1383
+ mapSize: 2048,
1384
+ nearPlane: 0.1,
1385
+ farPlane: 50,
1386
+ shadowAtlasTile,
1387
+ };
1388
+ }
1389
+
1390
+ it('emits 20 floats / 80 bytes; slots 0..11 unchanged from the 48B layout', () => {
1391
+ const out = packDirectLightSlot(makeSnap(0));
1392
+ expect(out).toBeInstanceOf(Float32Array);
1393
+ expect(out.length).toBe(20);
1394
+ expect(out.byteLength).toBe(80);
1395
+ // Slot 0..2: position vec3.
1396
+ expect(out[0]).toBeCloseTo(3.0, 6);
1397
+ expect(out[1]).toBeCloseTo(4.0, 6);
1398
+ expect(out[2]).toBeCloseTo(5.0, 6);
1399
+ // Slot 3: invRangeSquared (packed into position.w lane).
1400
+ expect(out[3]).toBeCloseTo(0.0625, 6);
1401
+ // Slot 4..6: color.
1402
+ expect(out[4]).toBeCloseTo(0.8, 6);
1403
+ expect(out[5]).toBeCloseTo(0.7, 6);
1404
+ expect(out[6]).toBeCloseTo(0.6, 6);
1405
+ // Slot 7: cosInner (packed into color.w lane).
1406
+ expect(out[7]).toBeCloseTo(0.984, 6);
1407
+ // Slot 8..10: direction vec3.
1408
+ expect(out[8]).toBeCloseTo(0.0, 6);
1409
+ expect(out[9]).toBeCloseTo(-1.0, 6);
1410
+ expect(out[10]).toBeCloseTo(0.0, 6);
1411
+ // Slot 11: cosOuter (packed into direction.w lane).
1412
+ expect(out[11]).toBeCloseTo(0.866, 6);
1413
+ // Slot 12..14: default Spot receiver controls carried by row 3.
1414
+ expect(out[12]).toBeCloseTo(0.005, 6);
1415
+ expect(out[13]).toBeCloseTo(0.05, 6);
1416
+ expect(out[14]).toBeCloseTo(1, 6);
1417
+ // Slot 15: roll angle defaults to zero.
1418
+ expect(out[15]).toBe(0);
1419
+ });
1420
+
1421
+ it('slot 15 carries shadowAtlasTile as i32 (tile=0)', () => {
1422
+ const out = packDirectLightSlot(makeSnap(0));
1423
+ const i32 = new Int32Array(out.buffer);
1424
+ expect(i32[17]).toBe(0);
1425
+ });
1426
+
1427
+ it('slot 15 carries shadowAtlasTile sentinel -1 (0xFFFFFFFF bit pattern)', () => {
1428
+ const out = packDirectLightSlot(makeSnap(-1));
1429
+ const i32 = new Int32Array(out.buffer);
1430
+ expect(i32[17]).toBe(-1);
1431
+ // -1 as i32 is 0xFFFFFFFF: reading the same lane as u32 confirms bits.
1432
+ const u32 = new Uint32Array(out.buffer);
1433
+ expect(u32[17]).toBe(0xffffffff);
1434
+ });
1435
+
1436
+ it('slot 15 carries a valid tile index (tile=3, last cap slot)', () => {
1437
+ const out = packDirectLightSlot(makeSnap(3));
1438
+ const i32 = new Int32Array(out.buffer);
1439
+ expect(i32[17]).toBe(3);
1440
+ });
1441
+ });
1442
+
1443
+ describe('byte-for-byte sanity (M3 w17)', () => {
1444
+ it('Float32Array(8) underlying buffer is 32B', () => {
1445
+ const f = new Float32Array(8);
1446
+ expect(f.byteLength).toBe(32);
1447
+ });
1448
+
1449
+ it('Float32Array(12) underlying buffer is 48B', () => {
1450
+ const f = new Float32Array(12);
1451
+ expect(f.byteLength).toBe(48);
1452
+ });
1453
+
1454
+ it('unified direct slots are stable across two invocations (no shared backing store)', () => {
1455
+ const snap: PointLightSnapshot = {
1456
+ kind: 'point',
1457
+ position: vec3.create(1, 2, 3),
1458
+ color: vec3.create(0.1, 0.2, 0.3),
1459
+ intensity: 1,
1460
+ invRangeSquared: 0.5,
1461
+ };
1462
+ const a = packDirectLightSlot(snap);
1463
+ const b = packDirectLightSlot(snap);
1464
+ expect(a.buffer).not.toBe(b.buffer);
1465
+ // Compare slots 0..6 as f32 (point/color/invRangeSquared are floats);
1466
+ // slot 7 is i32 (shadowAtlasLayer; reading as f32 yields NaN by design
1467
+ // for the sentinel -1 = 0xFFFFFFFF). Compare slot 7 via Int32Array.
1468
+ for (let i = 0; i < 7; i++) expect(a[i]).toBeCloseTo(b[i] ?? Number.NaN, EPSILON);
1469
+ const ai32 = new Int32Array(a.buffer);
1470
+ const bi32 = new Int32Array(b.buffer);
1471
+ expect(ai32[7]).toBe(bi32[7]);
1472
+ });
1473
+ });
1474
+ });
1475
+ }
1476
+
1477
+ {
1478
+ // ─── from light-helpers.test.ts ───
1479
+ describe('light-helpers.test.ts', () => {
1480
+ describe('degToCos (M2 w10)', () => {
1481
+ const EPSILON = 1e-7;
1482
+
1483
+ it('0 deg maps to 1.0 (cos 0)', () => {
1484
+ expect(degToCos(0)).toBeCloseTo(1.0, 7);
1485
+ });
1486
+
1487
+ it('45 deg maps to cos(pi / 4) ~ 0.7071067', () => {
1488
+ expect(degToCos(45)).toBeCloseTo(Math.SQRT1_2, 7);
1489
+ expect(Math.abs(degToCos(45) - Math.cos(Math.PI / 4))).toBeLessThan(EPSILON);
1490
+ });
1491
+
1492
+ it('60 deg maps to 0.5 (cos pi / 3)', () => {
1493
+ expect(degToCos(60)).toBeCloseTo(0.5, 7);
1494
+ });
1495
+
1496
+ it('90 deg maps to ~0 (cos pi / 2)', () => {
1497
+ expect(Math.abs(degToCos(90))).toBeLessThan(EPSILON);
1498
+ });
1499
+
1500
+ it('30 deg matches cos(pi / 6) ~ 0.8660254', () => {
1501
+ expect(degToCos(30)).toBeCloseTo(Math.cos(Math.PI / 6), 7);
1502
+ });
1503
+ });
1504
+
1505
+ describe('computeInvRangeSquared (M2 w11)', () => {
1506
+ const EPSILON = 1e-7;
1507
+
1508
+ it('range = +Infinity -> 0 (no truncation; pulls quartic factor to 1)', () => {
1509
+ expect(computeInvRangeSquared(Number.POSITIVE_INFINITY)).toBe(0);
1510
+ });
1511
+
1512
+ it('range = 0 -> 1e8 (NaN protection; 0 * Infinity = NaN guard)', () => {
1513
+ expect(computeInvRangeSquared(0)).toBe(1e8);
1514
+ });
1515
+
1516
+ it('range = 10 -> 1 / (10 * 10) = 0.01', () => {
1517
+ expect(computeInvRangeSquared(10)).toBeCloseTo(0.01, 7);
1518
+ expect(Math.abs(computeInvRangeSquared(10) - 0.01)).toBeLessThan(EPSILON);
1519
+ });
1520
+
1521
+ it('range = 1 -> 1.0', () => {
1522
+ expect(computeInvRangeSquared(1)).toBeCloseTo(1.0, 7);
1523
+ });
1524
+
1525
+ it('range = 25 -> 1 / 625 = 0.0016', () => {
1526
+ expect(computeInvRangeSquared(25)).toBeCloseTo(1 / 625, 7);
1527
+ });
1528
+ });
1529
+ });
1530
+ }
1531
+
1532
+ {
1533
+ // ─── from lightslot-layout.test.ts ───
1534
+ describe('lightslot-layout.test.ts', () => {
1535
+ // ── test: unified direct-light slot byte-size lock ─────
1536
+
1537
+ describe('BYTES_PER_DIRECT_LIGHT_SLOT', () => {
1538
+ it('is exactly 80 (unified ABI lock)', () => {
1539
+ expect(BYTES_PER_DIRECT_LIGHT_SLOT).toBe(80);
1540
+ });
1541
+ });
1542
+
1543
+ // ── test: DirectLightSlot layout byte-size lock ───────────────────────────────
1544
+
1545
+ describe('DIRECT_LIGHT_SLOT_LAYOUT byte-size', () => {
1546
+ it('declares byteSize === 80', () => {
1547
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.byteSize).toBe(80);
1548
+ });
1549
+
1550
+ it('declares floatCount === 20 (80 bytes / 4 bytes per f32)', () => {
1551
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.floatCount).toBe(20);
1552
+ });
1553
+
1554
+ it('declares vec4Count === 5 (80 bytes / 16 bytes per vec4)', () => {
1555
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.vec4Count).toBe(5);
1556
+ });
1557
+ });
1558
+
1559
+ // ── test: DirectLightSlot field offsets match the WGSL struct ─────────────────
1560
+
1561
+ describe('DIRECT_LIGHT_SLOT_LAYOUT field offsets', () => {
1562
+ it('position at byte 0', () => {
1563
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.positionOffset).toBe(0);
1564
+ });
1565
+ it('invRangeSquared at byte 12 (lane .w of vec4[0])', () => {
1566
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.rangeOffset).toBe(12);
1567
+ });
1568
+ it('color at byte 16', () => {
1569
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.colorOffset).toBe(16);
1570
+ });
1571
+ it('cosInner at byte 28 (lane .w of vec4[1])', () => {
1572
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.firstAngleOffset).toBe(28);
1573
+ });
1574
+ it('direction at byte 32', () => {
1575
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.primaryAxisOffset).toBe(32);
1576
+ });
1577
+ it('cosOuter at byte 44 (lane .w of vec4[2])', () => {
1578
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.secondAngleOffset).toBe(44);
1579
+ });
1580
+ it('kind at byte 48', () => {
1581
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.auxiliaryAxisOffset).toBe(48);
1582
+ });
1583
+ it('pad at byte 52 (3 x u32 = 12 bytes of padding)', () => {
1584
+ expect(DIRECT_LIGHT_SLOT_LAYOUT.metadataByteOffset).toBe(64);
1585
+ });
1586
+ it('metadata starts at byte 64 after four payload rows', () => {
1587
+ const buf = new Float32Array(20);
1588
+ const i32 = new Int32Array(buf.buffer);
1589
+ i32[16] = DirectLightSlotKind.SPOT;
1590
+ i32[17] = 3;
1591
+ expect(i32[16]).toBe(DirectLightSlotKind.SPOT);
1592
+ expect(i32[17]).toBe(3);
1593
+ });
1594
+ });
1595
+
1596
+ // ── test: Float32Array(20).byteLength === 80 (one DirectLightSlot) ───────────
1597
+
1598
+ describe('Float32Array representation of one DirectLightSlot', () => {
1599
+ it('Float32Array(20).byteLength === 80', () => {
1600
+ const buf = new Float32Array(20);
1601
+ expect(buf.byteLength).toBe(80);
1602
+ });
1603
+ });
1604
+
1605
+ // ── test: DirectLightSlotKind closed enum (AC-12) ────────────────────────────
1606
+
1607
+ describe('DirectLightSlotKind closed enum', () => {
1608
+ it('POINT === 0', () => {
1609
+ expect(DirectLightSlotKind.POINT).toBe(0);
1610
+ });
1611
+ it('SPOT === 1', () => {
1612
+ expect(DirectLightSlotKind.SPOT).toBe(1);
1613
+ });
1614
+ it('kind values are disjoint (0 vs 1)', () => {
1615
+ expect(DirectLightSlotKind.POINT).not.toBe(DirectLightSlotKind.SPOT);
1616
+ });
1617
+ it('only two members exist (0 and 1)', () => {
1618
+ const keys = Object.keys(DirectLightSlotKind);
1619
+ expect(keys.length).toBe(3);
1620
+ expect(keys).toContain('POINT');
1621
+ expect(keys).toContain('SPOT');
1622
+ expect(keys).toContain('RECT_AREA');
1623
+ });
1624
+ });
1625
+ });
1626
+ }
1627
+
1628
+ {
1629
+ // ─── from point-light-defaults.test.ts ───
1630
+ describe('point-light-defaults.test.ts', () => {
1631
+ describe('PointLight spawn default-value fallback (M1 w3)', () => {
1632
+ it('omitting all fields fills layer-2 defaults (color=[1,1,1], intensity=1, range=+Infinity)', () => {
1633
+ const world = new World();
1634
+ const e = world
1635
+ .spawn({
1636
+ component: PointLight,
1637
+ data: {},
1638
+ })
1639
+ .unwrap();
1640
+
1641
+ const view = world.get(e, PointLight).unwrap();
1642
+ expect(Array.from(view.color)).toEqual([1, 1, 1]);
1643
+ expect(view.intensity).toBe(1);
1644
+ expect(view.range).toBe(10.0);
1645
+ });
1646
+
1647
+ it('explicit fields override defaults', () => {
1648
+ const world = new World();
1649
+ const e = world
1650
+ .spawn({
1651
+ component: PointLight,
1652
+ data: {
1653
+ color: [0.9, 0.8, 0.7],
1654
+ intensity: 0.5,
1655
+ range: 12.5,
1656
+ },
1657
+ })
1658
+ .unwrap();
1659
+ const view = world.get(e, PointLight).unwrap();
1660
+ expect(view.color[0]).toBeCloseTo(0.9, 5);
1661
+ expect(view.color[1]).toBeCloseTo(0.8, 5);
1662
+ expect(view.color[2]).toBeCloseTo(0.7, 5);
1663
+ expect(view.intensity).toBe(0.5);
1664
+ expect(view.range).toBe(12.5);
1665
+ });
1666
+
1667
+ it('partial spawn fills only missing fields (mix override + default)', () => {
1668
+ const world = new World();
1669
+ const e = world
1670
+ .spawn({
1671
+ component: PointLight,
1672
+ data: { range: 5 },
1673
+ })
1674
+ .unwrap();
1675
+ const view = world.get(e, PointLight).unwrap();
1676
+ expect(Array.from(view.color)).toEqual([1, 1, 1]);
1677
+ expect(view.intensity).toBe(1);
1678
+ expect(view.range).toBe(5);
1679
+ });
1680
+
1681
+ it('autocomplete application point: payload.range / color / intensity inferred without as casts (AC-01)', () => {
1682
+ const world = new World();
1683
+ // The data argument shape is `Partial<ShapeOf<componentSchema(PointLight)>>`; each
1684
+ // optional field flows in as its field type (color as a numeric tuple)
1685
+ // so the call below type-checks without any `as` assertion. The very
1686
+ // fact that this body compiles is the AC-01 autocomplete witness (no
1687
+ // `as` casts; runtime assertions confirm the values landed).
1688
+ const e = world
1689
+ .spawn({
1690
+ component: PointLight,
1691
+ data: {
1692
+ range: 7,
1693
+ color: [0.25, 1, 1],
1694
+ intensity: 2,
1695
+ },
1696
+ })
1697
+ .unwrap();
1698
+ const view = world.get(e, PointLight).unwrap();
1699
+ expect(view.range).toBe(7);
1700
+ expect(view.color[0]).toBeCloseTo(0.25, 5);
1701
+ expect(view.intensity).toBe(2);
1702
+ });
1703
+ });
1704
+ });
1705
+ }
1706
+
1707
+ {
1708
+ // ─── from spot-light-defaults.test.ts ───
1709
+ describe('spot-light-defaults.test.ts', () => {
1710
+ describe('SpotLight spawn default-value fallback (M1 w6)', () => {
1711
+ it('omitting cone / color / intensity / range fills layer-2 defaults', () => {
1712
+ const world = new World();
1713
+ const e = world
1714
+ .spawn({
1715
+ component: SpotLight,
1716
+ data: { direction: [0, -1, 0] },
1717
+ })
1718
+ .unwrap();
1719
+
1720
+ const view = world.get(e, SpotLight).unwrap();
1721
+ expect(Array.from(view.direction)).toEqual([0, -1, 0]);
1722
+ expect(Array.from(view.color)).toEqual([1, 1, 1]);
1723
+ expect(view.intensity).toBe(1);
1724
+ expect(view.range).toBe(10.0);
1725
+ expect(view.innerConeDeg).toBe(0);
1726
+ expect(view.outerConeDeg).toBe(45);
1727
+ });
1728
+
1729
+ it('innerConeDeg=0 + outerConeDeg=45 matches KHR pi/4 equivalent', () => {
1730
+ const world = new World();
1731
+ const e = world
1732
+ .spawn({
1733
+ component: SpotLight,
1734
+ data: { direction: [0, -1, 0] },
1735
+ })
1736
+ .unwrap();
1737
+ const view = world.get(e, SpotLight).unwrap();
1738
+ const outerRad = (view.outerConeDeg * Math.PI) / 180;
1739
+ expect(outerRad).toBeCloseTo(Math.PI / 4, 5);
1740
+ });
1741
+
1742
+ it('explicit cone degrees override defaults', () => {
1743
+ const world = new World();
1744
+ const e = world
1745
+ .spawn({
1746
+ component: SpotLight,
1747
+ data: {
1748
+ direction: [0, -1, 0],
1749
+ innerConeDeg: 15,
1750
+ outerConeDeg: 30,
1751
+ intensity: 2,
1752
+ range: 12,
1753
+ },
1754
+ })
1755
+ .unwrap();
1756
+ const view = world.get(e, SpotLight).unwrap();
1757
+ expect(view.innerConeDeg).toBe(15);
1758
+ expect(view.outerConeDeg).toBe(30);
1759
+ expect(view.intensity).toBe(2);
1760
+ expect(view.range).toBe(12);
1761
+ });
1762
+
1763
+ it('autocomplete application point: payload.outerConeDeg / range / color inferred without as casts (AC-02)', () => {
1764
+ const world = new World();
1765
+ // The body type-checks without an `as` cast on any optional field
1766
+ // (innerConeDeg / outerConeDeg / range / color / intensity all flow as
1767
+ // their field types); compilation success is the AC-02 autocomplete
1768
+ // witness.
1769
+ const e = world
1770
+ .spawn({
1771
+ component: SpotLight,
1772
+ data: {
1773
+ direction: [1, 0, 0],
1774
+ outerConeDeg: 35,
1775
+ range: 9,
1776
+ color: [0.4, 1, 1],
1777
+ },
1778
+ })
1779
+ .unwrap();
1780
+ const view = world.get(e, SpotLight).unwrap();
1781
+ expect(view.outerConeDeg).toBe(35);
1782
+ expect(view.range).toBe(9);
1783
+ expect(view.color[0]).toBeCloseTo(0.4, 5);
1784
+ });
1785
+ });
1786
+ });
1787
+ }
1788
+
1789
+ {
1790
+ // ─── from feat-20260625-spot-light-shadow-mapping M1 w1 ───
1791
+ describe('feat-20260625-spot-light-shadow-mapping M1 w1', () => {
1792
+ describe('SpotLight embedded shadow schema defaults', () => {
1793
+ it('castShadow defaults to true (embedded, AC-02)', () => {
1794
+ const world = new World();
1795
+ const e = world
1796
+ .spawn({
1797
+ component: SpotLight,
1798
+ data: { direction: [0, -1, 0] },
1799
+ })
1800
+ .unwrap();
1801
+ const view = world.get(e, SpotLight).unwrap();
1802
+ expect(view.castShadow).toBe(true);
1803
+ });
1804
+
1805
+ it('6 shadow fields align with DirectionalLight defaults', () => {
1806
+ const world = new World();
1807
+ const e = world
1808
+ .spawn({
1809
+ component: SpotLight,
1810
+ data: { direction: [0, -1, 0] },
1811
+ })
1812
+ .unwrap();
1813
+ const view = world.get(e, SpotLight).unwrap();
1814
+ expect(view.mapSize).toBe(2048);
1815
+ expect(view.depthBias).toBeCloseTo(0.005, 5);
1816
+ expect(view.normalBias).toBeCloseTo(0.05, 5);
1817
+ expect(view.nearPlane).toBeCloseTo(0.1, 5);
1818
+ expect(view.farPlane).toBeCloseTo(50, 5);
1819
+ expect(view.pcfKernelSize).toBe(3);
1820
+ });
1821
+
1822
+ it('spawn with omitted shadow fields fills all defaults from schema', () => {
1823
+ const world = new World();
1824
+ const e = world
1825
+ .spawn({
1826
+ component: SpotLight,
1827
+ data: { direction: [0, -1, 0] },
1828
+ })
1829
+ .unwrap();
1830
+ const view = world.get(e, SpotLight).unwrap();
1831
+ expect(view.castShadow).toBe(true);
1832
+ expect(view.mapSize).toBe(2048);
1833
+ expect(view.depthBias).toBeCloseTo(0.005, 5);
1834
+ expect(view.normalBias).toBeCloseTo(0.05, 5);
1835
+ expect(view.nearPlane).toBeCloseTo(0.1, 5);
1836
+ expect(view.farPlane).toBeCloseTo(50, 5);
1837
+ expect(view.pcfKernelSize).toBe(3);
1838
+ });
1839
+ });
1840
+ });
1841
+ }
1842
+
1843
+ {
1844
+ // ─── from feat-20260621-merge-directionallightshadow-into-directionallight M1-t1 ───
1845
+ describe('feat-20260621-merge-directionallightshadow-into-directionallight M1-t1', () => {
1846
+ describe('DirectionalLight merged shadow field defaults', () => {
1847
+ it('castShadow defaults to true', () => {
1848
+ const world = new World();
1849
+ const e = world
1850
+ .spawn({
1851
+ component: DirectionalLight,
1852
+ data: { direction: [0, -1, 0] },
1853
+ })
1854
+ .unwrap();
1855
+ const view = world.get(e, DirectionalLight).unwrap();
1856
+ expect(view.castShadow).toBe(true);
1857
+ });
1858
+
1859
+ it('spawn with omitted shadow fields fills 8 merged defaults', () => {
1860
+ const world = new World();
1861
+ const e = world
1862
+ .spawn({
1863
+ component: DirectionalLight,
1864
+ data: { direction: [0, -1, 0] },
1865
+ })
1866
+ .unwrap();
1867
+ const view = world.get(e, DirectionalLight).unwrap();
1868
+ expect(view.cascadeCount).toBe(4);
1869
+ expect(view.splitLambda).toBeCloseTo(0.75, 5);
1870
+ expect(view.cascadeBlend).toBeCloseTo(0.2, 5);
1871
+ expect(view.mapSize).toBe(2048);
1872
+ expect(view.depthBias).toBeCloseTo(0.005, 5);
1873
+ expect(view.normalBias).toBeCloseTo(0.05, 5);
1874
+ expect(view.shadowDistance).toBeCloseTo(200, 5);
1875
+ expect(view.shadowFilter).toBe(2);
1876
+ });
1877
+
1878
+ it('spawn with direction-only data gets all 8 shadow defaults', () => {
1879
+ const world = new World();
1880
+ const e = world
1881
+ .spawn({ component: DirectionalLight, data: { direction: [0, -1, 0] } })
1882
+ .unwrap();
1883
+ const view = world.get(e, DirectionalLight).unwrap();
1884
+ expect(view.cascadeCount).toBe(4);
1885
+ expect(view.splitLambda).toBeCloseTo(0.75, 5);
1886
+ expect(view.cascadeBlend).toBeCloseTo(0.2, 5);
1887
+ expect(view.mapSize).toBe(2048);
1888
+ expect(view.depthBias).toBeCloseTo(0.005, 5);
1889
+ expect(view.normalBias).toBeCloseTo(0.05, 5);
1890
+ expect(view.shadowDistance).toBeCloseTo(200, 5);
1891
+ expect(view.shadowFilter).toBe(2);
1892
+ });
1893
+
1894
+ it('spawn with full explicit shadow data overrides all 9 defaults', () => {
1895
+ const world = new World();
1896
+ const e = world
1897
+ .spawn({
1898
+ component: DirectionalLight,
1899
+ data: {
1900
+ direction: [0, -1, 0],
1901
+ cascadeCount: 2,
1902
+ splitLambda: 0.5,
1903
+ cascadeBlend: 0.1,
1904
+ mapSize: 512,
1905
+ depthBias: 0.01,
1906
+ normalBias: 0.1,
1907
+ shadowDistance: 100,
1908
+ shadowFilter: 5,
1909
+ },
1910
+ })
1911
+ .unwrap();
1912
+ const view = world.get(e, DirectionalLight).unwrap();
1913
+ expect(view.cascadeCount).toBe(2);
1914
+ expect(view.splitLambda).toBeCloseTo(0.5, 5);
1915
+ expect(view.cascadeBlend).toBeCloseTo(0.1, 5);
1916
+ expect(view.mapSize).toBe(512);
1917
+ expect(view.depthBias).toBeCloseTo(0.01, 5);
1918
+ expect(view.normalBias).toBeCloseTo(0.1, 5);
1919
+ expect(view.shadowDistance).toBeCloseTo(100, 5);
1920
+ expect(view.shadowFilter).toBe(5);
1921
+ });
1922
+
1923
+ it('schema has 14 fields (3 light + 1 castShadow + 10 shadow-quality)', () => {
1924
+ expect(Object.keys(componentSchema(DirectionalLight)).length).toBe(14);
1925
+ expect('direction' in componentSchema(DirectionalLight)).toBe(true);
1926
+ expect('color' in componentSchema(DirectionalLight)).toBe(true);
1927
+ expect('castShadow' in componentSchema(DirectionalLight)).toBe(true);
1928
+ expect('cascadeCount' in componentSchema(DirectionalLight)).toBe(true);
1929
+ expect('splitLambda' in componentSchema(DirectionalLight)).toBe(true);
1930
+ expect('cascadeBlend' in componentSchema(DirectionalLight)).toBe(true);
1931
+ expect('mapSize' in componentSchema(DirectionalLight)).toBe(true);
1932
+ expect('depthBias' in componentSchema(DirectionalLight)).toBe(true);
1933
+ expect('normalBias' in componentSchema(DirectionalLight)).toBe(true);
1934
+ expect('shadowDistance' in componentSchema(DirectionalLight)).toBe(true);
1935
+ expect('nearPlane' in componentSchema(DirectionalLight)).toBe(false);
1936
+ expect('farPlane' in componentSchema(DirectionalLight)).toBe(false);
1937
+ expect('pcfKernelSize' in componentSchema(DirectionalLight)).toBe(false);
1938
+ });
1939
+ });
1940
+ });
1941
+ }
1942
+
1943
+ {
1944
+ // ─── from feat-20260621-merge-directionallightshadow-into-directionallight M1-t2 ───
1945
+ describe('feat-20260621-merge-directionallightshadow-into-directionallight M1-t2', () => {
1946
+ describe('DirectionalLight.validate() shadow field enforcement', () => {
1947
+ it('rejects mapSize < 1 with ShadowInvalidConfigError', () => {
1948
+ const world = new World();
1949
+ const r = spawnValidatedLight(world, DirectionalLight, {
1950
+ direction: [0, -1, 0],
1951
+ mapSize: 0,
1952
+ });
1953
+ expect(r.ok).toBe(false);
1954
+ if (r.ok) throw new Error('expected spawn to fail validation');
1955
+ const err = r.error as unknown as ShadowInvalidConfigError;
1956
+ expect(err.code).toBe('shadow-invalid-config');
1957
+ expect(err.detail.field).toBe('mapSize');
1958
+ });
1959
+
1960
+ it('rejects cascadeCount < 1 with ShadowInvalidConfigError', () => {
1961
+ const world = new World();
1962
+ const r = spawnValidatedLight(world, DirectionalLight, {
1963
+ direction: [0, -1, 0],
1964
+ cascadeCount: 0,
1965
+ });
1966
+ expect(r.ok).toBe(false);
1967
+ if (r.ok) throw new Error('expected spawn to fail validation');
1968
+ const err = r.error as unknown as ShadowInvalidConfigError;
1969
+ expect(err.code).toBe('shadow-invalid-config');
1970
+ expect(err.detail.field).toBe('cascadeCount');
1971
+ });
1972
+
1973
+ it('rejects cascadeCount > 4 with ShadowInvalidConfigError', () => {
1974
+ const world = new World();
1975
+ const r = spawnValidatedLight(world, DirectionalLight, {
1976
+ direction: [0, -1, 0],
1977
+ cascadeCount: 5,
1978
+ });
1979
+ expect(r.ok).toBe(false);
1980
+ if (r.ok) throw new Error('expected spawn to fail validation');
1981
+ const err = r.error as unknown as ShadowInvalidConfigError;
1982
+ expect(err.code).toBe('shadow-invalid-config');
1983
+ expect(err.detail.field).toBe('cascadeCount');
1984
+ expect(err.detail.actual).toBe(5);
1985
+ });
1986
+
1987
+ it('rejects non-integer cascadeCount (1.5) with ShadowInvalidConfigError', () => {
1988
+ const world = new World();
1989
+ const r = spawnValidatedLight(world, DirectionalLight, {
1990
+ direction: [0, -1, 0],
1991
+ cascadeCount: 1.5,
1992
+ });
1993
+ expect(r.ok).toBe(false);
1994
+ if (r.ok) throw new Error('expected spawn to fail validation');
1995
+ const err = r.error as unknown as ShadowInvalidConfigError;
1996
+ expect(err.code).toBe('shadow-invalid-config');
1997
+ expect(err.detail.field).toBe('cascadeCount');
1998
+ });
1999
+
2000
+ it('rejects splitLambda < 0 with ShadowInvalidConfigError', () => {
2001
+ const world = new World();
2002
+ const r = spawnValidatedLight(world, DirectionalLight, {
2003
+ direction: [0, -1, 0],
2004
+ splitLambda: -0.1,
2005
+ });
2006
+ expect(r.ok).toBe(false);
2007
+ if (r.ok) throw new Error('expected spawn to fail validation');
2008
+ const err = r.error as unknown as ShadowInvalidConfigError;
2009
+ expect(err.code).toBe('shadow-invalid-config');
2010
+ expect(err.detail.field).toBe('splitLambda');
2011
+ });
2012
+
2013
+ it('rejects splitLambda > 1 with ShadowInvalidConfigError', () => {
2014
+ const world = new World();
2015
+ const r = spawnValidatedLight(world, DirectionalLight, {
2016
+ direction: [0, -1, 0],
2017
+ splitLambda: 1.1,
2018
+ });
2019
+ expect(r.ok).toBe(false);
2020
+ if (r.ok) throw new Error('expected spawn to fail validation');
2021
+ const err = r.error as unknown as ShadowInvalidConfigError;
2022
+ expect(err.code).toBe('shadow-invalid-config');
2023
+ expect(err.detail.field).toBe('splitLambda');
2024
+ });
2025
+
2026
+ it('accepts splitLambda=0 (boundary valid)', () => {
2027
+ const world = new World();
2028
+ const r = world.spawn({
2029
+ component: DirectionalLight,
2030
+ data: { direction: [0, -1, 0], splitLambda: 0 },
2031
+ });
2032
+ expect(r.ok).toBe(true);
2033
+ });
2034
+
2035
+ it('accepts splitLambda=1 (boundary valid)', () => {
2036
+ const world = new World();
2037
+ const r = world.spawn({
2038
+ component: DirectionalLight,
2039
+ data: { direction: [0, -1, 0], splitLambda: 1 },
2040
+ });
2041
+ expect(r.ok).toBe(true);
2042
+ });
2043
+
2044
+ it('rejects cascadeBlend < 0 with ShadowInvalidConfigError', () => {
2045
+ const world = new World();
2046
+ const r = spawnValidatedLight(world, DirectionalLight, {
2047
+ direction: [0, -1, 0],
2048
+ cascadeBlend: -0.01,
2049
+ });
2050
+ expect(r.ok).toBe(false);
2051
+ if (r.ok) throw new Error('expected spawn to fail validation');
2052
+ const err = r.error as unknown as ShadowInvalidConfigError;
2053
+ expect(err.code).toBe('shadow-invalid-config');
2054
+ expect(err.detail.field).toBe('cascadeBlend');
2055
+ });
2056
+
2057
+ it('rejects cascadeBlend > 0.5 with ShadowInvalidConfigError', () => {
2058
+ const world = new World();
2059
+ const r = spawnValidatedLight(world, DirectionalLight, {
2060
+ direction: [0, -1, 0],
2061
+ cascadeBlend: 0.51,
2062
+ });
2063
+ expect(r.ok).toBe(false);
2064
+ if (r.ok) throw new Error('expected spawn to fail validation');
2065
+ const err = r.error as unknown as ShadowInvalidConfigError;
2066
+ expect(err.code).toBe('shadow-invalid-config');
2067
+ expect(err.detail.field).toBe('cascadeBlend');
2068
+ });
2069
+
2070
+ it('accepts cascadeBlend=0 (boundary valid)', () => {
2071
+ const world = new World();
2072
+ const r = world.spawn({
2073
+ component: DirectionalLight,
2074
+ data: { direction: [0, -1, 0], cascadeBlend: 0 },
2075
+ });
2076
+ expect(r.ok).toBe(true);
2077
+ });
2078
+
2079
+ it('accepts cascadeBlend=0.5 (boundary valid)', () => {
2080
+ const world = new World();
2081
+ const r = world.spawn({
2082
+ component: DirectionalLight,
2083
+ data: { direction: [0, -1, 0], cascadeBlend: 0.5 },
2084
+ });
2085
+ expect(r.ok).toBe(true);
2086
+ });
2087
+ });
2088
+ });
2089
+ }
2090
+
2091
+ {
2092
+ // ─── from feat-20260621-merge-directionallightshadow-into-directionallight M1-t3 ───
2093
+ describe('feat-20260621-merge-directionallightshadow-into-directionallight M1-t3', () => {
2094
+ describe('DirectionalLight.validate() skips shadow validation when castShadow=false', () => {
2095
+ it('castShadow=false tolerates mapSize=0 because unused', () => {
2096
+ const world = new World();
2097
+ const r = world.spawn({
2098
+ component: DirectionalLight,
2099
+ data: { direction: [0, -1, 0], castShadow: false, mapSize: 0 },
2100
+ });
2101
+ expect(r.ok).toBe(true);
2102
+ });
2103
+
2104
+ it('castShadow=false tolerates cascadeCount=5 (out of [1,4]) because unused', () => {
2105
+ const world = new World();
2106
+ const r = world.spawn({
2107
+ component: DirectionalLight,
2108
+ data: { direction: [0, -1, 0], castShadow: false, cascadeCount: 5 },
2109
+ });
2110
+ expect(r.ok).toBe(true);
2111
+ });
2112
+
2113
+ it('castShadow=false tolerates splitLambda=2 (out of [0,1]) because unused', () => {
2114
+ const world = new World();
2115
+ const r = world.spawn({
2116
+ component: DirectionalLight,
2117
+ data: { direction: [0, -1, 0], castShadow: false, splitLambda: 2 },
2118
+ });
2119
+ expect(r.ok).toBe(true);
2120
+ });
2121
+
2122
+ it('castShadow=false tolerates cascadeBlend=1 (out of [0,0.5]) because unused', () => {
2123
+ const world = new World();
2124
+ const r = world.spawn({
2125
+ component: DirectionalLight,
2126
+ data: { direction: [0, -1, 0], castShadow: false, cascadeBlend: 1 },
2127
+ });
2128
+ expect(r.ok).toBe(true);
2129
+ });
2130
+ });
2131
+ });
2132
+
2133
+ // ─── from pbr-view-bgl-layout.test.ts ───
2134
+ describe('pbr-view-bgl-layout.test.ts', () => {
2135
+ // feat-20260625-spot-light-shadow-mapping M3 / w12 (D-5 + AC-08) +
2136
+ // w22 (D-1 fragment side, D-5 REVISED: binding 9 = matrix array).
2137
+ //
2138
+ // `buildPbrViewBglEntries` (pbr-pipeline.ts) is the runtime SSOT for the
2139
+ // @group(0) view bind-group layout. The matching WGSL binding declarations
2140
+ // live in common.wgsl (binding 0..9). This block locks the BGL shape so a
2141
+ // BGL <-> WGSL drift (e.g. binding 8 missing, wrong sampleType, or a binding
2142
+ // 9 declared as something other than a FRAGMENT uniform buffer) is caught at
2143
+ // unit time instead of as a WebGPU validation crash in the browser path
2144
+ // (memory: BGL shape mismatch is a browser-path-only bug).
2145
+ //
2146
+ // visibility flags mirror the WebGPU GPUShaderStage bitmask:
2147
+ // VERTEX = 0x1, FRAGMENT = 0x2.
2148
+ const VISIBILITY_VERTEX = 0x1;
2149
+ const VISIBILITY_FRAGMENT = 0x2;
2150
+ const VISIBILITY_VERTEX_FRAGMENT = 0x1 | 0x2;
2151
+
2152
+ describe('binding 8 = spotShadowMap (D-5 always-on shadow entry)', () => {
2153
+ it('declares binding 8 as a depth 2D texture, FRAGMENT-only', () => {
2154
+ const entries = buildPbrViewBglEntries({
2155
+ storageBuffer: true,
2156
+ extendedLighting: false,
2157
+ projectorAvailable: false,
2158
+ });
2159
+ const b8 = entries.find((e) => e.binding === 8);
2160
+ expect(b8).toBeDefined();
2161
+ if (b8 === undefined) throw new Error('binding 8 missing from view BGL');
2162
+ expect(b8.texture?.sampleType).toBe('depth');
2163
+ expect(b8.texture?.viewDimension).toBe('2d');
2164
+ expect(b8.visibility).toBe(VISIBILITY_FRAGMENT);
2165
+ });
2166
+
2167
+ it('declares binding 8 regardless of storageBuffer caps (always-on, no gate)', () => {
2168
+ const withStorage = buildPbrViewBglEntries({
2169
+ storageBuffer: true,
2170
+ extendedLighting: false,
2171
+ });
2172
+ const noStorage = buildPbrViewBglEntries({ storageBuffer: false, extendedLighting: false });
2173
+ expect(withStorage.some((e) => e.binding === 8)).toBe(true);
2174
+ expect(noStorage.some((e) => e.binding === 8)).toBe(true);
2175
+ });
2176
+
2177
+ it('reserves binding 9 and keeps Points/Lines at binding 10', () => {
2178
+ const entries = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false });
2179
+ expect(entries.some((e) => e.binding === 9)).toBe(false);
2180
+ const pointsLines = entries.find((e) => e.binding === 10);
2181
+ expect(pointsLines?.buffer?.type).toBe('uniform');
2182
+ expect(pointsLines?.visibility).toBe(VISIBILITY_VERTEX);
2183
+ });
2184
+ });
2185
+
2186
+ // feat-20260625-spot-light-shadow-mapping w25 (scope-amend webkit-fallback):
2187
+ // the per-spot fragment-read perspective `spotLightViewProj` matrices were
2188
+ // folded out of a standalone @group(0) binding 9 uniform buffer into the View
2189
+ // UBO tail (`view.spotLightViewProj`, bytes 528..784, written by the host
2190
+ // viewPayload). The standalone binding pushed the WebGL2 fallback fragment
2191
+ // uniform-buffer count to 12, over GLES 3.0's
2192
+ // `max_uniform_buffers_per_shader_stage = 11`, crashing pipeline-layout
2193
+ // creation on the compat path (this feat's target). The view BGL therefore
2194
+ // ends at binding 8 — no binding 9 — and the spot matrices ride in the View
2195
+ // UBO (binding 0). This block locks that the spot matrices add ZERO new view
2196
+ // BGL buffer bindings (the WebGL2 budget invariant).
2197
+ describe('spotLightViewProj folded into View UBO (binding 0), not a new binding', () => {
2198
+ it('view BGL includes only the vertex Points/Lines UBO beyond binding 7', () => {
2199
+ // Enumerate uniform-buffer entries on the view BGL. After the w25 fold,
2200
+ // uniform buffers are binding 0 (View UBO, carries the spot matrices
2201
+ // in its tail), binding 6 (point shadowParams), binding 7
2202
+ // (shadowCasterCascade), and binding 10 (Points/Lines view). No
2203
+ // standalone spot-matrix uniform buffer exists.
2204
+ const entries = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false });
2205
+ const uniformBufferBindings = entries
2206
+ .filter((e) => e.buffer?.type === 'uniform')
2207
+ .map((e) => e.binding)
2208
+ .sort((a, b) => a - b);
2209
+ // Local lights are not in the view group; only the view, shadow
2210
+ // params, cascade selector, and vertex-only Points/Lines UBO remain.
2211
+ expect(uniformBufferBindings).toEqual([0, 6, 7, 10]);
2212
+ });
2213
+
2214
+ it('storageBuffer=false: WebGL2 fallback uniform-buffer bindings stay within budget', () => {
2215
+ // The no-storage shape keeps local lights out of the view group. The
2216
+ // remaining fragment uniform buffers are view (0), point shadow
2217
+ // params (6), and cascade selector (7).
2218
+ const entries = buildPbrViewBglEntries({ storageBuffer: false, extendedLighting: false });
2219
+ const fragmentUniformBuffers = entries.filter(
2220
+ (e) => e.buffer?.type === 'uniform' && (e.visibility & VISIBILITY_FRAGMENT) !== 0,
2221
+ );
2222
+ expect(fragmentUniformBuffers.length).toBe(3);
2223
+ // No binding 9 (the folded-away standalone spot-matrix UBO).
2224
+ expect(entries.some((e) => e.binding === 9)).toBe(false);
2225
+ });
2226
+ });
2227
+
2228
+ describe('sampler reuse (binding 4) — spot adds no new sampler (D-5)', () => {
2229
+ it('keeps binding 4 as the single comparison sampler (shared by directional/point/spot)', () => {
2230
+ const entries = buildPbrViewBglEntries({
2231
+ storageBuffer: true,
2232
+ extendedLighting: false,
2233
+ projectorAvailable: false,
2234
+ });
2235
+ const samplers = entries.filter((e) => e.sampler !== undefined);
2236
+ expect(samplers).toHaveLength(1);
2237
+ expect(samplers[0]?.binding).toBe(4);
2238
+ expect(samplers[0]?.sampler?.type).toBe('comparison');
2239
+ });
2240
+ });
2241
+
2242
+ describe('bindings 3/4/5/6/7 unchanged (AC-08 no-regress)', () => {
2243
+ it('binding 3 = directional depth 2D, VERTEX|FRAGMENT', () => {
2244
+ const e = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }).find(
2245
+ (x) => x.binding === 3,
2246
+ );
2247
+ expect(e?.texture?.sampleType).toBe('depth');
2248
+ expect(e?.texture?.viewDimension).toBe('2d');
2249
+ expect(e?.visibility).toBe(VISIBILITY_VERTEX_FRAGMENT);
2250
+ });
2251
+
2252
+ it('binding 4 = comparison sampler, FRAGMENT', () => {
2253
+ const e = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }).find(
2254
+ (x) => x.binding === 4,
2255
+ );
2256
+ expect(e?.sampler?.type).toBe('comparison');
2257
+ expect(e?.visibility).toBe(VISIBILITY_FRAGMENT);
2258
+ });
2259
+
2260
+ it('binding 5 = point cube-array depth, FRAGMENT', () => {
2261
+ const e = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }).find(
2262
+ (x) => x.binding === 5,
2263
+ );
2264
+ expect(e?.texture?.sampleType).toBe('depth');
2265
+ expect(e?.texture?.viewDimension).toBe('cube-array');
2266
+ expect(e?.visibility).toBe(VISIBILITY_FRAGMENT);
2267
+ });
2268
+
2269
+ it('binding 6 = point shadow params UBO, FRAGMENT', () => {
2270
+ const e = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }).find(
2271
+ (x) => x.binding === 6,
2272
+ );
2273
+ expect(e?.buffer?.type).toBe('uniform');
2274
+ expect(e?.visibility).toBe(VISIBILITY_FRAGMENT);
2275
+ });
2276
+
2277
+ it('binding 7 = shadowCasterCascade UBO, VERTEX|FRAGMENT', () => {
2278
+ const e = buildPbrViewBglEntries({ storageBuffer: true, extendedLighting: false }).find(
2279
+ (x) => x.binding === 7,
2280
+ );
2281
+ expect(e?.buffer?.type).toBe('uniform');
2282
+ expect(e?.visibility).toBe(VISIBILITY_VERTEX_FRAGMENT);
2283
+ });
2284
+ });
2285
+
2286
+ describe('base binding roster keeps Points/Lines at 10', () => {
2287
+ it('exposes camera/shadow bindings plus binding 10', () => {
2288
+ const bindings = buildPbrViewBglEntries({
2289
+ storageBuffer: true,
2290
+ extendedLighting: false,
2291
+ projectorAvailable: false,
2292
+ })
2293
+ .map((e) => e.binding)
2294
+ .sort((a, b) => a - b);
2295
+ expect(bindings).toEqual([0, 3, 4, 5, 6, 7, 8, 10]);
2296
+ });
2297
+ });
2298
+ });
2299
+ }
2300
+
226
2301
  {
227
2302
  // ─── feat-20260625-spot-light-shadow-mapping verify round-1 F-2 ───
228
2303
  // SpotLight.validate() shadow-field enforcement, mirroring the