@aura3d/engine 1.5.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (541) hide show
  1. package/README.md +142 -117
  2. package/dist/animation/FootIk.d.ts +10 -1
  3. package/dist/animation/FootIk.d.ts.map +1 -1
  4. package/dist/animation/FootIk.js.map +1 -1
  5. package/dist/animation/HumanoidRetargeting.js +1 -1
  6. package/dist/animation/RigQuality.d.ts +1 -1
  7. package/dist/animation/RigQuality.js +2 -2
  8. package/dist/animation/RigQuality.js.map +1 -1
  9. package/dist/animation/SecondaryAnimationSampling.d.ts +55 -0
  10. package/dist/animation/SecondaryAnimationSampling.d.ts.map +1 -0
  11. package/dist/animation/SecondaryAnimationSampling.js +145 -0
  12. package/dist/animation/SecondaryAnimationSampling.js.map +1 -0
  13. package/dist/animation/SpringBones.d.ts.map +1 -1
  14. package/dist/animation/SpringBones.js +1 -2
  15. package/dist/animation/SpringBones.js.map +1 -1
  16. package/dist/animation/browser-index.d.ts +0 -2
  17. package/dist/animation/browser-index.d.ts.map +1 -1
  18. package/dist/animation/browser-index.js +0 -2
  19. package/dist/animation/browser-index.js.map +1 -1
  20. package/dist/animation/index.d.ts +0 -2
  21. package/dist/animation/index.d.ts.map +1 -1
  22. package/dist/animation/index.js +0 -2
  23. package/dist/animation/index.js.map +1 -1
  24. package/dist/assets/GLTFAnimationRuntime.d.ts +4 -0
  25. package/dist/assets/GLTFAnimationRuntime.d.ts.map +1 -1
  26. package/dist/assets/GLTFAnimationRuntime.js +29 -6
  27. package/dist/assets/GLTFAnimationRuntime.js.map +1 -1
  28. package/dist/assets/GLTFLoader.d.ts.map +1 -1
  29. package/dist/assets/GLTFLoader.js +13 -4
  30. package/dist/assets/GLTFLoader.js.map +1 -1
  31. package/dist/assets/browser-index.d.ts +0 -4
  32. package/dist/assets/browser-index.d.ts.map +1 -1
  33. package/dist/assets/browser-index.js +0 -2
  34. package/dist/assets/browser-index.js.map +1 -1
  35. package/dist/assets/gltf-runtime.d.ts +6 -0
  36. package/dist/assets/gltf-runtime.d.ts.map +1 -0
  37. package/dist/assets/gltf-runtime.js +4 -0
  38. package/dist/assets/gltf-runtime.js.map +1 -0
  39. package/dist/assets/index.d.ts +0 -4
  40. package/dist/assets/index.d.ts.map +1 -1
  41. package/dist/assets/index.js +0 -2
  42. package/dist/assets/index.js.map +1 -1
  43. package/dist/audio/AudioBus.d.ts +19 -1
  44. package/dist/audio/AudioBus.d.ts.map +1 -1
  45. package/dist/audio/AudioBus.js.map +1 -1
  46. package/dist/audio/AudioFileManager.d.ts +5 -0
  47. package/dist/audio/AudioFileManager.d.ts.map +1 -1
  48. package/dist/audio/AudioFileManager.js +13 -0
  49. package/dist/audio/AudioFileManager.js.map +1 -1
  50. package/dist/audio/AudioSource.d.ts +8 -0
  51. package/dist/audio/AudioSource.d.ts.map +1 -1
  52. package/dist/audio/AudioSource.js +56 -7
  53. package/dist/audio/AudioSource.js.map +1 -1
  54. package/dist/audio/index.d.ts +1 -7
  55. package/dist/audio/index.d.ts.map +1 -1
  56. package/dist/audio/index.js +0 -3
  57. package/dist/audio/index.js.map +1 -1
  58. package/dist/aura3d-cli/asset-core-types.d.ts +1 -1
  59. package/dist/aura3d-cli/asset-core-types.d.ts.map +1 -1
  60. package/dist/aura3d-cli/asset-manifest.d.ts.map +1 -1
  61. package/dist/aura3d-cli/asset-manifest.js +36 -2
  62. package/dist/aura3d-cli/asset-manifest.js.map +1 -1
  63. package/dist/aura3d-cli/asset-role-admission.js +1 -1
  64. package/dist/aura3d-cli/asset-role-admission.js.map +1 -1
  65. package/dist/aura3d-cli/cli-help.js +3 -3
  66. package/dist/aura3d-cli/cli-help.js.map +1 -1
  67. package/dist/aura3d-cli/cli-options.d.ts +1 -1
  68. package/dist/aura3d-cli/cli-options.d.ts.map +1 -1
  69. package/dist/aura3d-cli/cli-options.js +2 -2
  70. package/dist/aura3d-cli/cli-options.js.map +1 -1
  71. package/dist/aura3d-cli/cli.js +1 -1
  72. package/dist/aura3d-cli/cli.js.map +1 -1
  73. package/dist/aura3d-cli/index.d.ts.map +1 -1
  74. package/dist/aura3d-cli/index.js +13 -5
  75. package/dist/aura3d-cli/index.js.map +1 -1
  76. package/dist/create-aura3d/index.d.ts.map +1 -1
  77. package/dist/create-aura3d/index.js +10 -4
  78. package/dist/create-aura3d/index.js.map +1 -1
  79. package/dist/create-aura3d/showcase-spec-evidence.js +5 -1
  80. package/dist/create-aura3d/showcase-spec-evidence.js.map +1 -1
  81. package/dist/create-aura3d/showcase-spec-game-geometry-extractor.d.ts +8 -1
  82. package/dist/create-aura3d/showcase-spec-game-geometry-extractor.d.ts.map +1 -1
  83. package/dist/create-aura3d/showcase-spec-game-geometry-extractor.js +191 -31
  84. package/dist/create-aura3d/showcase-spec-game-geometry-extractor.js.map +1 -1
  85. package/dist/create-aura3d/showcase-spec-game-template-evidence.d.ts.map +1 -1
  86. package/dist/create-aura3d/showcase-spec-game-template-evidence.js +7 -0
  87. package/dist/create-aura3d/showcase-spec-game-template-evidence.js.map +1 -1
  88. package/dist/create-aura3d/showcase-spec-parser.js +1 -1
  89. package/dist/create-aura3d/showcase-spec-parser.js.map +1 -1
  90. package/dist/create-aura3d/showcase-spec-types.d.ts +50 -0
  91. package/dist/create-aura3d/showcase-spec-types.d.ts.map +1 -1
  92. package/dist/editor-runtime/index.d.ts +0 -2
  93. package/dist/editor-runtime/index.d.ts.map +1 -1
  94. package/dist/editor-runtime/index.js +0 -1
  95. package/dist/editor-runtime/index.js.map +1 -1
  96. package/dist/engine/agent-api/GameAssetValidation.js +1 -1
  97. package/dist/engine/agent-api/GameAssetValidation.js.map +1 -1
  98. package/dist/engine/agent-api/GameEvidence.js +1 -1
  99. package/dist/engine/agent-api/GameEvidence.js.map +1 -1
  100. package/dist/engine/agent-api/GameGenreKits.d.ts +63 -0
  101. package/dist/engine/agent-api/GameGenreKits.d.ts.map +1 -1
  102. package/dist/engine/agent-api/GameGenreKits.js +274 -56
  103. package/dist/engine/agent-api/GameGenreKits.js.map +1 -1
  104. package/dist/engine/agent-api/GameRuntime.d.ts +38 -1
  105. package/dist/engine/agent-api/GameRuntime.d.ts.map +1 -1
  106. package/dist/engine/agent-api/GameRuntime.js +69 -1
  107. package/dist/engine/agent-api/GameRuntime.js.map +1 -1
  108. package/dist/engine/agent-api/GameSceneGeometryBindings.d.ts +24 -0
  109. package/dist/engine/agent-api/GameSceneGeometryBindings.d.ts.map +1 -1
  110. package/dist/engine/agent-api/GameSceneGeometryBindings.js +52 -0
  111. package/dist/engine/agent-api/GameSceneGeometryBindings.js.map +1 -1
  112. package/dist/engine/agent-api/LeanArcadeRuntime.d.ts +111 -0
  113. package/dist/engine/agent-api/LeanArcadeRuntime.d.ts.map +1 -0
  114. package/dist/engine/agent-api/LeanArcadeRuntime.js +219 -0
  115. package/dist/engine/agent-api/LeanArcadeRuntime.js.map +1 -0
  116. package/dist/engine/agent-api/PhysicsRuntime.d.ts +332 -0
  117. package/dist/engine/agent-api/PhysicsRuntime.d.ts.map +1 -0
  118. package/dist/engine/agent-api/PhysicsRuntime.js +619 -0
  119. package/dist/engine/agent-api/PhysicsRuntime.js.map +1 -0
  120. package/dist/engine/agent-api/PlatformerMotion.d.ts +124 -0
  121. package/dist/engine/agent-api/PlatformerMotion.d.ts.map +1 -1
  122. package/dist/engine/agent-api/PlatformerMotion.js +101 -16
  123. package/dist/engine/agent-api/PlatformerMotion.js.map +1 -1
  124. package/dist/engine/agent-api/RootGeometry.d.ts +36 -0
  125. package/dist/engine/agent-api/RootGeometry.d.ts.map +1 -0
  126. package/dist/engine/agent-api/RootGeometry.js +152 -0
  127. package/dist/engine/agent-api/RootGeometry.js.map +1 -0
  128. package/dist/engine/agent-api/VehicleChassis.d.ts +52 -0
  129. package/dist/engine/agent-api/VehicleChassis.d.ts.map +1 -1
  130. package/dist/engine/agent-api/VehicleChassis.js +182 -8
  131. package/dist/engine/agent-api/VehicleChassis.js.map +1 -1
  132. package/dist/engine/agent-api/WorldLabelRenderer.d.ts +65 -1
  133. package/dist/engine/agent-api/WorldLabelRenderer.d.ts.map +1 -1
  134. package/dist/engine/agent-api/WorldLabelRenderer.js +48 -5
  135. package/dist/engine/agent-api/WorldLabelRenderer.js.map +1 -1
  136. package/dist/engine/agent-api/humanoid-walk-runtime.d.ts +1 -1
  137. package/dist/engine/agent-api/index.d.ts +206 -13
  138. package/dist/engine/agent-api/index.d.ts.map +1 -1
  139. package/dist/engine/agent-api/index.js +1388 -357
  140. package/dist/engine/agent-api/index.js.map +1 -1
  141. package/dist/engine/agent-api/lean-base.d.ts +224 -0
  142. package/dist/engine/agent-api/lean-base.d.ts.map +1 -0
  143. package/dist/engine/agent-api/lean-base.js +322 -0
  144. package/dist/engine/agent-api/lean-base.js.map +1 -0
  145. package/dist/engine/agent-api/lean-game.d.ts +3 -0
  146. package/dist/engine/agent-api/lean-game.d.ts.map +1 -0
  147. package/dist/engine/agent-api/lean-game.js +3 -0
  148. package/dist/engine/agent-api/lean-game.js.map +1 -0
  149. package/dist/engine/agent-api/lean-product.d.ts +3 -0
  150. package/dist/engine/agent-api/lean-product.d.ts.map +1 -0
  151. package/dist/engine/agent-api/lean-product.js +3 -0
  152. package/dist/engine/agent-api/lean-product.js.map +1 -0
  153. package/dist/engine/agent-api/lean.d.ts +3 -0
  154. package/dist/engine/agent-api/lean.d.ts.map +1 -0
  155. package/dist/engine/agent-api/lean.js +3 -0
  156. package/dist/engine/agent-api/lean.js.map +1 -0
  157. package/dist/engine/agent-api/media-node.d.ts +42 -0
  158. package/dist/engine/agent-api/media-node.d.ts.map +1 -0
  159. package/dist/engine/agent-api/media-node.js +44 -0
  160. package/dist/engine/agent-api/media-node.js.map +1 -0
  161. package/dist/engine/agent-api/product-viewer-runtime.d.ts.map +1 -1
  162. package/dist/engine/game/GameAudio.d.ts +5 -0
  163. package/dist/engine/game/GameAudio.d.ts.map +1 -1
  164. package/dist/engine/game/GameAudio.js +65 -23
  165. package/dist/engine/game/GameAudio.js.map +1 -1
  166. package/dist/engine/production-runtime/TypedGLBActor.d.ts +9 -1
  167. package/dist/engine/production-runtime/TypedGLBActor.d.ts.map +1 -1
  168. package/dist/engine/production-runtime/TypedGLBActor.js +25 -7
  169. package/dist/engine/production-runtime/TypedGLBActor.js.map +1 -1
  170. package/dist/engine/production-runtime/index.d.ts +2 -2
  171. package/dist/engine/production-runtime/index.d.ts.map +1 -1
  172. package/dist/engine/threejs-example-parity/FlagshipFoundation.d.ts +3 -3
  173. package/dist/engine/threejs-example-parity/FlagshipFoundation.d.ts.map +1 -1
  174. package/dist/environments/index.d.ts +5 -10
  175. package/dist/environments/index.d.ts.map +1 -1
  176. package/dist/environments/index.js +0 -3
  177. package/dist/environments/index.js.map +1 -1
  178. package/dist/environments/node.d.ts +7 -0
  179. package/dist/environments/node.d.ts.map +1 -0
  180. package/dist/environments/node.js +5 -0
  181. package/dist/environments/node.js.map +1 -0
  182. package/dist/index.d.ts +3 -0
  183. package/dist/index.js +3 -0
  184. package/dist/input/InputActionBinding.d.ts +44 -0
  185. package/dist/input/InputActionBinding.d.ts.map +1 -0
  186. package/dist/input/InputActionBinding.js +86 -0
  187. package/dist/input/InputActionBinding.js.map +1 -0
  188. package/dist/input/InputActionBindingFixtures.d.ts +8 -0
  189. package/dist/input/InputActionBindingFixtures.d.ts.map +1 -1
  190. package/dist/input/InputActionBindingFixtures.js +8 -0
  191. package/dist/input/InputActionBindingFixtures.js.map +1 -1
  192. package/dist/input/InputValueProcessors.d.ts +19 -0
  193. package/dist/input/InputValueProcessors.d.ts.map +1 -0
  194. package/dist/input/InputValueProcessors.js +24 -0
  195. package/dist/input/InputValueProcessors.js.map +1 -0
  196. package/dist/input/index.d.ts +2 -6
  197. package/dist/input/index.d.ts.map +1 -1
  198. package/dist/input/index.js +1 -3
  199. package/dist/input/index.js.map +1 -1
  200. package/dist/lean/ArcadeRuntime.d.ts +111 -0
  201. package/dist/lean/ArcadeRuntime.d.ts.map +1 -0
  202. package/dist/lean/ArcadeRuntime.js +219 -0
  203. package/dist/lean/ArcadeRuntime.js.map +1 -0
  204. package/dist/lean/base.d.ts +228 -0
  205. package/dist/lean/base.d.ts.map +1 -0
  206. package/dist/lean/base.js +322 -0
  207. package/dist/lean/base.js.map +1 -0
  208. package/dist/lean/game.d.ts +19 -0
  209. package/dist/lean/game.d.ts.map +1 -0
  210. package/dist/lean/game.js +37 -0
  211. package/dist/lean/game.js.map +1 -0
  212. package/dist/lean/index.d.ts +4 -0
  213. package/dist/lean/index.d.ts.map +1 -0
  214. package/dist/lean/index.js +10 -0
  215. package/dist/lean/index.js.map +1 -0
  216. package/dist/lean/product.d.ts +4 -0
  217. package/dist/lean/product.d.ts.map +1 -0
  218. package/dist/lean/product.js +55 -0
  219. package/dist/lean/product.js.map +1 -0
  220. package/dist/materials/index.d.ts +1 -10
  221. package/dist/materials/index.d.ts.map +1 -1
  222. package/dist/materials/index.js +1 -6
  223. package/dist/materials/index.js.map +1 -1
  224. package/dist/materials/node.d.ts +4 -0
  225. package/dist/materials/node.d.ts.map +1 -0
  226. package/dist/materials/node.js +3 -0
  227. package/dist/materials/node.js.map +1 -0
  228. package/dist/physics/ArcadeCharacterController.d.ts +53 -0
  229. package/dist/physics/ArcadeCharacterController.d.ts.map +1 -0
  230. package/dist/physics/ArcadeCharacterController.js +98 -0
  231. package/dist/physics/ArcadeCharacterController.js.map +1 -0
  232. package/dist/physics/ArcadeVehicleTelemetry.d.ts +162 -0
  233. package/dist/physics/ArcadeVehicleTelemetry.d.ts.map +1 -0
  234. package/dist/physics/ArcadeVehicleTelemetry.js +363 -0
  235. package/dist/physics/ArcadeVehicleTelemetry.js.map +1 -0
  236. package/dist/physics/CharacterController.d.ts +52 -0
  237. package/dist/physics/CharacterController.d.ts.map +1 -1
  238. package/dist/physics/CharacterController.js +380 -7
  239. package/dist/physics/CharacterController.js.map +1 -1
  240. package/dist/physics/Constraint.d.ts +20 -4
  241. package/dist/physics/Constraint.d.ts.map +1 -1
  242. package/dist/physics/Constraint.js +34 -60
  243. package/dist/physics/Constraint.js.map +1 -1
  244. package/dist/physics/FightingCharacterController.d.ts +32 -0
  245. package/dist/physics/FightingCharacterController.d.ts.map +1 -0
  246. package/dist/physics/FightingCharacterController.js +76 -0
  247. package/dist/physics/FightingCharacterController.js.map +1 -0
  248. package/dist/physics/MeshBVH.d.ts +72 -0
  249. package/dist/physics/MeshBVH.d.ts.map +1 -0
  250. package/dist/physics/MeshBVH.js +244 -0
  251. package/dist/physics/MeshBVH.js.map +1 -0
  252. package/dist/physics/PathFollowDriver.d.ts +162 -0
  253. package/dist/physics/PathFollowDriver.d.ts.map +1 -0
  254. package/dist/physics/PathFollowDriver.js +163 -0
  255. package/dist/physics/PathFollowDriver.js.map +1 -0
  256. package/dist/physics/PhysicsDebugDraw.d.ts +40 -1
  257. package/dist/physics/PhysicsDebugDraw.d.ts.map +1 -1
  258. package/dist/physics/PhysicsDebugDraw.js +87 -17
  259. package/dist/physics/PhysicsDebugDraw.js.map +1 -1
  260. package/dist/physics/PhysicsWorld.d.ts +37 -18
  261. package/dist/physics/PhysicsWorld.d.ts.map +1 -1
  262. package/dist/physics/PhysicsWorld.js +231 -630
  263. package/dist/physics/PhysicsWorld.js.map +1 -1
  264. package/dist/physics/RacingLineProfile.d.ts +116 -0
  265. package/dist/physics/RacingLineProfile.d.ts.map +1 -0
  266. package/dist/physics/RacingLineProfile.js +181 -0
  267. package/dist/physics/RacingLineProfile.js.map +1 -0
  268. package/dist/physics/Raycast.d.ts +11 -0
  269. package/dist/physics/Raycast.d.ts.map +1 -1
  270. package/dist/physics/Raycast.js +62 -8
  271. package/dist/physics/Raycast.js.map +1 -1
  272. package/dist/physics/RigidBody.d.ts +16 -9
  273. package/dist/physics/RigidBody.d.ts.map +1 -1
  274. package/dist/physics/RigidBody.js +23 -75
  275. package/dist/physics/RigidBody.js.map +1 -1
  276. package/dist/physics/SurfaceQuery.d.ts +72 -0
  277. package/dist/physics/SurfaceQuery.d.ts.map +1 -0
  278. package/dist/physics/SurfaceQuery.js +89 -0
  279. package/dist/physics/SurfaceQuery.js.map +1 -0
  280. package/dist/physics/VehicleDynamics.d.ts +10 -0
  281. package/dist/physics/VehicleDynamics.d.ts.map +1 -1
  282. package/dist/physics/VehicleDynamics.js +31 -3
  283. package/dist/physics/VehicleDynamics.js.map +1 -1
  284. package/dist/physics/VehicleMotion.d.ts +125 -0
  285. package/dist/physics/VehicleMotion.d.ts.map +1 -0
  286. package/dist/physics/VehicleMotion.js +373 -0
  287. package/dist/physics/VehicleMotion.js.map +1 -0
  288. package/dist/physics/index.d.ts +62 -17
  289. package/dist/physics/index.d.ts.map +1 -1
  290. package/dist/physics/index.js +70 -17
  291. package/dist/physics/index.js.map +1 -1
  292. package/dist/physics/solverless-entry.d.ts +41 -0
  293. package/dist/physics/solverless-entry.d.ts.map +1 -0
  294. package/dist/physics/solverless-entry.js +40 -0
  295. package/dist/physics/solverless-entry.js.map +1 -0
  296. package/dist/physics/solverless.d.ts +37 -0
  297. package/dist/physics/solverless.d.ts.map +1 -0
  298. package/dist/physics/solverless.js +36 -0
  299. package/dist/physics/solverless.js.map +1 -0
  300. package/dist/physics/world-entry.d.ts +31 -0
  301. package/dist/physics/world-entry.d.ts.map +1 -0
  302. package/dist/physics/world-entry.js +30 -0
  303. package/dist/physics/world-entry.js.map +1 -0
  304. package/dist/physics/world.d.ts +18 -0
  305. package/dist/physics/world.d.ts.map +1 -0
  306. package/dist/physics/world.js +17 -0
  307. package/dist/physics/world.js.map +1 -0
  308. package/dist/physics-rapier/index.d.ts +169 -0
  309. package/dist/physics-rapier/index.d.ts.map +1 -0
  310. package/dist/physics-rapier/index.js +311 -0
  311. package/dist/physics-rapier/index.js.map +1 -0
  312. package/dist/product-studio/ProductLighting.d.ts.map +1 -1
  313. package/dist/product-studio/ProductLighting.js +34 -3
  314. package/dist/product-studio/ProductLighting.js.map +1 -1
  315. package/dist/rendering/ArchitecturalLighting.d.ts +35 -0
  316. package/dist/rendering/ArchitecturalLighting.d.ts.map +1 -0
  317. package/dist/rendering/ArchitecturalLighting.js +162 -0
  318. package/dist/rendering/ArchitecturalLighting.js.map +1 -0
  319. package/dist/rendering/ArchitecturalMaterialCatalog.d.ts +1 -1
  320. package/dist/rendering/ArchitecturalMaterialCatalog.d.ts.map +1 -1
  321. package/dist/rendering/ArchitecturalMaterialCatalog.js +1 -1
  322. package/dist/rendering/ArchitecturalMaterialCatalog.js.map +1 -1
  323. package/dist/rendering/ArchitecturalMeasurement.d.ts +27 -0
  324. package/dist/rendering/ArchitecturalMeasurement.d.ts.map +1 -0
  325. package/dist/rendering/ArchitecturalMeasurement.js +112 -0
  326. package/dist/rendering/ArchitecturalMeasurement.js.map +1 -0
  327. package/dist/rendering/CanonicalProductScene.d.ts +13 -0
  328. package/dist/rendering/CanonicalProductScene.d.ts.map +1 -0
  329. package/dist/rendering/CanonicalProductScene.js +78 -0
  330. package/dist/rendering/CanonicalProductScene.js.map +1 -0
  331. package/dist/rendering/CascadedShadowMaps.d.ts +1 -1
  332. package/dist/rendering/CascadedShadowMaps.d.ts.map +1 -1
  333. package/dist/rendering/CascadedShadowMaps.js +1 -1
  334. package/dist/rendering/CascadedShadowMaps.js.map +1 -1
  335. package/dist/rendering/DepthPass.d.ts +1 -1
  336. package/dist/rendering/DepthPass.d.ts.map +1 -1
  337. package/dist/rendering/DepthPass.js +2 -2
  338. package/dist/rendering/DepthPass.js.map +1 -1
  339. package/dist/rendering/EnvironmentBackgroundPass.d.ts +1 -1
  340. package/dist/rendering/EnvironmentBackgroundPass.d.ts.map +1 -1
  341. package/dist/rendering/EnvironmentBackgroundPass.js +2 -2
  342. package/dist/rendering/EnvironmentBackgroundPass.js.map +1 -1
  343. package/dist/rendering/EnvironmentPlatform.js +2 -2
  344. package/dist/rendering/EnvironmentPlatform.js.map +1 -1
  345. package/dist/rendering/ForwardPass.d.ts +1 -1
  346. package/dist/rendering/ForwardPass.d.ts.map +1 -1
  347. package/dist/rendering/ForwardPass.js +24 -10
  348. package/dist/rendering/ForwardPass.js.map +1 -1
  349. package/dist/rendering/Geometry.d.ts +2 -0
  350. package/dist/rendering/Geometry.d.ts.map +1 -1
  351. package/dist/rendering/Geometry.js +15 -0
  352. package/dist/rendering/Geometry.js.map +1 -1
  353. package/dist/rendering/InstancedPBRMaterial.js +1 -1
  354. package/dist/rendering/InstancedPBRMaterial.js.map +1 -1
  355. package/dist/rendering/InstancedUnlitMaterial.js +1 -1
  356. package/dist/rendering/InstancedUnlitMaterial.js.map +1 -1
  357. package/dist/rendering/Material.js +2 -2
  358. package/dist/rendering/Material.js.map +1 -1
  359. package/dist/rendering/MaterialBinding.js +4 -4
  360. package/dist/rendering/MaterialBinding.js.map +1 -1
  361. package/dist/rendering/MaterialInstance.js +2 -2
  362. package/dist/rendering/MaterialInstance.js.map +1 -1
  363. package/dist/rendering/MorphUnlitMaterial.js +1 -1
  364. package/dist/rendering/MorphUnlitMaterial.js.map +1 -1
  365. package/dist/rendering/NormalMappedPBRMaterial.js +1 -1
  366. package/dist/rendering/NormalMappedPBRMaterial.js.map +1 -1
  367. package/dist/rendering/OceanSurface.d.ts +62 -0
  368. package/dist/rendering/OceanSurface.d.ts.map +1 -0
  369. package/dist/rendering/OceanSurface.js +240 -0
  370. package/dist/rendering/OceanSurface.js.map +1 -0
  371. package/dist/rendering/PBRMaterial.d.ts.map +1 -1
  372. package/dist/rendering/PBRMaterial.js +3 -3
  373. package/dist/rendering/PBRMaterial.js.map +1 -1
  374. package/dist/rendering/PortableShaderMaterial.d.ts +60 -0
  375. package/dist/rendering/PortableShaderMaterial.d.ts.map +1 -0
  376. package/dist/rendering/PortableShaderMaterial.js +223 -0
  377. package/dist/rendering/PortableShaderMaterial.js.map +1 -0
  378. package/dist/rendering/PostProcessPass.d.ts.map +1 -1
  379. package/dist/rendering/PostProcessPass.js +30 -6
  380. package/dist/rendering/PostProcessPass.js.map +1 -1
  381. package/dist/rendering/ProceduralTexture.d.ts +27 -0
  382. package/dist/rendering/ProceduralTexture.d.ts.map +1 -0
  383. package/dist/rendering/ProceduralTexture.js +336 -0
  384. package/dist/rendering/ProceduralTexture.js.map +1 -0
  385. package/dist/rendering/ProductTurntable.d.ts +96 -0
  386. package/dist/rendering/ProductTurntable.d.ts.map +1 -0
  387. package/dist/rendering/ProductTurntable.js +639 -0
  388. package/dist/rendering/ProductTurntable.js.map +1 -0
  389. package/dist/rendering/RenderDevice.d.ts +30 -0
  390. package/dist/rendering/RenderDevice.d.ts.map +1 -1
  391. package/dist/rendering/RenderDevice.js.map +1 -1
  392. package/dist/rendering/Renderer.d.ts +3 -2
  393. package/dist/rendering/Renderer.d.ts.map +1 -1
  394. package/dist/rendering/Renderer.js +23 -9
  395. package/dist/rendering/Renderer.js.map +1 -1
  396. package/dist/rendering/RendererPostprocessPlan.js +4 -4
  397. package/dist/rendering/RendererPostprocessPlan.js.map +1 -1
  398. package/dist/rendering/ScreenSpaceLineMaterial.js +1 -1
  399. package/dist/rendering/ScreenSpaceLineMaterial.js.map +1 -1
  400. package/dist/rendering/ShaderChunks.d.ts.map +1 -1
  401. package/dist/rendering/ShaderChunks.js +81 -19
  402. package/dist/rendering/ShaderChunks.js.map +1 -1
  403. package/dist/rendering/ShaderLibrary.d.ts +2 -78
  404. package/dist/rendering/ShaderLibrary.d.ts.map +1 -1
  405. package/dist/rendering/ShaderLibrary.js +127 -793
  406. package/dist/rendering/ShaderLibrary.js.map +1 -1
  407. package/dist/rendering/ShaderLibraryCore.d.ts +100 -0
  408. package/dist/rendering/ShaderLibraryCore.d.ts.map +1 -0
  409. package/dist/rendering/ShaderLibraryCore.js +800 -0
  410. package/dist/rendering/ShaderLibraryCore.js.map +1 -0
  411. package/dist/rendering/ShaderModule.d.ts +1 -1
  412. package/dist/rendering/ShaderModule.d.ts.map +1 -1
  413. package/dist/rendering/ShaderModule.js +4 -2
  414. package/dist/rendering/ShaderModule.js.map +1 -1
  415. package/dist/rendering/ShadowPass.d.ts +1 -1
  416. package/dist/rendering/ShadowPass.d.ts.map +1 -1
  417. package/dist/rendering/ShadowPass.js +6 -4
  418. package/dist/rendering/ShadowPass.js.map +1 -1
  419. package/dist/rendering/SkinnedLitMaterial.js +1 -1
  420. package/dist/rendering/SkinnedLitMaterial.js.map +1 -1
  421. package/dist/rendering/SkinnedUnlitMaterial.js +1 -1
  422. package/dist/rendering/SkinnedUnlitMaterial.js.map +1 -1
  423. package/dist/rendering/SpaceEnvironment.d.ts +54 -0
  424. package/dist/rendering/SpaceEnvironment.d.ts.map +1 -0
  425. package/dist/rendering/SpaceEnvironment.js +116 -0
  426. package/dist/rendering/SpaceEnvironment.js.map +1 -0
  427. package/dist/rendering/TerrainHeightfield.d.ts +60 -0
  428. package/dist/rendering/TerrainHeightfield.d.ts.map +1 -0
  429. package/dist/rendering/TerrainHeightfield.js +239 -0
  430. package/dist/rendering/TerrainHeightfield.js.map +1 -0
  431. package/dist/rendering/TextureBinding.d.ts +14 -0
  432. package/dist/rendering/TextureBinding.d.ts.map +1 -1
  433. package/dist/rendering/TextureBinding.js +17 -0
  434. package/dist/rendering/TextureBinding.js.map +1 -1
  435. package/dist/rendering/TexturedPBRMaterial.js +1 -1
  436. package/dist/rendering/TexturedPBRMaterial.js.map +1 -1
  437. package/dist/rendering/TexturedUnlitMaterial.js +1 -1
  438. package/dist/rendering/TexturedUnlitMaterial.js.map +1 -1
  439. package/dist/rendering/UnlitMaterial.js +1 -1
  440. package/dist/rendering/UnlitMaterial.js.map +1 -1
  441. package/dist/rendering/VegetationFixtures.d.ts +1 -1
  442. package/dist/rendering/VegetationFixtures.d.ts.map +1 -1
  443. package/dist/rendering/VegetationFixtures.js +1 -1
  444. package/dist/rendering/VegetationFixtures.js.map +1 -1
  445. package/dist/rendering/VegetationScatter.d.ts +68 -0
  446. package/dist/rendering/VegetationScatter.d.ts.map +1 -0
  447. package/dist/rendering/VegetationScatter.js +241 -0
  448. package/dist/rendering/VegetationScatter.js.map +1 -0
  449. package/dist/rendering/VoxelWorld.d.ts +51 -0
  450. package/dist/rendering/VoxelWorld.d.ts.map +1 -0
  451. package/dist/rendering/VoxelWorld.js +183 -0
  452. package/dist/rendering/VoxelWorld.js.map +1 -0
  453. package/dist/rendering/Weather.d.ts +44 -0
  454. package/dist/rendering/Weather.d.ts.map +1 -0
  455. package/dist/rendering/Weather.js +140 -0
  456. package/dist/rendering/Weather.js.map +1 -0
  457. package/dist/rendering/WebGL2Device.d.ts +22 -0
  458. package/dist/rendering/WebGL2Device.d.ts.map +1 -1
  459. package/dist/rendering/WebGL2Device.js +134 -23
  460. package/dist/rendering/WebGL2Device.js.map +1 -1
  461. package/dist/rendering/WebGPUDevice.d.ts +19 -2
  462. package/dist/rendering/WebGPUDevice.d.ts.map +1 -1
  463. package/dist/rendering/WebGPUDevice.js +188 -9
  464. package/dist/rendering/WebGPUDevice.js.map +1 -1
  465. package/dist/rendering/index.d.ts +25 -32
  466. package/dist/rendering/index.d.ts.map +1 -1
  467. package/dist/rendering/index.js +35 -19
  468. package/dist/rendering/index.js.map +1 -1
  469. package/dist/rendering/lean/LeanProductRenderer.d.ts +29 -0
  470. package/dist/rendering/lean/LeanProductRenderer.d.ts.map +1 -0
  471. package/dist/rendering/lean/LeanProductRenderer.js +38 -0
  472. package/dist/rendering/lean/LeanProductRenderer.js.map +1 -0
  473. package/dist/rendering/lean/LeanProductionRenderer.d.ts +38 -0
  474. package/dist/rendering/lean/LeanProductionRenderer.d.ts.map +1 -0
  475. package/dist/rendering/lean/LeanProductionRenderer.js +88 -0
  476. package/dist/rendering/lean/LeanProductionRenderer.js.map +1 -0
  477. package/dist/rendering/lean-runtime.d.ts +18 -0
  478. package/dist/rendering/lean-runtime.d.ts.map +1 -0
  479. package/dist/rendering/lean-runtime.js +13 -0
  480. package/dist/rendering/lean-runtime.js.map +1 -0
  481. package/dist/rendering/postprocess/EffectComposer.d.ts.map +1 -1
  482. package/dist/rendering/postprocess/EffectComposer.js +1 -3
  483. package/dist/rendering/postprocess/EffectComposer.js.map +1 -1
  484. package/dist/rendering/production-runtime/PBRHDRPipeline.d.ts.map +1 -1
  485. package/dist/rendering/production-runtime/PBRHDRPipeline.js +5 -1
  486. package/dist/rendering/production-runtime/PBRHDRPipeline.js.map +1 -1
  487. package/dist/rendering/production-runtime/ProductionRendererTypes.d.ts +9 -0
  488. package/dist/rendering/production-runtime/ProductionRendererTypes.d.ts.map +1 -1
  489. package/dist/rendering/production-runtime/ProductionRendererTypes.js.map +1 -1
  490. package/dist/rendering/production-runtime/ProductionRuntimeRenderer.d.ts +5 -0
  491. package/dist/rendering/production-runtime/ProductionRuntimeRenderer.d.ts.map +1 -1
  492. package/dist/rendering/production-runtime/ProductionRuntimeRenderer.js +53 -5
  493. package/dist/rendering/production-runtime/ProductionRuntimeRenderer.js.map +1 -1
  494. package/dist/rendering/production-runtime/ProductionWebGL2Renderer.d.ts +10 -0
  495. package/dist/rendering/production-runtime/ProductionWebGL2Renderer.d.ts.map +1 -1
  496. package/dist/rendering/production-runtime/ProductionWebGL2Renderer.js +21 -2
  497. package/dist/rendering/production-runtime/ProductionWebGL2Renderer.js.map +1 -1
  498. package/dist/rendering/production-runtime/ProductionWebGPURenderer.d.ts.map +1 -1
  499. package/dist/rendering/production-runtime/ProductionWebGPURenderer.js +4 -1
  500. package/dist/rendering/production-runtime/ProductionWebGPURenderer.js.map +1 -1
  501. package/dist/rendering/webgpu-device-entry.d.ts +25 -0
  502. package/dist/rendering/webgpu-device-entry.d.ts.map +1 -0
  503. package/dist/rendering/webgpu-device-entry.js +24 -0
  504. package/dist/rendering/webgpu-device-entry.js.map +1 -0
  505. package/dist/rendering/webgpu.d.ts +25 -0
  506. package/dist/rendering/webgpu.d.ts.map +1 -0
  507. package/dist/rendering/webgpu.js +24 -0
  508. package/dist/rendering/webgpu.js.map +1 -0
  509. package/dist/scene/MathTypes.d.ts +1 -0
  510. package/dist/scene/MathTypes.d.ts.map +1 -1
  511. package/dist/scene/MathTypes.js +3 -0
  512. package/dist/scene/MathTypes.js.map +1 -1
  513. package/dist/scene/Renderable.d.ts +6 -0
  514. package/dist/scene/Renderable.d.ts.map +1 -1
  515. package/dist/scene/Renderable.js +8 -2
  516. package/dist/scene/Renderable.js.map +1 -1
  517. package/dist/scripting/index.d.ts +0 -16
  518. package/dist/scripting/index.d.ts.map +1 -1
  519. package/dist/scripting/index.js +0 -8
  520. package/dist/scripting/index.js.map +1 -1
  521. package/dist/workflows/production-runtime/ProductionExampleRuntime.d.ts +2 -2
  522. package/dist/workflows/production-runtime/ProductionExampleRuntime.d.ts.map +1 -1
  523. package/dist/workflows/production-runtime/ProductionExampleRuntime.js +2 -2
  524. package/dist/workflows/production-runtime/ProductionExampleRuntime.js.map +1 -1
  525. package/package.json +157 -40
  526. package/templates/cinematic-scene/node_modules/.vite/deps/_metadata.json +8 -0
  527. package/templates/cinematic-scene/node_modules/.vite/deps/package.json +3 -0
  528. package/templates/cinematic-scene/package.json +1 -1
  529. package/templates/cinematic-scene/test-results/.last-run.json +6 -0
  530. package/templates/cinematic-scene/test-results/route-health-Aura3D-cinematic-scene-reaches-ready-state/error-context.md +64 -0
  531. package/templates/mini-game/README.md +3 -2
  532. package/templates/mini-game/package.json +1 -1
  533. package/templates/mini-game/src/aura-assets.ts +1 -1
  534. package/templates/mini-game/src/main.ts +69 -141
  535. package/templates/product-viewer/README.md +7 -5
  536. package/templates/product-viewer/index.html +1 -1
  537. package/templates/product-viewer/node_modules/.vite/deps/_metadata.json +8 -0
  538. package/templates/product-viewer/node_modules/.vite/deps/package.json +3 -0
  539. package/templates/product-viewer/package.json +1 -1
  540. package/templates/product-viewer/src/aura-assets.ts +1 -1
  541. package/templates/product-viewer/src/main.ts +29 -23
@@ -1,10 +1,54 @@
1
- import { PhysicsDebugDraw, PhysicsStepper, PhysicsWorld, ScenePhysicsBridge, Shape as PhysicsShapeFactory } from "../../physics/index.js";
2
- import { createExternalParityEnvironmentLighting, Geometry, IndexBuffer, PBRMaterial, ProductionRuntimeRenderer, VertexBuffer, VertexFormat } from "../../rendering/index.js";
1
+ /*
2
+ * WS-2.2 — module-scope physics values come from the SOLVERLESS entry.
3
+ *
4
+ * `Shape`, `PhysicsStepper`, `ScenePhysicsBridge` and `PhysicsDebugDraw` are solver-free: each
5
+ * imports `PhysicsWorld` as a *type* only. But reaching them through `@aura3d/physics` — a chain of
6
+ * `export *` that includes `PhysicsWorld` — dragged in the physical adapter, so a scene with no
7
+ * bodies still downloaded the solver.
8
+ *
9
+ * This is a different defect from eager construction, which was already fixed (see `:9832`). A lazy
10
+ * `new PhysicsWorld()` still leaves a static `import`, and a bundler keeps the module either way.
11
+ * Deferring the *import* is what removes the bytes. `PhysicsWorld` is now loaded by
12
+ * `await import("../../physics/index.js")` at the points that actually simulate.
13
+ */
14
+ import { PhysicsDebugDraw, PhysicsStepper, ScenePhysicsBridge, Shape as PhysicsShapeFactory } from "../../physics/solverless.js";
15
+ /*
16
+ * WS-2.2 — the solver, from a NARROW entry rather than the physics barrel.
17
+ *
18
+ * Static on purpose. `app.physics` is documented and tested as live synchronously for every app, so
19
+ * `createAuraApp` cannot await a solver, and R7 forbids breaking that to save bytes. What this does
20
+ * avoid is the barrel: `@aura3d/physics` is a chain of `export *` that also carries `HitboxWorld`,
21
+ * `CharacterController`, `KinematicBody`, arcade vehicle telemetry, `NarrowPhase` and six fixture modules, all
22
+ * of which arrived just to reach one class.
23
+ */
24
+ import { PhysicsWorld } from "../../physics/world.js";
25
+ import { createExternalParityEnvironmentLighting, Geometry, IndexBuffer, InstancedPBRMaterial, PBRMaterial, ProductionRuntimeRenderer, srgbToLinearChannel, VertexBuffer, VertexFormat } from "../../rendering/index.js";
3
26
  import { DirectionalLight, PointLight, SpotLight } from "../../scene/index.js";
4
- import { createTypedGLBActor } from "../production-runtime/TypedGLBActor.js";
27
+ import { lookAtMat4, multiplyMat4, orthographicMat4, perspectiveMat4 } from "../../scene/MathTypes.js";
5
28
  import { AURA_NORMALIZED_MODEL_MAX_DIMENSION, boundsFromAsset, boundsHeight, boundsMaxDimension, boundsSize } from "./SceneGroundingUtils.js";
6
29
  import { createWorldLabelLayer } from "./WorldLabelRenderer.js";
30
+ import { createAuraText3DGeometry, defineAuraCustomGeometry, selectAuraRootLodLevel } from "./RootGeometry.js";
31
+ export * from "./RootGeometry.js";
7
32
  export * from "./SpatialAnchoring.js";
33
+ export * from "./PhysicsRuntime.js";
34
+ /**
35
+ * WS-2.4: mesh surface queries on the public surface.
36
+ *
37
+ * Re-exported from `@aura3d/physics` so grounding anything to a mesh is a one-liner for any
38
+ * genre, from `@aura3d/engine` alone. Without this a route wanting real per-point ground
39
+ * height had to either deep-import `@aura3d/physics/src` (banned by the lint rule) or
40
+ * hand-roll an analytic approximation — which is exactly what every racing route did, and
41
+ * why wheels sank through visible road.
42
+ */
43
+ /*
44
+ * WS-2.2 — re-exported from the SOLVERLESS entry, not the physics barrel.
45
+ *
46
+ * These are geometry queries: `MeshBVH` and `SurfaceQuery` import only `Shape`'s types. Re-exporting
47
+ * them from `@aura3d/physics` made this barrel a static consumer of the whole solver, so grounding a
48
+ * wheel on a mesh reached the physical solver. Same symbols, same public names.
49
+ */
50
+ export { buildMeshBVH, createMeshSurfaceQuery, raycastMesh } from "../../physics/solverless.js";
51
+ import { createPhysicsRuntime } from "./PhysicsRuntime.js";
8
52
  export * from "./FocusSelection.js";
9
53
  export * from "./WorldLabelRenderer.js";
10
54
  export * from "./VehicleChassis.js";
@@ -17,14 +61,23 @@ export * from "./FrameEncoder.js";
17
61
  export * from "./BrowserFrameCaptureAdapter.js";
18
62
  export * from "./MediaRecorderFrameEncoder.js";
19
63
  export * from "./WebCodecsFrameEncoder.js";
20
- export * from "./FfmpegFrameEncoder.js";
21
- export * from "./PngSequenceEncoder.js";
22
- export * from "./AudioMuxer.js";
64
+ /*
65
+ * WS-2.3 — `FfmpegFrameEncoder` is deliberately NOT re-exported here.
66
+ *
67
+ * It is the only file in the 37-file media surface that reaches `node:` builtins (`child_process` to
68
+ * spawn ffmpeg; `fs/promises`/`os`/`path` to stage frames). Re-exporting it put Node builtins in every
69
+ * browser bundle of this entry point, which is why `tools/bundle-size` had to mark four `node:`
70
+ * specifiers external "for every browser bundle measurement" — a workaround for a dependency that
71
+ * should not have been in the browser graph. esbuild resolves `await import()` at build time whether or
72
+ * not the branch can run.
73
+ *
74
+ * Node consumers import `@aura3d/engine/media-node`. Enforced by `tools/browser-entry-purity`, which
75
+ * bundles every browser entry with no `node:` externals so a reachable builtin fails the build.
76
+ */
23
77
  export { GameInspector, createGameInspector } from "./GameInspector.js";
24
78
  import { createGameInspector } from "./GameInspector.js";
25
79
  export { createGameAudio } from "../game/GameAudio.js";
26
80
  export * from "./RenderProgressTracker.js";
27
- export * from "./VideoExportPipeline.js";
28
81
  export * from "./AudioVisemeAnalyzer.js";
29
82
  export * from "./ExternalPhonemeAnalyzer.js";
30
83
  export * from "./WaveformVisualizer.js";
@@ -36,12 +89,7 @@ export * from "./CameraPresetLibrary.js";
36
89
  export * from "./ShotCompositionRules.js";
37
90
  export * from "./CameraChoreographer.js";
38
91
  export * from "./CameraPathEditor.js";
39
- export * from "./CaptionExporter.js";
40
92
  export * from "./ThumbnailGenerator.js";
41
- export * from "./YouTubeMetadataGenerator.js";
42
- export * from "./YouTubeUploadAdapter.js";
43
- export * from "./PublishingPipeline.js";
44
- export * from "./CloudRenderAdapter.js";
45
93
  export * from "./BatchEpisodeRenderer.js";
46
94
  export * from "./AnimationAssetManifest.js";
47
95
  export * from "./SceneGroundingUtils.js";
@@ -188,7 +236,8 @@ export function model(asset, options = {}) {
188
236
  targetHeight: options.targetHeight,
189
237
  targetMaxDimension: options.targetMaxDimension,
190
238
  targetLength: options.targetLength,
191
- physics: options.physics
239
+ physics: options.physics,
240
+ hiddenNodeNames: options.hiddenNodeNames
192
241
  });
193
242
  }
194
243
  export function unsafeModelUrl(url, options = {}) {
@@ -235,7 +284,12 @@ function primitive(primitiveName, options = {}) {
235
284
  size: options.size,
236
285
  castShadow: options.castShadow ?? (primitiveName !== "plane" && options.material?.emissive === undefined),
237
286
  receiveShadow: options.receiveShadow ?? true,
238
- physics: options.physics
287
+ physics: options.physics,
288
+ instances: options.instances,
289
+ instanceColors: options.instanceColors,
290
+ geometry: options.geometry,
291
+ text3D: options.text3D,
292
+ lod: options.lod
239
293
  });
240
294
  }
241
295
  function sceneNodeFromInput(node) {
@@ -262,6 +316,43 @@ export const primitives = {
262
316
  capsule: (options) => primitive("capsule", options),
263
317
  torus: (options) => primitive("torus", options)
264
318
  };
319
+ function instancedPrimitive(primitiveName, options) {
320
+ if (options.transforms.length === 0)
321
+ throw new Error("Aura3D instancing requires at least one transform.");
322
+ if (options.colors && options.colors.length !== options.transforms.length)
323
+ throw new Error("Aura3D instance color count must match transform count.");
324
+ return primitive(primitiveName, { ...options, instances: options.transforms, instanceColors: options.colors });
325
+ }
326
+ export const instances = {
327
+ box: (options) => instancedPrimitive("box", options),
328
+ sphere: (options) => instancedPrimitive("sphere", options),
329
+ plane: (options) => instancedPrimitive("plane", options),
330
+ cylinder: (options) => instancedPrimitive("cylinder", options),
331
+ capsule: (options) => instancedPrimitive("capsule", options),
332
+ torus: (options) => instancedPrimitive("torus", options)
333
+ };
334
+ export const geometry = {
335
+ define: defineAuraCustomGeometry,
336
+ custom: (spec, options = {}) => primitive("custom", { ...options, geometry: defineAuraCustomGeometry(spec) })
337
+ };
338
+ export function text3D(text, options = {}) {
339
+ const built = createAuraText3DGeometry(text, options);
340
+ return primitive("custom", { ...options, geometry: built.geometry, text3D: { text: built.text, glyphCount: built.glyphCount, unsupportedCharacters: built.unsupportedCharacters, method: built.method } });
341
+ }
342
+ export function distanceLod(options) {
343
+ if (options.levels.length === 0)
344
+ throw new Error("Aura3D distance LOD requires at least one level.");
345
+ selectAuraRootLodLevel(0, options.levels, undefined, options.hysteresis ?? 0);
346
+ const first = options.levels[0];
347
+ if (!first.primitive && !first.geometry)
348
+ throw new Error("Every Aura3D LOD level requires a primitive or custom geometry.");
349
+ return primitive(first.geometry ? "custom" : first.primitive, {
350
+ ...options,
351
+ geometry: first.geometry,
352
+ material: first.material ?? options.material,
353
+ lod: { levels: options.levels, hysteresis: options.hysteresis }
354
+ });
355
+ }
265
356
  export const groups = {
266
357
  create: group,
267
358
  flatten: (nodes) => flattenSceneNodes(nodes)
@@ -1606,7 +1697,7 @@ const rendererQualityProfiles = {
1606
1697
  kind: "aura-renderer-quality-profile",
1607
1698
  id: "safe-basic",
1608
1699
  label: "Safe Basic",
1609
- rendererMode: "safe-basic",
1700
+ rendererMode: "production",
1610
1701
  status: "supported",
1611
1702
  antialiasing: "msaa",
1612
1703
  pixelRatio: 1,
@@ -1615,7 +1706,7 @@ const rendererQualityProfiles = {
1615
1706
  requestedFeatures: ["typed models", "primitives", "basic particles", "base-color textures", "runtime nodes"],
1616
1707
  supportedInRoot: ["typed models", "primitives", "basic particles", "base-color textures", "runtime nodes"],
1617
1708
  blockedInRoot: ["production PBR parity", "skinned animation mixer", "native WebGPU compute", "postprocess pass chain"],
1618
- claimBoundary: "Root createAuraApp safe path. Use this for public examples unless a browser test proves a stronger profile."
1709
+ claimBoundary: "Root createAuraApp uses the production renderer with the conservative safe-basic feature profile. Claims still require route-specific browser evidence."
1619
1710
  },
1620
1711
  production: {
1621
1712
  kind: "aura-renderer-quality-profile",
@@ -1684,8 +1775,6 @@ function analyzeProductionBridgeEligibility(nodes) {
1684
1775
  const primitiveCount = nodes.filter((node) => node.kind === "primitive").length;
1685
1776
  const effectCount = nodes.filter((node) => node.kind === "effect").length;
1686
1777
  const reasons = [];
1687
- if (typedModelCount < 1)
1688
- reasons.push("production bridge requires at least one typed GLB from generated aura-assets");
1689
1778
  if (unsafeModelCount > 0)
1690
1779
  reasons.push("unsafeModelUrl and remote/raw model URLs are blocked from the production bridge");
1691
1780
  if (inlineModelCount > 0)
@@ -1748,10 +1837,10 @@ function createRendererDiagnosticReport(snapshot, runtime, rendererOptions) {
1748
1837
  if (postprocessRequested && runtime?.mounted && !runtimePostprocess?.pixelBacked)
1749
1838
  warnings.push("postprocess was requested but the runtime composer did not initialize a pixel-backed pass");
1750
1839
  if (rendererSelection.mode === "production" && !runtime?.mounted && productionEligibility.eligible) {
1751
- warnings.push(`Renderer profile "${rendererSelection.profile.id}" can use the typed-GLB production bridge after createAuraApp mounts; inspect app.diagnostics().renderer.runtime for pixel-backed status.`);
1840
+ warnings.push(`Renderer profile "${rendererSelection.profile.id}" will use the production renderer after createAuraApp mounts; inspect app.diagnostics().renderer.runtime for pixel-backed status.`);
1752
1841
  }
1753
1842
  if (rendererSelection.mode === "production" && !productionEligibility.eligible) {
1754
- warnings.push(`Renderer profile "${rendererSelection.profile.id}" will use safe-basic fallback for this scene: ${productionEligibility.reasons.join("; ")}.`);
1843
+ warnings.push(`Renderer profile "${rendererSelection.profile.id}" cannot mount this scene on the production renderer: ${productionEligibility.reasons.join("; ")}.`);
1755
1844
  }
1756
1845
  if (rendererSelection.profile.status === "fallback-only") {
1757
1846
  warnings.push(`Renderer profile "${rendererSelection.profile.id}" is a request profile; public claims require route-specific runtime diagnostics and screenshot proof.`);
@@ -1837,6 +1926,7 @@ function createRendererDiagnosticReport(snapshot, runtime, rendererOptions) {
1837
1926
  requestedPasses,
1838
1927
  actualPasses: runtimePostprocess?.actualPasses ?? [],
1839
1928
  fallbackPasses: runtimePostprocess?.fallbackPasses ?? [],
1929
+ targetFormat: runtimePostprocess?.targetFormat,
1840
1930
  evidence: postprocessEvidence
1841
1931
  },
1842
1932
  runtime: {
@@ -1844,7 +1934,13 @@ function createRendererDiagnosticReport(snapshot, runtime, rendererOptions) {
1844
1934
  backend: runtime?.backend ?? "scene-plan",
1845
1935
  postprocessVerified: runtimePostprocess?.pixelBacked ?? false,
1846
1936
  passNames: runtimePostprocess?.actualPasses ?? [],
1847
- warnings: runtime?.warnings ?? []
1937
+ warnings: runtime?.warnings ?? [],
1938
+ nativeInstancedSubmissions: runtime?.deviceDiagnostics?.nativeInstancedSubmissions ?? 0,
1939
+ submittedObjects: runtime?.deviceDiagnostics?.submittedObjects ?? 0,
1940
+ visibleObjects: runtime?.deviceDiagnostics?.visibleObjects ?? 0,
1941
+ culledObjects: runtime?.deviceDiagnostics?.culledObjects ?? 0,
1942
+ frustumTestedObjects: runtime?.deviceDiagnostics?.frustumTestedObjects ?? 0,
1943
+ lodSelections: runtime?.lodSelections ?? []
1848
1944
  },
1849
1945
  environment: {
1850
1946
  enabled: environmentStatus.enabled,
@@ -2254,6 +2350,7 @@ function createRuntimeScenePhysics(snapshot) {
2254
2350
  const physicsNodes = snapshot.nodes.filter((node) => (node.kind === "model" || node.kind === "primitive") && Boolean(node.physics));
2255
2351
  if (physicsNodes.length === 0)
2256
2352
  return undefined;
2353
+ // WS-2.2: solver resolved through the on-demand cache rather than a static import.
2257
2354
  const world = new PhysicsWorld({ gravity: [0, -9.81, 0], fixedDelta: 1 / 60, enableSleeping: true });
2258
2355
  const stepper = new PhysicsStepper(world.fixedDelta);
2259
2356
  const debugDraw = new PhysicsDebugDraw();
@@ -2654,6 +2751,8 @@ export const prefabs = {
2654
2751
  ...makeCityCrosswalk("zebra crosswalk west", -0.72, 0, "eastWest"),
2655
2752
  ...makeCityCrosswalk("zebra crosswalk east", 0.72, 0, "eastWest")
2656
2753
  ];
2754
+ const cityTowerTransforms = [];
2755
+ const cityTowerColors = [];
2657
2756
  const xSlots = [-4.25, -2.58, -0.95, 1.45, 3.3];
2658
2757
  const zSlots = [-4, -1.45, 1.3, 3.65];
2659
2758
  for (let index = 0; index < blocks; index += 1) {
@@ -2665,15 +2764,19 @@ export const prefabs = {
2665
2764
  const color = night
2666
2765
  ? (index % 3 === 0 ? "#1e293b" : index % 3 === 1 ? "#2d3340" : "#172233")
2667
2766
  : (index % 3 === 0 ? "#8ea2aa" : index % 3 === 1 ? "#b89b72" : "#668094");
2668
- nodes.push(primitives.box({
2669
- name: `city tower ${index + 1}`,
2670
- material: material.pbr({ color, roughness: 0.68, metallic: 0.06 })
2671
- }).position(x, height / 2, z).scale([1.08, height, 1.08]).toJSON());
2767
+ cityTowerTransforms.push({ position: [x, height / 2, z], scale: [1.08, height, 1.08] });
2768
+ cityTowerColors.push(color);
2672
2769
  if (options.litWindows !== false) {
2673
2770
  nodes.push(...makeBuildingWindowRows(x, z, height, index, timeOfDay));
2674
2771
  }
2675
2772
  nodes.push(...makeBuildingDetails(x, z, height, index, timeOfDay));
2676
2773
  }
2774
+ nodes.push(instances.box({
2775
+ name: "city tower native instanced family",
2776
+ transforms: cityTowerTransforms,
2777
+ colors: cityTowerColors,
2778
+ material: material.pbr({ color: "#ffffff", roughness: 0.68, metallic: 0.06 })
2779
+ }).toJSON());
2677
2780
  const lampPositions = [
2678
2781
  [-1.15, 0, 0.85], [1.15, 0, 0.85], [-1.15, 0, -0.85], [1.15, 0, -0.85],
2679
2782
  [-3.85, 0, -2.05], [-2.95, 0, 2.05], [2.05, 0, -2.05], [3.05, 0, 2.05],
@@ -2769,7 +2872,9 @@ export const prefabs = {
2769
2872
  const rimMaterial = material.emissive({ color: "#dbeafe", emissive: "#93c5fd", emissiveIntensity: 0.18, opacity: 0.42 });
2770
2873
  const nodes = [
2771
2874
  primitives.plane({ name: "seamless matte product hero floor", material: floorMaterial }).position(0, -0.018, -0.62).scale([3.6, 1, 2.6]).toJSON(),
2772
- primitives.plane({ name: "distant seamless product photography backdrop", material: backdropMaterial }).position(0, 1.22, -2.28).rotate(-0.08, 0, 0).scale([3.6, 1, 1.8]).toJSON(),
2875
+ primitives.plane({ name: "vertical seamless product photography backdrop", material: backdropMaterial }).position(0, 1.22, -2.28).rotate(1.5708, 0, 0).scale([6.2, 1, 2.15]).toJSON(),
2876
+ primitives.box({ name: "cool cyan studio reflection panel", material: material.emissive({ color: "#20bfe8", emissive: "#20bfe8", emissiveIntensity: 0.46 }) }).position(-2.35, 1.14, -2.18).scale([0.24, 1.38, 0.035]).toJSON(),
2877
+ primitives.box({ name: "warm amber studio reflection panel", material: material.emissive({ color: "#f0a43c", emissive: "#f0a43c", emissiveIntensity: 0.42 }) }).position(2.35, 1.14, -2.18).scale([0.24, 1.38, 0.035]).toJSON(),
2773
2878
  primitives.cylinder({ name: "low matte hero product plinth", material: plinthMaterial }).position(0, 0.255, -0.65).scale([1.46, 0.26, 1.46]).toJSON(),
2774
2879
  primitives.cylinder({ name: "soft product contact shadow from footprint", material: shadowMaterial }).position(0, 0.292, -0.65).scale([1.06, 0.01, 0.66]).toJSON(),
2775
2880
  primitives.box({ name: "subtle turntable orbit cue on product plinth", material: material.emissive({ color: "#93c5fd", emissive: "#38bdf8", emissiveIntensity: 0.22, opacity: 0.38 }) }).position(0, 0.52, -0.65).scale([1.02, 0.012, 1.02]).toJSON(),
@@ -3371,7 +3476,7 @@ function createMiniGolfStateController() {
3371
3476
  const worldSnapshot = world.snapshot();
3372
3477
  return {
3373
3478
  physicsBackend: worldSnapshot.backend.active,
3374
- deterministicReplayId: `mini-golf-cannon-v1-${shots}-${score}-${collisions}-${resets}`,
3479
+ deterministicReplayId: `mini-golf-rapier-v2-${shots}-${score}-${collisions}-${resets}`,
3375
3480
  replayFrame: worldSnapshot.stats.steps,
3376
3481
  captureTime: Number((worldSnapshot.stats.steps * world.fixedDelta).toFixed(3)),
3377
3482
  shots,
@@ -3478,187 +3583,6 @@ export const games = {
3478
3583
  export function collectGameRuntimeEvidence(app, options = {}) {
3479
3584
  return collectGameRuntimeEvidenceV105(app, options);
3480
3585
  }
3481
- function createGameInputController(options) {
3482
- const actions = options.actions;
3483
- const axes = options.axes ?? {};
3484
- const bufferMs = options.bufferMs ?? 120;
3485
- const activeBindings = new Set();
3486
- const activeActionOverrides = new Set();
3487
- const previousHeld = new Map();
3488
- const currentHeld = new Map();
3489
- const pressedEdges = new Set();
3490
- const releasedEdges = new Set();
3491
- const lastPressedAt = new Map();
3492
- const replayEvents = [];
3493
- let frame = 0;
3494
- let time = 0;
3495
- let latestSnapshot = {
3496
- kind: "aura-game-input-snapshot",
3497
- frame,
3498
- time,
3499
- activeBindings: [],
3500
- actions: {}
3501
- };
3502
- const resolveHeld = (action) => {
3503
- if (activeActionOverrides.has(action))
3504
- return true;
3505
- const bindings = actions[action] ?? [];
3506
- return bindings.some((binding) => activeBindings.has(binding));
3507
- };
3508
- const record = (type, binding) => {
3509
- replayEvents.push({ frame, time, type, binding });
3510
- };
3511
- const pressBinding = (binding, shouldRecord = true) => {
3512
- activeBindings.add(binding);
3513
- if (actions[binding])
3514
- activeActionOverrides.add(binding);
3515
- if (shouldRecord)
3516
- record("press", binding);
3517
- };
3518
- const releaseBinding = (binding, shouldRecord = true) => {
3519
- activeBindings.delete(binding);
3520
- activeActionOverrides.delete(binding);
3521
- if (shouldRecord)
3522
- record("release", binding);
3523
- };
3524
- const toSnapshot = () => {
3525
- const actionStates = {};
3526
- const nowMs = time * 1000;
3527
- for (const action of Object.keys(actions)) {
3528
- const held = currentHeld.get(action) ?? false;
3529
- actionStates[action] = {
3530
- pressed: pressedEdges.has(action),
3531
- held,
3532
- released: releasedEdges.has(action),
3533
- buffered: pressedEdges.has(action) || nowMs - (lastPressedAt.get(action) ?? Number.NEGATIVE_INFINITY) <= bufferMs,
3534
- value: held ? 1 : 0
3535
- };
3536
- }
3537
- return {
3538
- kind: "aura-game-input-snapshot",
3539
- frame,
3540
- time,
3541
- activeBindings: [...activeBindings].sort(),
3542
- actions: actionStates
3543
- };
3544
- };
3545
- const update = (dt = 1 / 60) => {
3546
- frame += 1;
3547
- time += Math.max(0, dt);
3548
- pressedEdges.clear();
3549
- releasedEdges.clear();
3550
- for (const action of Object.keys(actions)) {
3551
- const held = resolveHeld(action);
3552
- const wasHeld = previousHeld.get(action) ?? false;
3553
- currentHeld.set(action, held);
3554
- if (held && !wasHeld) {
3555
- pressedEdges.add(action);
3556
- lastPressedAt.set(action, time * 1000);
3557
- }
3558
- if (!held && wasHeld)
3559
- releasedEdges.add(action);
3560
- previousHeld.set(action, held);
3561
- }
3562
- latestSnapshot = toSnapshot();
3563
- return latestSnapshot;
3564
- };
3565
- const target = options.target ?? (typeof window !== "undefined" ? window : undefined);
3566
- const onKeyDown = (event) => {
3567
- const keyboard = event;
3568
- if (keyboard.repeat)
3569
- return;
3570
- if (keyboard.code)
3571
- pressBinding(keyboard.code);
3572
- if (keyboard.key && keyboard.key !== keyboard.code)
3573
- pressBinding(keyboard.key);
3574
- };
3575
- const onKeyUp = (event) => {
3576
- const keyboard = event;
3577
- if (keyboard.code)
3578
- releaseBinding(keyboard.code);
3579
- if (keyboard.key && keyboard.key !== keyboard.code)
3580
- releaseBinding(keyboard.key);
3581
- };
3582
- if (options.autoListen !== false && target?.addEventListener) {
3583
- target.addEventListener("keydown", onKeyDown);
3584
- target.addEventListener("keyup", onKeyUp);
3585
- }
3586
- return {
3587
- kind: "aura-game-input-plan",
3588
- actions,
3589
- axes,
3590
- bufferMs,
3591
- update,
3592
- snapshot() {
3593
- return latestSnapshot;
3594
- },
3595
- pressed(action) {
3596
- return pressedEdges.has(action);
3597
- },
3598
- held(action) {
3599
- return currentHeld.get(action) ?? resolveHeld(action);
3600
- },
3601
- released(action) {
3602
- return releasedEdges.has(action);
3603
- },
3604
- buffered(action, windowMs = bufferMs) {
3605
- return pressedEdges.has(action) || time * 1000 - (lastPressedAt.get(action) ?? Number.NEGATIVE_INFINITY) <= windowMs;
3606
- },
3607
- axis(name, negativeAction, positiveAction) {
3608
- const binding = axes[name];
3609
- const negative = negativeAction ?? binding?.negative;
3610
- const positive = positiveAction ?? binding?.positive;
3611
- if (!negative && !positive)
3612
- return this.held(name) ? 1 : 0;
3613
- return (positive && this.held(positive) ? 1 : 0) - (negative && this.held(negative) ? 1 : 0);
3614
- },
3615
- press(binding) {
3616
- pressBinding(binding);
3617
- },
3618
- release(binding) {
3619
- releaseBinding(binding);
3620
- },
3621
- setAction(action, held) {
3622
- if (held) {
3623
- activeActionOverrides.add(action);
3624
- record("press", action);
3625
- }
3626
- else {
3627
- activeActionOverrides.delete(action);
3628
- record("release", action);
3629
- }
3630
- },
3631
- recorded() {
3632
- return [...replayEvents];
3633
- },
3634
- replay(events) {
3635
- activeBindings.clear();
3636
- activeActionOverrides.clear();
3637
- for (const event of events) {
3638
- if (event.type === "press")
3639
- pressBinding(event.binding, false);
3640
- else
3641
- releaseBinding(event.binding, false);
3642
- }
3643
- return update(0);
3644
- },
3645
- clearReplay() {
3646
- replayEvents.length = 0;
3647
- },
3648
- dispose() {
3649
- if (target?.removeEventListener) {
3650
- target.removeEventListener("keydown", onKeyDown);
3651
- target.removeEventListener("keyup", onKeyUp);
3652
- }
3653
- activeBindings.clear();
3654
- activeActionOverrides.clear();
3655
- previousHeld.clear();
3656
- currentHeld.clear();
3657
- pressedEdges.clear();
3658
- releasedEdges.clear();
3659
- }
3660
- };
3661
- }
3662
3586
  export function createAuraGameRules(options = {}) {
3663
3587
  return {
3664
3588
  kind: "aura-game-rules",
@@ -5385,12 +5309,16 @@ function cityScene(options = {}) {
5385
5309
  .camera(cityCameraPreset(options.cameraPreset ?? "overview", timeOfDay));
5386
5310
  }
5387
5311
  function collectCityInstancingPlan(nodes) {
5388
- const names = groups.flatten(nodes).map((node) => "name" in node ? node.name ?? "" : "");
5312
+ const flattened = groups.flatten(nodes);
5313
+ const names = flattened.map((node) => "name" in node ? node.name ?? "" : "");
5314
+ const nativeInstanceNodes = flattened.filter((node) => node.kind === "primitive" && Boolean(node.instances?.length));
5315
+ const nativeInstances = nativeInstanceNodes.reduce((total, node) => total + (node.instances?.length ?? 0), 0);
5389
5316
  const windows = names.filter((name) => name.includes("window column")).length;
5390
5317
  const props = names.filter((name) => name.includes("bench") || name.includes("tree") || name.includes("sign") || name.includes("car body") || name.includes("traffic signal")).length;
5391
5318
  const roadMarkings = names.filter((name) => name.includes("lane marking") || name.includes("road stripe") || name.includes("crosswalk") || name.includes("turn arrow") || name.includes("bike lane")).length;
5392
5319
  const lights = names.filter((name) => name.includes("street lamp") || name.includes("headlight") || name.includes("lamp glow") || name.includes("city glow")).length;
5393
5320
  const groupsList = [
5321
+ nativeInstanceNodes.some((node) => node.name?.includes("city tower")) ? "city towers" : "",
5394
5322
  windows >= 8 ? "window columns" : "",
5395
5323
  props >= 6 ? "street props" : "",
5396
5324
  roadMarkings >= 8 ? "road markings" : "",
@@ -5398,13 +5326,15 @@ function collectCityInstancingPlan(nodes) {
5398
5326
  ].filter(Boolean);
5399
5327
  return {
5400
5328
  kind: "aura-city-instancing-plan",
5401
- rendererPath: "auraWebGL2PrimitiveBatches",
5329
+ rendererPath: "productionRuntimeNativeInstancing",
5402
5330
  windows,
5403
5331
  props,
5404
5332
  roadMarkings,
5405
5333
  lights,
5334
+ nativeInstanceGroups: nativeInstanceNodes.length,
5335
+ nativeInstances,
5406
5336
  groups: groupsList,
5407
- instanced: windows >= 8 && props >= 6 && roadMarkings >= 8 && lights >= 6
5337
+ instanced: nativeInstanceNodes.length > 0 && nativeInstances > 0
5408
5338
  };
5409
5339
  }
5410
5340
  function changedCityNodeNames(previous, next) {
@@ -5417,7 +5347,11 @@ function changedCityNodeNames(previous, next) {
5417
5347
  function validateCityVisualQA(nodes, options = {}) {
5418
5348
  const flattened = groups.flatten(nodes);
5419
5349
  const names = flattened.map((node) => "name" in node ? node.name ?? "" : "");
5420
- const buildings = names.filter((name) => name.includes("city tower")).length;
5350
+ const buildings = flattened.reduce((total, node) => {
5351
+ if (!("name" in node) || !node.name?.includes("city tower"))
5352
+ return total;
5353
+ return total + (node.kind === "primitive" && node.instances?.length ? node.instances.length : 1);
5354
+ }, 0);
5421
5355
  const windows = names.filter((name) => name.includes("window column")).length;
5422
5356
  const streets = names.filter((name) => name.includes("road") || name.includes("street") || name.includes("avenue")).length;
5423
5357
  const crosswalks = names.filter((name) => name.includes("crosswalk")).length;
@@ -5945,7 +5879,7 @@ function createSceneKitLazyLoadingPlan(id) {
5945
5879
  systems.push({ system, trigger, loadedByDefault: false, evidence });
5946
5880
  };
5947
5881
  if (id === "physicsPlayground" || id === "miniGolf") {
5948
- add("physics-backend", `${id} physics state construction`, "Cannon-backed physics is optional scene-kit work and not required for static material/chart/product scenes");
5882
+ add("physics-backend", `${id} physics state construction`, "Rapier-backed physics is isolated scene-kit work and is not required for static material, chart, or product scenes");
5949
5883
  }
5950
5884
  if (id === "productViewer") {
5951
5885
  add("product-gltf-loader", "typed product model render path", "GLTF loading is tied to typed model scenes and excluded from procedural-only scene kits");
@@ -6215,10 +6149,11 @@ export const promptRecipes = {
6215
6149
  .add(primitives.sphere({ name: "rain splash foreground", material: material.emissive({ color: "#c8f4ff", emissive: "#c8f4ff" }) }).position(-0.88, 0.035, 0.72).scale([0.07, 0.018, 0.07]))
6216
6150
  .add(primitives.sphere({ name: "rain splash key side", material: material.emissive({ color: "#ffe1ad", emissive: "#ffe1ad" }) }).position(1.14, 0.035, 0.44).scale([0.08, 0.018, 0.08]))
6217
6151
  .add(model(asset, { name: plan.subject.label }).position(-0.08, 0.02, -0.86).rotate(-0.08, -0.74, 0.02).scale(1.48))
6218
- .add(lights.ambient({ intensity: 0.07, color: "#839dc6" }))
6152
+ .add(lights.ambient({ intensity: 0.22, color: "#b4c9e8" }))
6219
6153
  .add(lights.point({ name: "hard cyan rim", position: [-2.35, 2.65, 0.85], color: "#38d6ff", intensity: 3.25 }))
6220
6154
  .add(lights.point({ name: "warm practical key", position: [2.35, 1.7, -0.25], color: "#ffd08a", intensity: 1.6 }))
6221
6155
  .add(lights.point({ name: "low floor bounce", position: [0.1, 0.45, 1.1], color: "#7edfff", intensity: 0.62 }))
6156
+ .add(lights.point({ name: "cinematic hero front fill", position: [0.15, 1.45, 3.1], color: "#e8f2ff", intensity: 5.2 }))
6222
6157
  .add(effects.rain({ intensity: 0.46, color: "#c3e6ff" }))
6223
6158
  .add(effects.fog({ density: 0.08, color: "#32435a" }))
6224
6159
  .add(effects.bloom({ intensity: 0.36, color: "#6edfff" }))
@@ -6719,10 +6654,137 @@ export function createAuraApp(target, options) {
6719
6654
  let disposed = false;
6720
6655
  let animationHandle = 0;
6721
6656
  let productionController;
6657
+ /** WS-2.9: true from the moment a WebGL mount starts until the controller arrives or fails. */
6658
+ let productionMountPending = false;
6659
+ /**
6660
+ * WS-2.5 — true when a WebGL mount was attempted and FAILED for this scene.
6661
+ *
6662
+ * Distinct from `productionMountPending`, and the distinction is the whole point. After a failed mount
6663
+ * both the pending flag and the controller are absent, which used to be indistinguishable from "this
6664
+ * scene never wanted WebGL" — so `step()` and the render loop fell through to the Canvas-2D branch and
6665
+ * painted a gradient over a scene whose renderer had just failed. Measured: 16,384 lit pixels on a
6666
+ * 128x128 canvas, i.e. the entire surface, with the real error sitting in `diagnostics().errors` where
6667
+ * nobody was looking.
6668
+ */
6669
+ let productionMountFailed = false;
6670
+ /**
6671
+ * Settles when the in-flight WebGL mount finishes, successfully or not. Backs `app.ready()`.
6672
+ *
6673
+ * Resolved rather than pending when no mount is in flight, so `await app.ready()` is safe to call at
6674
+ * any time and on any scene.
6675
+ */
6676
+ let productionMountSettled = Promise.resolve();
6677
+ /** Lets `dispose()` settle an in-flight mount; see the note there. */
6678
+ let settleMountForDispose = () => undefined;
6679
+ /*
6680
+ * WS-2.6 — device-loss subscribers, held here rather than pushed straight at the controller.
6681
+ *
6682
+ * The renderer mounts asynchronously, so a developer writing `app.onDeviceLost(...)` on the line after
6683
+ * `createAuraApp` has no controller to attach to yet. Making them await `ready()` first would be the
6684
+ * same trap WS-2.9 fixed: an API that silently does nothing depending on timing. So subscriptions are
6685
+ * recorded and attached when the device arrives, and on every re-mount from `setScene`.
6686
+ */
6687
+ /*
6688
+ * Keyed BY LISTENER, not a flat array — and that detail is load-bearing.
6689
+ *
6690
+ * A flat list of unsubscribe functions looks equivalent and is not. `attachDeviceListeners` re-attaches
6691
+ * every held listener on each mount, so a listener registered before the mount ends up with **two**
6692
+ * controller subscriptions: the one its own `onDeviceLost` call created, and the one the mount handler
6693
+ * created. Its returned unsubscribe closure only knew about the first, so calling it left the second
6694
+ * live and the listener still fired.
6695
+ *
6696
+ * Measured by the WS-2.6 test's final assertion: after `unsubscribe()`, a second context loss still
6697
+ * incremented the counter — 2 where 1 was expected. A map from listener to its current subscription
6698
+ * makes unsubscribing complete regardless of how many mounts have happened.
6699
+ */
6700
+ const deviceLostListeners = new Map();
6701
+ const deviceRestoredListeners = new Map();
6702
+ const attachDeviceListeners = (controller) => {
6703
+ for (const [listener, unsubscribe] of deviceLostListeners) {
6704
+ unsubscribe?.();
6705
+ deviceLostListeners.set(listener, controller.onDeviceLost?.(listener));
6706
+ }
6707
+ for (const [listener, unsubscribe] of deviceRestoredListeners) {
6708
+ unsubscribe?.();
6709
+ deviceRestoredListeners.set(listener, controller.onDeviceRestored?.(listener));
6710
+ }
6711
+ };
6722
6712
  let lastTime = 0;
6723
6713
  let mountRevision = 0;
6724
6714
  let canvasRuntimePhysics;
6725
6715
  const runtimeNodes = createAuraRuntimeNodeRegistry(renderSnapshot);
6716
+ // One world owned by the app, so `app.physics` is live whether or not the scene declared
6717
+ // any bodies. A route that spawns everything at runtime (a shooter, a stacking puzzle) is
6718
+ // then no harder to write than one that declares bodies up front.
6719
+ const appPhysicsNodeNames = new Map();
6720
+ /*
6721
+ * The physics world is created on first use, not on app construction.
6722
+ *
6723
+ * `app.physics` must be live for every app — a route that spawns bodies at runtime should be no
6724
+ * harder to write than one that declares them. But constructing the world eagerly made **every**
6725
+ * app pay for the selected physical solver.
6726
+ *
6727
+ * Measured cost of the eager version: a minimal `createAuraApp` scene containing one box bundled to
6728
+ * The eager version made the physical runtime part of every route's startup path, including
6729
+ * routes that never created a body.
6730
+ *
6731
+ * Lazily constructing it changes no behaviour — `app.physics` still returns a working runtime, and
6732
+ * a scene that declares `.physics({...})` still gets its bodies registered, because
6733
+ * `registerDeclaredBodies` asks for the world and therefore creates it. What changes is that a
6734
+ * scene with no physics never instantiates a solver, so a bundler can drop it.
6735
+ */
6736
+ let appPhysicsWorldInstance;
6737
+ const appPhysicsWorld = () => {
6738
+ appPhysicsWorldInstance ??= new PhysicsWorld({
6739
+ gravity: options.physics?.gravity ? [...options.physics.gravity] : [0, -9.81, 0],
6740
+ fixedDelta: 1 / 60,
6741
+ enableSleeping: true
6742
+ });
6743
+ return appPhysicsWorldInstance;
6744
+ };
6745
+ let appPhysicsInstance;
6746
+ /** Lazy `app.physics`. Identity is stable: the same runtime is returned on every access. */
6747
+ const appPhysics = () => {
6748
+ appPhysicsInstance ??= createPhysicsRuntime(appPhysicsWorld(), {
6749
+ ...(options.physics?.layers ? { layers: options.physics.layers } : {}),
6750
+ nodeNameFor: (bodyId) => appPhysicsNodeNames.get(bodyId)
6751
+ });
6752
+ return appPhysicsInstance;
6753
+ };
6754
+ /**
6755
+ * Register scene-declared bodies into the app-owned world.
6756
+ *
6757
+ * Runs on construction and on every `setScene`, so a declared node name resolves through
6758
+ * `app.physics.bodies.require(name)` without the route restating the body.
6759
+ */
6760
+ const registerDeclaredBodies = (target) => {
6761
+ // Ask the question before creating the world, so a scene with no declared bodies never does.
6762
+ const declaresPhysics = target.nodes.some((node) => (node.kind === "model" || node.kind === "primitive") && Boolean(node.physics));
6763
+ if (!declaresPhysics)
6764
+ return;
6765
+ const world = appPhysicsWorld();
6766
+ for (const node of target.nodes) {
6767
+ if ((node.kind !== "model" && node.kind !== "primitive") || !node.physics)
6768
+ continue;
6769
+ const spec = node.physics;
6770
+ const body = world.createRigidBody({
6771
+ type: spec.type ?? "dynamic",
6772
+ position: node.position ?? [0, 0, 0],
6773
+ rotation: eulerToQuat(node.rotation ?? [0, 0, 0]),
6774
+ ...(spec.mass === undefined ? {} : { mass: spec.mass }),
6775
+ ...(spec.friction === undefined ? {} : { friction: spec.friction }),
6776
+ ...(spec.restitution === undefined ? {} : { restitution: spec.restitution })
6777
+ });
6778
+ world.createCollider(body, {
6779
+ shape: resolveNodePhysicsShape(node, spec),
6780
+ ...(spec.sensor === undefined ? {} : { sensor: spec.sensor }),
6781
+ material: { friction: spec.friction ?? 0.5, restitution: spec.restitution ?? 0 }
6782
+ });
6783
+ if (node.name)
6784
+ appPhysicsNodeNames.set(body.id, node.name);
6785
+ }
6786
+ };
6787
+ registerDeclaredBodies(renderSnapshot);
6726
6788
  const frameCallbacks = new Set();
6727
6789
  let runtimePaused = options.autoStart === false;
6728
6790
  let runtimeFrame = 0;
@@ -6775,7 +6837,16 @@ export function createAuraApp(target, options) {
6775
6837
  }
6776
6838
  diagnosticsState.evidence = collectAuraSceneEvidence(renderSnapshot);
6777
6839
  diagnosticsState.fps = Math.round(1000 / delta);
6778
- diagnosticsState.drawCalls = renderSceneToCanvas(canvas, renderSnapshot, time);
6840
+ /*
6841
+ * WS-2.5 — the live loop must respect the same rule as `step()`.
6842
+ *
6843
+ * Fixing only `step()` would have left the gradient reachable through `autoStart`, which is the path
6844
+ * most routes take. A scene whose WebGL mount is pending or has failed gets nothing drawn; the reason
6845
+ * is already recorded in `warnings`/`errors`.
6846
+ */
6847
+ diagnosticsState.drawCalls = productionMountPending || productionMountFailed
6848
+ ? 0
6849
+ : renderDiagnosticPreviewToCanvas(canvas, renderSnapshot, time);
6779
6850
  if (canvas)
6780
6851
  diagnosticsState.renderSize = [canvas.width, canvas.height];
6781
6852
  overlay?.update();
@@ -6793,24 +6864,87 @@ export function createAuraApp(target, options) {
6793
6864
  productionController = undefined;
6794
6865
  lastTime = 0;
6795
6866
  const shouldUseProductionRenderer = shouldUseProductionRendererForCurrentScene();
6867
+ /*
6868
+ * WS-2.5 — a scene with renderable content must never fall to the diagnostic 2D path.
6869
+ *
6870
+ * `shouldUseProductionRendererForCurrentScene()` is false in two very different situations, and the
6871
+ * old code treated them the same: a scene with nothing renderable in it (fine, nothing to draw), and
6872
+ * a scene *with* renderable nodes that could not reach WebGL — no canvas, or no `window`. The second
6873
+ * used to silently produce a gradient schematic, which looks like a render and is not.
6874
+ *
6875
+ * So the two cases are separated. A renderable scene with no usable canvas now raises a diagnosable
6876
+ * error naming the cause, instead of drawing something plausible.
6877
+ */
6878
+ const declaresRenderableContent = renderSnapshot.nodes.some(isWebGLRenderableNode);
6879
+ /*
6880
+ * Scoped to the case that is actually a lie: a canvas WAS supplied, so the caller is looking at
6881
+ * pixels, and the scene has renderable content — but WebGL declined it, so those pixels would be a
6882
+ * gradient schematic.
6883
+ *
6884
+ * Deliberately NOT extended to `canvas === undefined`. Constructing an app with no canvas is a
6885
+ * legitimate and widely used pattern — 18 tests across `tests/unit/agent-api` and
6886
+ * `tests/unit/rendering` do it to exercise scene, runtime and physics behaviour headlessly, and
6887
+ * `createAuraApp(undefined, ...)` is how `lazy-physics-world.test.ts` checks `app.physics`. Throwing
6888
+ * there would break working semantics to satisfy a rendering rule, which R7 forbids. Those callers
6889
+ * are not being shown a misleading frame; they are not being shown a frame at all, and
6890
+ * `diagnostics().backend` reports `"headless"`.
6891
+ */
6892
+ if (declaresRenderableContent && canvas && !shouldUseProductionRenderer) {
6893
+ throw new AuraRuntimeError("backend-fallback", "Aura3D cannot render this scene on the canvas you supplied: it has renderable nodes but WebGL2 is unavailable in this context. It will NOT fall back to the Canvas 2D diagnostic preview, because that draws a gradient schematic rather than your scene and has silently hidden defects before — world labels once reached the scene graph but were drawn only in that path. Suggested fix: run in a browser context with WebGL2 available, or inspect diagnostics().errors for the underlying device failure.");
6894
+ }
6796
6895
  const backend = shouldUseProductionRenderer ? "webgl2" : canvas ? "canvas2d" : "headless";
6797
6896
  resetDiagnosticsForCurrentScene(backend);
6798
6897
  canvasRuntimePhysics = shouldUseProductionRenderer ? undefined : createRuntimeScenePhysics(renderSnapshot);
6799
6898
  overlay?.update();
6800
6899
  const revision = ++mountRevision;
6900
+ /*
6901
+ * WS-2.9 — record that a WebGL mount is in flight.
6902
+ *
6903
+ * `startProductionRender` is async, so between `createAuraApp` returning and the controller
6904
+ * arriving there is a window in which `step()` used to fall through to the Canvas-2D `render()`
6905
+ * path and draw nothing for a WebGL scene — silently, with `drawCalls: 0`, `warnings: []` and
6906
+ * `errors: []`. `step(dt)` is the documented deterministic entry point, so a developer writing a
6907
+ * headless capture got a blank image and no explanation.
6908
+ */
6909
+ productionMountPending = shouldUseProductionRenderer && Boolean(canvas);
6910
+ productionMountFailed = false;
6911
+ let settleMount = () => undefined;
6912
+ productionMountSettled = productionMountPending
6913
+ ? new Promise((resolveSettled) => { settleMount = resolveSettled; })
6914
+ : Promise.resolve();
6915
+ settleMountForDispose = settleMount;
6801
6916
  if (shouldUseProductionRenderer && canvas) {
6802
- void startProductionRender(canvas, renderSnapshot, diagnosticsState, options, overlay, runRuntimeFrame, () => runtimePaused, runtimeNodes)
6917
+ void startProductionRender(canvas, renderSnapshot, diagnosticsState, options, overlay, runRuntimeFrame, () => runtimePaused, runtimeNodes,
6918
+ // A route that registered a frame callback, or that has bodies to simulate, needs
6919
+ // frames regardless of what the scene declares.
6920
+ /*
6921
+ * Must not *create* a world just to ask whether one exists.
6922
+ * Reading `appPhysicsWorldInstance` directly keeps the lazy construction lazy; calling
6923
+ * `appPhysicsWorld()` here would instantiate the solver on the first render of every scene
6924
+ * and defeat the whole point.
6925
+ */
6926
+ () => frameCallbacks.size > 0 || (appPhysicsWorldInstance?.bodies().length ?? 0) > 0,
6927
+ // Paused frames render at the app's own simulated clock, so a held frame really is held.
6928
+ () => runtimeTime * 1000)
6803
6929
  .then((controller) => {
6804
6930
  if (disposed || revision !== mountRevision) {
6931
+ productionMountPending = false;
6932
+ settleMount();
6805
6933
  controller.dispose();
6806
6934
  return;
6807
6935
  }
6808
6936
  productionController = controller;
6937
+ productionMountPending = false;
6938
+ attachDeviceListeners(controller);
6939
+ settleMount();
6809
6940
  markRouteReady(snapshot, diagnosticsState);
6810
6941
  })
6811
6942
  .catch((error) => {
6812
6943
  if (disposed || revision !== mountRevision)
6813
6944
  return;
6945
+ productionMountPending = false;
6946
+ productionMountFailed = true;
6947
+ settleMount();
6814
6948
  diagnosticsState.backend = "webgl2";
6815
6949
  diagnosticsState.errors.push(productionRenderErrorMessage(error));
6816
6950
  overlay?.update();
@@ -6822,7 +6956,7 @@ export function createAuraApp(target, options) {
6822
6956
  markRouteReady(snapshot, diagnosticsState);
6823
6957
  };
6824
6958
  mountCurrentScene();
6825
- return {
6959
+ const app = {
6826
6960
  canvas,
6827
6961
  get scene() {
6828
6962
  return snapshot;
@@ -6834,9 +6968,24 @@ export function createAuraApp(target, options) {
6834
6968
  snapshot = normalizeSceneSnapshot(nextScene);
6835
6969
  renderSnapshot = flattenSceneSnapshot(snapshot);
6836
6970
  runtimeNodes.reset(renderSnapshot);
6971
+ // Drop bodies from the previous scene before registering the new ones, so swapping
6972
+ // scenes does not leave orphaned bodies colliding with the new level.
6973
+ // Only a world that exists can hold stale bodies; `setScene` on a physics-free app is a no-op here.
6974
+ for (const id of [...appPhysicsNodeNames.keys()])
6975
+ appPhysicsWorldInstance?.removeRigidBody(id);
6976
+ appPhysicsNodeNames.clear();
6977
+ registerDeclaredBodies(renderSnapshot);
6837
6978
  mountCurrentScene();
6838
6979
  },
6839
6980
  nodes: runtimeNodes,
6981
+ /*
6982
+ * A getter, so touching `app.physics` is what constructs the world.
6983
+ * Declared as a getter rather than an eager property because the property access itself is the
6984
+ * signal that a route intends to use physics.
6985
+ */
6986
+ get physics() {
6987
+ return appPhysics();
6988
+ },
6840
6989
  get runtime() {
6841
6990
  return {
6842
6991
  paused: runtimePaused,
@@ -6863,23 +7012,112 @@ export function createAuraApp(target, options) {
6863
7012
  pause() {
6864
7013
  runtimePaused = true;
6865
7014
  },
7015
+ resetRuntimeClock() {
7016
+ /*
7017
+ * Rewind the frame counter and elapsed time to zero.
7018
+ *
7019
+ * Needed for reproducible capture. Routes animate from accumulated `time` — Data Galaxy sets
7020
+ * `setRotation(time * 0.16, ...)` — so a scene paused after 180 frames of real-time loading
7021
+ * looks different from the same scene paused after 240, even though both were then advanced by
7022
+ * an identical number of fixed steps. Zeroing the clock first makes "settle to N steps" name
7023
+ * exactly one state.
7024
+ *
7025
+ * Not part of `AuraApp`: this is a capture/testing seam, reached through `auraAppRegistry`
7026
+ * rather than offered as gameplay API, because rewinding a live game's clock mid-session is
7027
+ * not something a route should be encouraged to do.
7028
+ */
7029
+ runtimeFrame = 0;
7030
+ runtimeTime = 0;
7031
+ runtimeAlpha = 0;
7032
+ lastTime = 0;
7033
+ },
6866
7034
  resume() {
6867
7035
  runtimePaused = false;
6868
7036
  if (!animationHandle && !productionController && options.autoStart !== false && typeof requestAnimationFrame !== "undefined") {
6869
7037
  animationHandle = requestAnimationFrame(render);
6870
7038
  }
6871
7039
  },
7040
+ async ready() {
7041
+ // Resolves when the in-flight mount settles; immediate when there is none. See AuraApp.ready.
7042
+ await productionMountSettled;
7043
+ },
7044
+ onDeviceLost(listener) {
7045
+ // Attach now when a device already exists; otherwise the mount handler attaches it on arrival.
7046
+ deviceLostListeners.set(listener, productionController?.onDeviceLost?.(listener));
7047
+ return () => {
7048
+ // Read the CURRENT subscription: a re-mount may have replaced the one captured at registration.
7049
+ deviceLostListeners.get(listener)?.();
7050
+ deviceLostListeners.delete(listener);
7051
+ };
7052
+ },
7053
+ onDeviceRestored(listener) {
7054
+ deviceRestoredListeners.set(listener, productionController?.onDeviceRestored?.(listener));
7055
+ return () => {
7056
+ deviceRestoredListeners.get(listener)?.();
7057
+ deviceRestoredListeners.delete(listener);
7058
+ };
7059
+ },
7060
+ deviceLost() {
7061
+ return productionController?.deviceLost?.() ?? false;
7062
+ },
6872
7063
  step(dt = 1 / 60) {
6873
7064
  const seconds = Math.max(0, dt);
6874
7065
  runRuntimeFrame(seconds, "manual");
6875
7066
  canvasRuntimePhysics?.step(seconds);
6876
7067
  const previousPaused = runtimePaused;
6877
7068
  runtimePaused = true;
7069
+ /*
7070
+ * Render at the *simulated* time, not wall-clock time.
7071
+ *
7072
+ * `step(dt)` is the deterministic entry point — a caller advances a fixed amount and expects a
7073
+ * reproducible frame. Passing `performanceNow()` to the renderer defeated that: any
7074
+ * time-driven shader or effect sampled real elapsed milliseconds, so the same `step` sequence
7075
+ * produced a different image on every run.
7076
+ *
7077
+ * Measured while making screenshot approval satisfiable: after zeroing the runtime clock and
7078
+ * stepping identically, 6 of 29 screenshots still drifted perceptually — all of them routes
7079
+ * with time-animated rendering (particle lab, material inspector). Feeding `runtimeTime`
7080
+ * instead makes `step` mean one thing.
7081
+ *
7082
+ * The live `render()` loop still uses wall-clock time, which is correct for real playback.
7083
+ */
7084
+ const simulatedMs = runtimeTime * 1000;
6878
7085
  if (productionController) {
6879
- productionController.render(performanceNow());
7086
+ productionController.render(simulatedMs);
7087
+ }
7088
+ else if (productionMountPending || productionMountFailed) {
7089
+ /*
7090
+ * WS-2.9 — a WebGL mount is in flight, so there is nothing correct to draw yet.
7091
+ *
7092
+ * Falling through to the Canvas-2D `render()` branch below would draw a gradient for a scene
7093
+ * that has a WebGL renderer coming, which is worse than drawing nothing: it produces a frame
7094
+ * that looks like a real render and is not one. Measured before this fix: eight synchronous
7095
+ * `step(1/60)` calls after construction gave `drawCalls: 0`, a fully blank canvas,
7096
+ * `backend: "webgl2"` and — the actual defect — **empty `warnings` and `errors`**. One
7097
+ * `await requestAnimationFrame` first gave 58,480 lit pixels.
7098
+ *
7099
+ * So this reports rather than renders. The warning is actionable and names both the cause and
7100
+ * the fix, because a developer writing a headless capture has no way to guess that a
7101
+ * documented deterministic entry point depends on an animation frame having elapsed.
7102
+ */
7103
+ const pendingWarning = productionMountFailed
7104
+ ? "Aura3D step() rendered nothing because the WebGL renderer failed to mount for this scene. It will NOT fall back to the Canvas 2D diagnostic preview, because that paints a gradient schematic over a scene whose renderer just failed. Suggested fix: read diagnostics().errors for the underlying device failure."
7105
+ : "Aura3D step() was called before the WebGL renderer finished mounting, so this frame rendered nothing. The production renderer mounts asynchronously. Suggested fix: await one animation frame — `await new Promise(requestAnimationFrame)` — or await `app.ready()` before stepping, then call step() as normal.";
7106
+ if (!diagnosticsState.warnings.includes(pendingWarning))
7107
+ diagnosticsState.warnings.push(pendingWarning);
7108
+ diagnosticsState.drawCalls = 0;
7109
+ overlay?.update();
6880
7110
  }
6881
7111
  else {
6882
- render(performanceNow());
7112
+ /*
7113
+ * `render` records `lastTime` to derive its own delta. Feeding it simulated time would leave
7114
+ * `lastTime` in a different clock from `performanceNow()`, so the first frame after a
7115
+ * `resume()` would compute a wildly wrong delta — a visible jump. Restore it afterwards so
7116
+ * `step` stays side-effect-free with respect to live playback.
7117
+ */
7118
+ const previousLastTime = lastTime;
7119
+ render(simulatedMs);
7120
+ lastTime = previousLastTime;
6883
7121
  }
6884
7122
  runtimePaused = previousPaused;
6885
7123
  },
@@ -6903,6 +7141,16 @@ export function createAuraApp(target, options) {
6903
7141
  },
6904
7142
  dispose() {
6905
7143
  disposed = true;
7144
+ /*
7145
+ * Settle any in-flight mount so `await app.ready()` cannot hang on a disposed app. A promise
7146
+ * that never resolves is a worse failure than the one WS-2.9 fixed: it has no diagnostic at all.
7147
+ */
7148
+ productionMountPending = false;
7149
+ settleMountForDispose();
7150
+ for (const unsubscribe of [...deviceLostListeners.values(), ...deviceRestoredListeners.values()])
7151
+ unsubscribe?.();
7152
+ deviceLostListeners.clear();
7153
+ deviceRestoredListeners.clear();
6906
7154
  if (animationHandle && typeof cancelAnimationFrame !== "undefined")
6907
7155
  cancelAnimationFrame(animationHandle);
6908
7156
  for (const controller of ownedInputControllers)
@@ -6910,9 +7158,77 @@ export function createAuraApp(target, options) {
6910
7158
  ownedInputControllers.clear();
6911
7159
  productionController?.dispose();
6912
7160
  overlay?.dispose();
7161
+ unregisterAuraApp(app);
6913
7162
  }
6914
7163
  };
7164
+ registerAuraApp(app);
7165
+ return app;
6915
7166
  }
7167
+ /**
7168
+ * Live apps on this page, so tooling can act on a scene it did not create.
7169
+ *
7170
+ * ## Why this exists
7171
+ *
7172
+ * Visual-approval and screenshot gates need to *freeze* a scene before capturing it. Without a way
7173
+ * to reach the running app, they cannot: a route creates its app in module scope and typically never
7174
+ * exposes the handle, so an automated capture can only `waitForTimeout` and photograph whatever frame
7175
+ * the loop happened to reach.
7176
+ *
7177
+ * Measured consequence, before this existed: re-running the showcase screenshot spec with **no code
7178
+ * change** produced different bytes for 14 of 29 screenshots, because most routes animate and print
7179
+ * live telemetry. Any gate binding approval to a screenshot hash was therefore unsatisfiable — every
7180
+ * regeneration invalidated a still-correct signature.
7181
+ *
7182
+ * Deliberately a `Set` of handles rather than a global "the app", because a page may legitimately
7183
+ * mount several. Entries are removed on `dispose()` so a long-lived page does not leak.
7184
+ */
7185
+ const liveAuraApps = new Set();
7186
+ function registerAuraApp(app) {
7187
+ liveAuraApps.add(app);
7188
+ if (typeof globalThis !== "undefined") {
7189
+ globalThis.__AURA3D_LIVE_APPS__ = auraAppRegistry;
7190
+ }
7191
+ }
7192
+ function unregisterAuraApp(app) {
7193
+ liveAuraApps.delete(app);
7194
+ }
7195
+ export const auraAppRegistry = {
7196
+ kind: "aura3d-live-app-registry",
7197
+ count() {
7198
+ return liveAuraApps.size;
7199
+ },
7200
+ pauseAll() {
7201
+ for (const app of liveAuraApps)
7202
+ app.pause();
7203
+ return liveAuraApps.size;
7204
+ },
7205
+ resumeAll() {
7206
+ for (const app of liveAuraApps)
7207
+ app.resume();
7208
+ return liveAuraApps.size;
7209
+ },
7210
+ settle(steps = 30, dt = 1 / 60) {
7211
+ for (const app of liveAuraApps) {
7212
+ app.pause();
7213
+ /*
7214
+ * Rewind before stepping.
7215
+ *
7216
+ * Without this, `settle(30)` means "30 steps after however long the page took to load", which
7217
+ * differs run to run — and routes that animate from accumulated `time` then render a different
7218
+ * frame. Measured before this: 8 of 29 screenshots still drifted perceptually even with every
7219
+ * app paused and stepped identically.
7220
+ */
7221
+ const resettable = app;
7222
+ resettable.resetRuntimeClock?.();
7223
+ for (let index = 0; index < Math.max(0, steps); index += 1)
7224
+ app.step(dt);
7225
+ }
7226
+ return liveAuraApps.size;
7227
+ },
7228
+ all() {
7229
+ return [...liveAuraApps];
7230
+ }
7231
+ };
6916
7232
  export function createGameApp(target, options) {
6917
7233
  const { input, loop, runtimeEvidence, ...appOptions } = options;
6918
7234
  const app = createAuraApp(target, { ...appOptions, autoStart: false });
@@ -7168,6 +7484,14 @@ function worldLabelsFromSnapshot(snapshot, runtimeNodes) {
7168
7484
  fontSize: Math.max(11, Math.min(26, Math.round((node.size ?? 0.34) * 46))),
7169
7485
  leader: node.leader === true,
7170
7486
  offscreenPolicy: node.offscreenPolicy ?? (node.label === "hud" ? "draw" : "clamp"),
7487
+ /*
7488
+ * WS-2.7 — carry the declared option through. This line is the fix.
7489
+ *
7490
+ * `occlusionAware` has defaulted to true on every label factory since before 1.6 and was never
7491
+ * read here, so `WorldLabel` never received it and the renderer had nothing to act on. The option
7492
+ * was documented, accepted, and inert.
7493
+ */
7494
+ occlusionAware: node.occlusionAware ?? true,
7171
7495
  ...(node.label === "hud" && node.screenAnchor ? { screenAnchor: node.screenAnchor } : {}),
7172
7496
  ...(node.label === "hud" && !node.screenAnchor ? { screenAnchor: "top-left" } : {})
7173
7497
  };
@@ -7187,15 +7511,154 @@ function createSceneLabelLayer(canvas, snapshot) {
7187
7511
  return undefined;
7188
7512
  return createWorldLabelLayer({ container: canvas.parentElement ?? document.body, canvas });
7189
7513
  }
7190
- async function startProductionRender(canvas, snapshot, diagnosticsState, options, overlay, beforeRender, isPaused = () => false, runtimeNodes) {
7514
+ /**
7515
+ * WS-2.7 — build the occlusion test for a scene's annotations.
7516
+ *
7517
+ * ## What it does
7518
+ *
7519
+ * Walks the segment from the camera eye to a label's anchor and asks whether any renderable node's
7520
+ * world-space box lies across it. If one does, the thing being annotated is behind geometry.
7521
+ *
7522
+ * ## Why boxes and not triangles
7523
+ *
7524
+ * An annotation only needs to know whether its subject is *hidden*, and a node's bounding box answers
7525
+ * that at a fraction of the cost of a mesh raycast — one slab test per node against one segment, no BVH,
7526
+ * no per-frame allocation of any size. A box is conservative: it can report occlusion slightly early at a
7527
+ * silhouette edge. For a label that is the right direction to be wrong in; the alternative, reporting a
7528
+ * label as visible when its subject is behind a wall, is the defect being fixed.
7529
+ *
7530
+ * ## What it deliberately skips
7531
+ *
7532
+ * The node the label is anchored *to*. Every annotation sits on or just outside its own subject's
7533
+ * surface, so including it would report every label as occluded by the very geometry it annotates —
7534
+ * the label-shaped version of z-fighting.
7535
+ */
7536
+ function createSceneLabelOcclusionTest(snapshot, cameraEye, runtimeNodes) {
7537
+ const occluders = [];
7538
+ for (const node of groups.flatten(snapshot.nodes)) {
7539
+ if (node.kind !== "primitive" && !isRenderableModelNode(node))
7540
+ continue;
7541
+ if (node.visible === false)
7542
+ continue;
7543
+ const runtimeSnapshot = node.runtime?.id ? runtimeNodes?.get(node.runtime.id)?.snapshot() : undefined;
7544
+ if (runtimeSnapshot?.visible === false)
7545
+ continue;
7546
+ const position = (runtimeSnapshot?.position ?? node.position ?? [0, 0, 0]);
7547
+ const scale = (runtimeSnapshot?.scale ?? node.scale ?? [1, 1, 1]);
7548
+ /*
7549
+ * Local extents from the primitive's own geometry where available, and a unit box otherwise.
7550
+ * A model's real bounds come from its asset, which is not loaded synchronously here; the declared
7551
+ * scale is the honest approximation and is what the scene author controls.
7552
+ */
7553
+ const localBounds = node.kind === "primitive" ? primitiveGeometryBounds(node) : undefined;
7554
+ const halfLocal = localBounds
7555
+ ? [
7556
+ Math.max(1e-4, (localBounds.max[0] - localBounds.min[0]) / 2),
7557
+ Math.max(1e-4, (localBounds.max[1] - localBounds.min[1]) / 2),
7558
+ Math.max(1e-4, (localBounds.max[2] - localBounds.min[2]) / 2)
7559
+ ]
7560
+ : [0.5, 0.5, 0.5];
7561
+ const half = [
7562
+ Math.abs(halfLocal[0] * scale[0]),
7563
+ Math.abs(halfLocal[1] * scale[1]),
7564
+ Math.abs(halfLocal[2] * scale[2])
7565
+ ];
7566
+ occluders.push({
7567
+ min: [position[0] - half[0], position[1] - half[1], position[2] - half[2]],
7568
+ max: [position[0] + half[0], position[1] + half[1], position[2] + half[2]]
7569
+ });
7570
+ }
7571
+ if (occluders.length === 0)
7572
+ return () => false;
7573
+ /** Slab test: does the segment eye -> anchor enter this box strictly before reaching the anchor? */
7574
+ const segmentHitsBox = (anchor, box) => {
7575
+ let tEnter = 0;
7576
+ let tExit = 1;
7577
+ for (let axis = 0; axis < 3; axis += 1) {
7578
+ const origin = cameraEye[axis];
7579
+ const direction = anchor[axis] - origin;
7580
+ const min = box.min[axis];
7581
+ const max = box.max[axis];
7582
+ if (Math.abs(direction) < 1e-9) {
7583
+ // Parallel to this slab: outside it means the segment can never be inside the box.
7584
+ if (origin < min || origin > max)
7585
+ return false;
7586
+ continue;
7587
+ }
7588
+ const t1 = (min - origin) / direction;
7589
+ const t2 = (max - origin) / direction;
7590
+ tEnter = Math.max(tEnter, Math.min(t1, t2));
7591
+ tExit = Math.min(tExit, Math.max(t1, t2));
7592
+ if (tEnter > tExit)
7593
+ return false;
7594
+ }
7595
+ /*
7596
+ * `tEnter` must be meaningfully before the anchor. A box the anchor merely touches at t ~= 1 is the
7597
+ * subject's own surface, not an occluder — the epsilon is what keeps a label from occluding itself.
7598
+ */
7599
+ return tEnter > 1e-4 && tEnter < 1 - 1e-3;
7600
+ };
7601
+ return (anchor) => {
7602
+ for (const box of occluders) {
7603
+ // Skip the box the anchor is inside: that is the subject, and a subject cannot occlude its own label.
7604
+ const insideBox = anchor[0] >= box.min[0] && anchor[0] <= box.max[0]
7605
+ && anchor[1] >= box.min[1] && anchor[1] <= box.max[1]
7606
+ && anchor[2] >= box.min[2] && anchor[2] <= box.max[2];
7607
+ if (insideBox)
7608
+ continue;
7609
+ if (segmentHitsBox(anchor, box))
7610
+ return true;
7611
+ }
7612
+ return false;
7613
+ };
7614
+ }
7615
+ async function startProductionRender(canvas, snapshot, diagnosticsState, options, overlay, beforeRender, isPaused = () => false, runtimeNodes,
7616
+ /**
7617
+ * Extra reason to keep rendering, beyond what the scene declares.
7618
+ *
7619
+ * See {@link shouldContinuouslyRender}: scene-declared motion cannot know about a
7620
+ * simulation or a frame callback the route drives itself.
7621
+ */
7622
+ requiresFrames = () => false,
7623
+ /**
7624
+ * Render clock to use while paused, in milliseconds.
7625
+ *
7626
+ * Supplied by the app so a paused scene renders at its own simulated time rather than at
7627
+ * wall-clock time. Without it, `pause()` froze the simulation but not time-driven rendering.
7628
+ */
7629
+ pausedRenderTime = () => 0) {
7191
7630
  const renderableNode = snapshot.nodes.find(isWebGLRenderableNode);
7192
7631
  if (!renderableNode) {
7193
7632
  throw new AuraRuntimeError("missing-asset", "Aura3D production rendering requires at least one typed model asset or primitive. Suggested fix: add model(assets.product), primitives.box(), primitives.sphere(), primitives.cylinder(), or primitives.plane().");
7194
7633
  }
7195
7634
  const renderer = await createProductionSceneRenderer(canvas, snapshot, options.renderer, runtimeNodes);
7196
7635
  diagnosticsState.renderer = renderer.diagnostics;
7197
- const continuousRender = shouldContinuouslyRender(snapshot);
7636
+ const sceneWantsFrames = shouldContinuouslyRender(snapshot);
7198
7637
  const labelLayer = createSceneLabelLayer(canvas, snapshot);
7638
+ const rendererSelection = normalizeCreateAppRendererOptions(options.renderer);
7639
+ const pixelRatio = options.pixelRatio ?? rendererSelection.profile.pixelRatio ?? devicePixelRatioSafe();
7640
+ const resizeRenderer = () => {
7641
+ if (options.resize === false)
7642
+ return;
7643
+ const parentRect = canvas.parentElement?.getBoundingClientRect();
7644
+ const cssWidth = parentRect?.width || canvas.clientWidth || (typeof window !== "undefined" ? window.innerWidth : 960) || 960;
7645
+ const cssHeight = parentRect?.height || canvas.clientHeight || (typeof window !== "undefined" ? window.innerHeight : 540) || 540;
7646
+ const width = Math.max(320, Math.round(cssWidth * pixelRatio));
7647
+ const height = Math.max(220, Math.round(cssHeight * pixelRatio));
7648
+ if (canvas.width === width && canvas.height === height)
7649
+ return;
7650
+ canvas.style.width = `${cssWidth}px`;
7651
+ canvas.style.height = `${cssHeight}px`;
7652
+ canvas.width = width;
7653
+ canvas.height = height;
7654
+ renderer.resize?.(width, height);
7655
+ };
7656
+ const resizeObserver = options.resize !== false && typeof ResizeObserver !== "undefined" && canvas.parentElement
7657
+ ? new ResizeObserver(resizeRenderer)
7658
+ : undefined;
7659
+ resizeObserver?.observe(canvas.parentElement);
7660
+ if (options.resize !== false && typeof window !== "undefined")
7661
+ window.addEventListener("resize", resizeRenderer);
7199
7662
  let disposed = false;
7200
7663
  let animationHandle = 0;
7201
7664
  let lastTime = 0;
@@ -7206,7 +7669,21 @@ async function startProductionRender(canvas, snapshot, diagnosticsState, options
7206
7669
  lastTime = time;
7207
7670
  if (!isPaused())
7208
7671
  beforeRender?.(delta / 1000, "raf");
7209
- const drawCalls = renderer.render(time);
7672
+ /*
7673
+ * A paused app must not keep advancing time-driven rendering.
7674
+ *
7675
+ * `isPaused()` gated the simulation callback but not the render clock, so `pause()` stopped
7676
+ * gameplay while particle emitters, animated materials and anything else keyed to elapsed time
7677
+ * carried on. Measured on the particle lab: with the app paused *and* settled to a fixed frame,
7678
+ * two screenshots taken 500 ms apart in the **same page load** still differed across 20.6% of the
7679
+ * vortex region. The frame was never actually held, so no capture of it could be reproducible.
7680
+ *
7681
+ * While paused, render is pinned to the time of the last simulated frame. `app.step()` advances
7682
+ * `runtimeTime` and renders explicitly, so stepping still produces new frames — what stops is
7683
+ * time passing on its own.
7684
+ */
7685
+ const renderTime = isPaused() ? pausedRenderTime() : time;
7686
+ const drawCalls = renderer.render(renderTime);
7210
7687
  diagnosticsState.backend = renderer.backend;
7211
7688
  diagnosticsState.fps = diagnosticsState.fps || 60;
7212
7689
  diagnosticsState.drawCalls = drawCalls;
@@ -7217,28 +7694,66 @@ async function startProductionRender(canvas, snapshot, diagnosticsState, options
7217
7694
  // Reproject every frame against the renderer's own camera so labels track
7218
7695
  // their anchors while the camera moves.
7219
7696
  labelLayer.setLabels(worldLabelsFromSnapshot(snapshot, runtimeNodes));
7220
- labelLayer.update(renderer.viewProjection(time));
7697
+ /*
7698
+ * WS-2.7 — rebuilt per frame, because both the camera and any runtime-driven node can move. The
7699
+ * cost is one bounding box per renderable node, which is negligible beside the render itself, and
7700
+ * caching it would silently stop occluding as soon as anything moved.
7701
+ */
7702
+ labelLayer.setOcclusionTest(createSceneLabelOcclusionTest(snapshot, resolveCameraEye(snapshot, snapshot.camera, renderTime, runtimeNodes), runtimeNodes));
7703
+ labelLayer.update(renderer.viewProjection(renderTime));
7221
7704
  diagnosticsState.labels = labelLayer.snapshot();
7222
7705
  }
7223
7706
  overlay?.update();
7224
- if (continuousRender && options.autoStart !== false && typeof requestAnimationFrame !== "undefined") {
7707
+ if ((sceneWantsFrames || requiresFrames()) && options.autoStart !== false && typeof requestAnimationFrame !== "undefined") {
7225
7708
  animationHandle = requestAnimationFrame(renderFrame);
7226
7709
  }
7227
7710
  };
7228
7711
  renderFrame();
7229
7712
  return {
7230
7713
  render(time = performanceNow()) {
7714
+ /*
7715
+ * Preserve `lastTime` across an explicit render.
7716
+ *
7717
+ * `renderFrame` records `lastTime` to derive its own delta and to drive `beforeRender`. When
7718
+ * `app.step()` renders at *simulated* time for reproducibility, leaving `lastTime` on the
7719
+ * simulated clock means the first live frame after `resume()` computes its delta against a
7720
+ * different clock — a visible jump. Same defect the canvas2d path had; fixed identically so
7721
+ * the two render paths cannot disagree about what `step` does.
7722
+ */
7723
+ const previousLastTime = lastTime;
7231
7724
  renderFrame(time);
7725
+ lastTime = previousLastTime;
7232
7726
  },
7727
+ // WS-2.6 — forward device-loss subscription from whichever renderer backs this controller.
7728
+ onDeviceLost: renderer.onDeviceLost ? (listener) => renderer.onDeviceLost(listener) : undefined,
7729
+ onDeviceRestored: renderer.onDeviceRestored ? (listener) => renderer.onDeviceRestored(listener) : undefined,
7730
+ deviceLost: renderer.deviceLost ? () => renderer.deviceLost() : undefined,
7233
7731
  dispose() {
7234
7732
  disposed = true;
7235
7733
  if (animationHandle && typeof cancelAnimationFrame !== "undefined")
7236
7734
  cancelAnimationFrame(animationHandle);
7735
+ resizeObserver?.disconnect();
7736
+ if (typeof window !== "undefined")
7737
+ window.removeEventListener("resize", resizeRenderer);
7237
7738
  labelLayer?.dispose();
7238
7739
  renderer.dispose();
7239
7740
  }
7240
7741
  };
7241
7742
  }
7743
+ /**
7744
+ * Whether the *scene* declares something that changes over time.
7745
+ *
7746
+ * Deliberately conservative: a fully static scene should render once and stop rather than
7747
+ * burn a core redrawing an identical frame.
7748
+ *
7749
+ * It is not sufficient on its own, and that was a real defect. This predicate can only see
7750
+ * what the scene *declares* — a looping timeline, a moving camera, an animated or mutable
7751
+ * node. It cannot see a physics simulation or a frame callback the route drives itself. So
7752
+ * an app whose only motion came from `app.physics.step()` inside `app.onFrame()` rendered
7753
+ * exactly **one** frame and froze: measured `frames: 1` with no console error, in a
7754
+ * clean-room project where every crate should have been falling. Callers therefore combine
7755
+ * this with their own `requiresFrames()`.
7756
+ */
7242
7757
  function shouldContinuouslyRender(snapshot) {
7243
7758
  if (snapshot.timeline?.mode === "loop")
7244
7759
  return true;
@@ -7262,9 +7777,7 @@ async function createProductionSceneRenderer(canvas, snapshot, rendererOptions,
7262
7777
  const flattened = groups.flatten(snapshot.nodes);
7263
7778
  const eligibility = analyzeProductionBridgeEligibility(flattened);
7264
7779
  if (!eligibility.eligible) {
7265
- return await createWebGLSceneRenderer(canvas, snapshot, rendererOptions, [
7266
- `Production bridge skipped: ${eligibility.reasons.join("; ")}.`
7267
- ], runtimeNodes);
7780
+ throw new AuraRuntimeError("backend-fallback", `Aura3D production renderer rejected this scene: ${eligibility.reasons.join("; ")}. Suggested fix: import models through generated typed aura-assets.`);
7268
7781
  }
7269
7782
  try {
7270
7783
  return await createProductionRuntimeSceneRenderer(canvas, snapshot, rendererOptions, runtimeNodes);
@@ -7277,7 +7790,57 @@ async function createProductionSceneRenderer(canvas, snapshot, rendererOptions,
7277
7790
  }
7278
7791
  let cachedProductionRuntimeFallbackLights;
7279
7792
  function createProductionRuntimeEnvironment(snapshot) {
7280
- const names = groups.flatten(snapshot.nodes).map((node) => "name" in node ? node.name?.toLowerCase() ?? "" : "");
7793
+ const nodes = groups.flatten(snapshot.nodes);
7794
+ const authoredEnvironment = nodes.find((node) => node.kind === "environment");
7795
+ if (authoredEnvironment) {
7796
+ const presetByEnvironment = {
7797
+ "studio": "studio",
7798
+ "material-lab": "inspection",
7799
+ "product-hero": "softbox",
7800
+ "night-cinematic": "evening",
7801
+ "metal-studio": "exhibit",
7802
+ "glass-studio": "softbox"
7803
+ };
7804
+ const preset = presetByEnvironment[authoredEnvironment.environment];
7805
+ const bundle = createExternalParityEnvironmentLighting(preset);
7806
+ const intensity = nonNegativeFinite(authoredEnvironment.intensity);
7807
+ const proceduralMap = bundle.lighting.proceduralMap;
7808
+ return {
7809
+ preset: authoredEnvironment.environment,
7810
+ intensity,
7811
+ evidence: `production bridge submitted the authored ${authoredEnvironment.environment} environment through generated HDR ${preset} lighting`,
7812
+ lighting: {
7813
+ ...bundle.lighting,
7814
+ color: authoredEnvironment.color ? colorToLinearRgb(authoredEnvironment.color) : bundle.lighting.color,
7815
+ intensity: bundle.lighting.intensity * intensity,
7816
+ ...(proceduralMap ? {
7817
+ proceduralMap: {
7818
+ ...proceduralMap,
7819
+ intensity: proceduralMap.intensity * intensity,
7820
+ specularIntensity: proceduralMap.specularIntensity * intensity
7821
+ }
7822
+ } : {}),
7823
+ environmentMapIntensity: (bundle.lighting.environmentMapIntensity ?? 0) * intensity,
7824
+ environmentMapSpecularIntensity: (bundle.lighting.environmentMapSpecularIntensity ?? 0) * intensity
7825
+ }
7826
+ };
7827
+ }
7828
+ const ambientLights = nodes.filter((node) => node.kind === "light" && node.light === "ambient" && node.intensity > 0);
7829
+ if (ambientLights.length > 0) {
7830
+ const intensity = ambientLights.reduce((total, light) => total + light.intensity, 0);
7831
+ const color = ambientLights.reduce((sum, light) => {
7832
+ const linear = colorToLinearRgb(light.color ?? "#ffffff");
7833
+ const weight = light.intensity / intensity;
7834
+ return [sum[0] + linear[0] * weight, sum[1] + linear[1] * weight, sum[2] + linear[2] * weight];
7835
+ }, [0, 0, 0]);
7836
+ return {
7837
+ preset: "authored-ambient",
7838
+ intensity,
7839
+ evidence: `production bridge submitted ${ambientLights.length} authored ambient light${ambientLights.length === 1 ? "" : "s"} without an implicit environment map`,
7840
+ lighting: { color, intensity, environmentMapIntensity: 0, environmentMapSpecularIntensity: 0 }
7841
+ };
7842
+ }
7843
+ const names = nodes.map((node) => "name" in node ? node.name?.toLowerCase() ?? "" : "");
7281
7844
  const category = resolveRendererSceneCategory(snapshot, names);
7282
7845
  const preset = category === "city-day" ? "daylight"
7283
7846
  : category === "city-night" ? "evening"
@@ -7306,7 +7869,7 @@ function createProductionRuntimeEnvironmentFog(snapshot) {
7306
7869
  return false;
7307
7870
  const density = clampNumber(fog.density ?? 0.12, 0, 1);
7308
7871
  const intensity = clampNumber(fog.intensity ?? 0.5, 0, 1);
7309
- const color = colorToRgb(fog.color ?? "#9fb7d9");
7872
+ const color = colorToLinearRgb(fog.color ?? "#9fb7d9");
7310
7873
  return {
7311
7874
  // Exponential-squared reads as depth haze rather than a hard linear band, which
7312
7875
  // matches what the public helper documents.
@@ -7320,16 +7883,8 @@ function createProductionRuntimeEnvironmentFog(snapshot) {
7320
7883
  }
7321
7884
  function createProductionRuntimePostprocess(snapshot) {
7322
7885
  const nodes = groups.flatten(snapshot.nodes);
7323
- const names = nodes.map((node) => "name" in node ? node.name?.toLowerCase() ?? "" : "");
7324
- const category = resolveRendererSceneCategory(snapshot, names);
7325
7886
  const authoredBloom = nodes.find((node) => node.kind === "effect" && node.effect === "bloom");
7326
- const emissiveSubjects = nodes.filter((node) => {
7327
- if (!("material" in node) || !node.material)
7328
- return false;
7329
- return Boolean(node.material.emissive) && (node.material.emissiveIntensity ?? 1) > 0;
7330
- }).length;
7331
- const darkScene = ["neon", "city-night", "space", "game"].includes(category);
7332
- const bloomRequested = Boolean(authoredBloom) || (darkScene && emissiveSubjects > 0);
7887
+ const bloomRequested = Boolean(authoredBloom);
7333
7888
  // Root previously advertised `ssao` in `requestedPasses` whenever a scene added
7334
7889
  // effects.ambientOcclusion() or effects.contactOcclusion(), but never submitted
7335
7890
  // an `ssao` option to the renderer. The advertised pass could therefore never
@@ -7338,20 +7893,15 @@ function createProductionRuntimePostprocess(snapshot) {
7338
7893
  const authoredAmbientOcclusion = nodes.find((node) => node.kind === "effect" && node.effect === "ambient-occlusion");
7339
7894
  const authoredContactOcclusion = nodes.find((node) => node.kind === "effect" && node.effect === "contact-occlusion");
7340
7895
  const authoredOcclusion = authoredAmbientOcclusion ?? authoredContactOcclusion;
7341
- const exposure = category === "city-day" ? 1.02
7342
- : category === "material" || category === "product" ? 1.08
7343
- : category === "space" ? 1.18
7344
- : 1.12;
7345
- const saturation = category === "material" || category === "product" ? 1.01
7346
- : category === "city-day" ? 1.04
7347
- : 1.1;
7348
7896
  return {
7349
- targetFormat: "rgba8",
7897
+ // Tone mapping requires unclamped linear input. RGBA8 quantized dark clear
7898
+ // colors and clipped highlights before ACES, which produced washed-out output.
7899
+ targetFormat: "rgba16f",
7350
7900
  ...(bloomRequested ? {
7351
7901
  bloom: {
7352
- threshold: clampNumber(authoredBloom?.threshold ?? (darkScene ? 0.68 : 0.78), 0, 1),
7353
- intensity: clampNumber(authoredBloom?.intensity ?? Math.min(0.5, 0.24 + emissiveSubjects * 0.035), 0, 2),
7354
- radius: Math.max(1, Math.min(4, Math.round(authoredBloom?.radius ?? (category === "space" ? 3 : 2))))
7902
+ threshold: clampNumber(authoredBloom?.threshold ?? 0.78, 0, 1),
7903
+ intensity: clampNumber(authoredBloom?.intensity ?? 0.3, 0, 2),
7904
+ radius: Math.max(1, Math.min(4, Math.round(authoredBloom?.radius ?? 2)))
7355
7905
  }
7356
7906
  } : {}),
7357
7907
  ...(authoredOcclusion ? {
@@ -7367,22 +7917,11 @@ function createProductionRuntimePostprocess(snapshot) {
7367
7917
  }
7368
7918
  } : {}),
7369
7919
  toneMapping: {
7370
- exposure,
7371
- whitePoint: category === "city-day" ? 1.28 : 1.18,
7372
- operator: "filmic",
7920
+ exposure: 1,
7921
+ whitePoint: 1,
7922
+ operator: "aces",
7373
7923
  inputColorSpace: "linear",
7374
7924
  outputColorSpace: "srgb"
7375
- },
7376
- colorGrade: {
7377
- contrast: category === "material" || category === "product" ? 1.06 : 1.1,
7378
- saturation,
7379
- vibrance: darkScene ? 0.14 : 0.06,
7380
- vignette: category === "game" || category === "neon" ? 0.1 : 0.04,
7381
- sharpening: category === "material" || category === "product" ? 0.28 : 0.2
7382
- },
7383
- fxaa: {
7384
- edgeThreshold: category === "game" ? 0.07 : 0.09,
7385
- subpixelBlend: category === "material" || category === "product" ? 0.48 : 0.58
7386
7925
  }
7387
7926
  };
7388
7927
  }
@@ -7447,7 +7986,8 @@ function createProductionRuntimePostprocessObservation(diagnostics) {
7447
7986
  contactOcclusionReceiver: actualPasses.includes("contact-shadow"),
7448
7987
  pixelBacked,
7449
7988
  actualPasses,
7450
- fallbackPasses: pixelBacked ? [] : ["direct-render"]
7989
+ fallbackPasses: pixelBacked ? [] : ["direct-render"],
7990
+ targetFormat: diagnostics.postprocessTargetFormat
7451
7991
  };
7452
7992
  }
7453
7993
  function createProductionRuntimeCollectedLights(snapshot) {
@@ -7468,7 +8008,7 @@ function createProductionRuntimeCollectedLights(snapshot) {
7468
8008
  }
7469
8009
  function createProductionRuntimeLightDescriptors(node, authoredIndex) {
7470
8010
  const name = node.name?.trim() || `aura-authored-${node.light}-${authoredIndex + 1}`;
7471
- const color = colorToRgb(node.color ?? "#ffffff");
8011
+ const color = colorToLinearRgb(node.color ?? "#ffffff");
7472
8012
  const intensity = nonNegativeFinite(node.intensity);
7473
8013
  const position = productionRuntimeLightPosition(node);
7474
8014
  const direction = productionRuntimeLightDirection(node, position);
@@ -7745,17 +8285,40 @@ function multiplyRgb(color, tint) {
7745
8285
  async function createProductionRuntimeSceneRenderer(canvas, snapshot, rendererOptions, runtimeNodes) {
7746
8286
  const flattened = groups.flatten(snapshot.nodes);
7747
8287
  const modelNodes = flattened.filter((node) => isRenderableModelNode(node) && createAssetProvenance(node.asset).source === "typed-aura-assets-manifest");
7748
- const actorEntries = await Promise.all(modelNodes.map(async (node, index) => ({
7749
- node,
7750
- actor: await createTypedGLBActor({
7751
- asset: node.asset,
7752
- id: node.runtime?.id ?? node.asset.id ?? `model-${index + 1}`,
7753
- name: node.name ?? node.asset.id ?? `model-${index + 1}`,
7754
- width: canvas.width,
7755
- height: canvas.height,
7756
- ...(node.material?.color ? { tint: { baseColor: colorToRgba(node.material.color) } } : {})
7757
- })
7758
- })));
8288
+ /*
8289
+ * Loaded here rather than imported at module scope (WS-2.2). This function is only reached when the
8290
+ * scene contains a typed GLB, so the glTF loader is downloaded exactly when it is needed.
8291
+ */
8292
+ const actorEntries = modelNodes.length > 0
8293
+ ? await (async () => {
8294
+ const { createTypedGLBActor } = await import("../production-runtime/TypedGLBActor.js");
8295
+ return await Promise.all(modelNodes.map(async (node, index) => ({
8296
+ node,
8297
+ actor: await createTypedGLBActor({
8298
+ asset: node.asset,
8299
+ id: node.runtime?.id ?? node.asset.id ?? `model-${index + 1}`,
8300
+ name: node.name ?? node.asset.id ?? `model-${index + 1}`,
8301
+ width: canvas.width,
8302
+ height: canvas.height,
8303
+ ...(node.hiddenNodeNames ? { hiddenNodeNames: node.hiddenNodeNames } : {}),
8304
+ ...(node.material?.color ? {
8305
+ tint: {
8306
+ baseColor: colorToLinearRgba(node.material.color),
8307
+ replaceSurfaceTextures: true,
8308
+ ...(node.material.emissive ? { emissiveColor: colorToLinearRgb(node.material.emissive) } : {}),
8309
+ ...(node.material.emissiveIntensity === undefined ? {} : { emissiveStrength: node.material.emissiveIntensity }),
8310
+ ...(node.material.roughness === undefined ? {} : { roughness: clamp01(node.material.roughness) }),
8311
+ ...(node.material.metallic === undefined && node.material.metalness === undefined
8312
+ ? {}
8313
+ : { metallic: clamp01(node.material.metallic ?? node.material.metalness ?? 0) }),
8314
+ ...(node.material.clearcoat === undefined ? {} : { clearcoat: clamp01(node.material.clearcoat) }),
8315
+ ...(node.material.clearcoatRoughness === undefined ? {} : { clearcoatRoughness: clamp01(node.material.clearcoatRoughness) })
8316
+ }
8317
+ } : {})
8318
+ })
8319
+ })));
8320
+ })()
8321
+ : [];
7759
8322
  const primitiveEntries = createProductionRuntimePrimitiveEntries(flattened);
7760
8323
  const productionRenderer = await ProductionRuntimeRenderer.create({
7761
8324
  canvas,
@@ -7764,7 +8327,9 @@ async function createProductionRuntimeSceneRenderer(canvas, snapshot, rendererOp
7764
8327
  backend: "webgl2",
7765
8328
  antialias: true,
7766
8329
  preserveDrawingBuffer: true,
7767
- clearColor: colorToClearColor(snapshot.background)
8330
+ // Background colors are display intent. Pre-invert the renderer's coupled
8331
+ // matrix-fitted ACES transform so presentation preserves that authored color.
8332
+ clearColor: colorToAcesInputClearColor(snapshot.background)
7768
8333
  });
7769
8334
  let latestDeviceDiagnostics = productionRenderer.getDiagnostics();
7770
8335
  let latestFeatures = productionRenderer.getFeatures();
@@ -7805,7 +8370,15 @@ async function createProductionRuntimeSceneRenderer(canvas, snapshot, rendererOp
7805
8370
  .filter((feature) => feature.state !== "supported")
7806
8371
  .map((feature) => `Production runtime feature ${feature.id} is ${feature.state}: ${feature.detail}`),
7807
8372
  ...runtimeWarnings
7808
- ]
8373
+ ],
8374
+ deviceDiagnostics: latestDeviceDiagnostics,
8375
+ lodSelections: primitiveEntries
8376
+ .filter((entry) => Boolean(entry.node.lod?.levels.length))
8377
+ .map((entry) => ({
8378
+ nodeName: entry.node.name ?? "unnamed distance LOD",
8379
+ levelIndex: entry.currentLodIndex,
8380
+ levelName: entry.resources[entry.currentLodIndex]?.name ?? `level-${entry.currentLodIndex}`
8381
+ }))
7809
8382
  }, rendererOptions);
7810
8383
  return {
7811
8384
  get backend() {
@@ -7825,19 +8398,35 @@ async function createProductionRuntimeSceneRenderer(canvas, snapshot, rendererOp
7825
8398
  viewProjection(time) {
7826
8399
  return createViewProjection(snapshot, canvas.width / Math.max(1, canvas.height), time, runtimeNodes);
7827
8400
  },
8401
+ resize(width, height) {
8402
+ productionRenderer.resize(width, height);
8403
+ },
8404
+ onDeviceLost(listener) {
8405
+ return productionRenderer.onDeviceLost(listener);
8406
+ },
8407
+ onDeviceRestored(listener) {
8408
+ return productionRenderer.onDeviceRestored(listener);
8409
+ },
8410
+ deviceLost() {
8411
+ return productionRenderer.deviceLost();
8412
+ },
7828
8413
  dispose() {
7829
8414
  productionRenderer.dispose();
7830
8415
  for (const { actor } of actorEntries)
7831
8416
  actor.pipeline.dispose();
7832
- for (const { geometry, material } of primitiveEntries) {
7833
- geometry.dispose();
7834
- material.dispose();
8417
+ for (const { resources } of primitiveEntries) {
8418
+ for (const { geometry, material } of resources) {
8419
+ geometry.dispose();
8420
+ material.dispose();
8421
+ }
7835
8422
  }
7836
8423
  }
7837
8424
  };
7838
8425
  }
7839
8426
  function createProductionRuntimeRendererInput(snapshot, canvas, actorEntries, primitiveEntries, time, runtimeNodes, runtimeWarnings, environmentLighting, collectedLights) {
7840
8427
  const items = [];
8428
+ const viewProjectionMatrix = createViewProjection(snapshot, canvas.width / Math.max(1, canvas.height), time, runtimeNodes);
8429
+ const cameraPosition = resolveCameraEye(snapshot, snapshot.camera, time, runtimeNodes);
7841
8430
  for (const entry of actorEntries) {
7842
8431
  const currentState = resolveProductionActorRuntimeState(entry, runtimeNodes);
7843
8432
  const currentNode = currentState.node;
@@ -7854,16 +8443,16 @@ function createProductionRuntimeRendererInput(snapshot, canvas, actorEntries, pr
7854
8443
  const currentState = resolveProductionPrimitiveRuntimeState(entry, runtimeNodes);
7855
8444
  if (!currentState.visible)
7856
8445
  continue;
8446
+ const resource = selectProductionPrimitiveResource(entry, currentState.node, cameraPosition);
7857
8447
  items.push({
7858
- geometry: entry.geometry,
7859
- material: entry.material,
7860
- modelMatrix: createModelMatrix(currentState.node, entry.bounds, false, time),
8448
+ geometry: resource.geometry,
8449
+ material: resource.material,
8450
+ modelMatrix: createModelMatrix(currentState.node, resource.bounds, false, time),
7861
8451
  label: currentState.node.name ?? `aura-primitive-${currentState.node.primitive}`,
7862
- includeInAutoFrame: false
8452
+ includeInAutoFrame: false,
8453
+ ...(currentState.node.instances ? { instanceTransforms: createProductionInstanceTransforms(currentState.node.instances, currentState.node), instanceColors: createProductionInstanceColors(currentState.node.instanceColors, currentState.node.instances.length) } : {})
7863
8454
  });
7864
8455
  }
7865
- const viewProjectionMatrix = createViewProjection(snapshot, canvas.width / Math.max(1, canvas.height), time, runtimeNodes);
7866
- const cameraPosition = resolveCameraEye(snapshot, snapshot.camera, time, runtimeNodes);
7867
8456
  const source = {
7868
8457
  collectRenderItems: () => items,
7869
8458
  cameraPolicy: "require",
@@ -7880,18 +8469,57 @@ function createProductionRuntimeRendererInput(snapshot, canvas, actorEntries, pr
7880
8469
  return {
7881
8470
  source,
7882
8471
  camera: cameraLike,
7883
- metadata: createProductionRuntimeMetadata(actorEntries)
8472
+ metadata: createProductionRuntimeMetadata(actorEntries, primitiveEntries)
7884
8473
  };
7885
8474
  }
7886
8475
  function createProductionRuntimePrimitiveEntries(nodes) {
7887
8476
  return nodes
7888
8477
  .filter((node) => node.kind === "primitive")
7889
- .map((node) => ({
7890
- node,
7891
- geometry: createProductionPrimitiveGeometry(node),
7892
- material: createProductionPrimitiveMaterial(node),
7893
- bounds: primitiveGeometryBounds(node.primitive)
7894
- }));
8478
+ .map((node) => ({ node, resources: createProductionPrimitiveResources(node), currentLodIndex: 0 }));
8479
+ }
8480
+ function createProductionPrimitiveResources(node) {
8481
+ const levels = node.lod?.levels;
8482
+ if (!levels?.length) {
8483
+ return [{ geometry: createProductionPrimitiveGeometry(node), material: createProductionPrimitiveMaterial(node), bounds: primitiveGeometryBounds(node), name: node.name ?? node.primitive }];
8484
+ }
8485
+ return levels.map((level, index) => {
8486
+ if (!level.primitive && !level.geometry)
8487
+ throw new Error(`Aura3D LOD level ${index} requires primitive or custom geometry.`);
8488
+ const levelNode = {
8489
+ ...node,
8490
+ primitive: level.geometry ? "custom" : level.primitive,
8491
+ geometry: level.geometry,
8492
+ material: level.material ?? node.material,
8493
+ lod: undefined
8494
+ };
8495
+ return { geometry: createProductionPrimitiveGeometry(levelNode), material: createProductionPrimitiveMaterial(levelNode), bounds: primitiveGeometryBounds(levelNode), name: level.name };
8496
+ });
8497
+ }
8498
+ function selectProductionPrimitiveResource(entry, node, cameraPosition) {
8499
+ if (!node.lod?.levels.length)
8500
+ return entry.resources[0];
8501
+ const position = node.position ?? [0, 0, 0];
8502
+ const distance = Math.hypot(cameraPosition[0] - position[0], cameraPosition[1] - position[1], cameraPosition[2] - position[2]);
8503
+ const selection = selectAuraRootLodLevel(distance, node.lod.levels, entry.currentLodIndex, node.lod.hysteresis ?? 0);
8504
+ entry.currentLodIndex = selection.levelIndex;
8505
+ return entry.resources[selection.levelIndex] ?? entry.resources[entry.resources.length - 1];
8506
+ }
8507
+ function createProductionInstanceTransforms(transforms, node) {
8508
+ const matrices = new Float32Array(transforms.length * 16);
8509
+ transforms.forEach((transform, index) => {
8510
+ const localNode = { kind: "primitive", primitive: node.primitive, ...transform };
8511
+ matrices.set(createModelMatrix(localNode, { min: [-0.5, -0.5, -0.5], max: [0.5, 0.5, 0.5] }, false, 0), index * 16);
8512
+ });
8513
+ return matrices;
8514
+ }
8515
+ function createProductionInstanceColors(colors, count) {
8516
+ if (!colors)
8517
+ return undefined;
8518
+ if (colors.length !== count)
8519
+ throw new Error("Aura3D instance color count must match instance transform count.");
8520
+ const values = new Float32Array(count * 4);
8521
+ colors.forEach((color, index) => values.set(colorToLinearRgba(color), index * 4));
8522
+ return values;
7895
8523
  }
7896
8524
  function resolveProductionPrimitiveRuntimeState(entry, runtimeNodes) {
7897
8525
  const runtimeId = entry.node.runtime?.id;
@@ -7911,11 +8539,91 @@ function resolveProductionPrimitiveRuntimeState(entry, runtimeNodes) {
7911
8539
  visible: runtimeSnapshot.visible
7912
8540
  };
7913
8541
  }
8542
+ /**
8543
+ * Build the production `PBRMaterial` for a primitive node.
8544
+ *
8545
+ * ## WS-2.1a — the parameter drop this function used to be
8546
+ *
8547
+ * Until 1.6 this forwarded `clearcoat` and **nothing else** from the extended material surface.
8548
+ * `AuraMaterialSpec` accepts `sheen`, `sheenRoughness`, `sheenColor`, `iridescence`,
8549
+ * `iridescenceIOR`, `iridescenceThicknessRange`, `anisotropy`, `anisotropyRotation`, `transmission`,
8550
+ * `thickness`, `ior`, `attenuationColor` and `attenuationDistance`; `PBRMaterial` accepts a uniform
8551
+ * for every one of them and binds them all. The bridge between the two dropped them on the floor.
8552
+ *
8553
+ * How that presented, measured by `tools/material-structural-parity` before the fix:
8554
+ *
8555
+ * ```
8556
+ * material.pbr({ ..., sheen: 1 }) -> byte-identical frame to sheen: 0
8557
+ * material.pbr({ ..., iridescence: 1 }) -> byte-identical frame to iridescence: 0
8558
+ * material.pbr({ ..., clearcoat: 1 }) -> byte-identical frame to clearcoat: 0
8559
+ * ```
8560
+ *
8561
+ * `clearcoat` was *forwarded* and still produced an identical frame, which pins the second, separate
8562
+ * defect: `a3dPbrExtensionEnvironmentLight` in `ShaderChunks.ts` adds its lobes to
8563
+ * `specularRadiance`, a term that is zero without an environment map. Forwarding alone is necessary
8564
+ * and not sufficient, so `environmentIntensity` now also has a floor when an extension factor is
8565
+ * present — otherwise a developer setting `clearcoat: 1` on a scene with no environment sees nothing
8566
+ * and has no way to find out why.
8567
+ *
8568
+ * The reason this survived so long is worth stating: three of the four lobes are scalar
8569
+ * approximations rather than real BRDFs, and **nobody could tell**, because the parameters never
8570
+ * arrived. The plumbing defect concealed the shading defect.
8571
+ */
7914
8572
  function createProductionPrimitiveMaterial(node) {
7915
8573
  const materialSpec = node.material;
7916
- const baseColor = colorToRgba(materialSpec?.color ?? materialSpec?.emissive ?? "#d7dee8");
8574
+ const baseColor = colorToLinearRgba(materialSpec?.color ?? materialSpec?.emissive ?? "#d7dee8");
7917
8575
  const opacity = clamp01(materialSpec?.opacity ?? baseColor[3] ?? 1);
7918
- const emissiveColor = materialSpec?.emissive ? colorToRgb(materialSpec.emissive) : [0, 0, 0];
8576
+ const emissiveColor = materialSpec?.emissive ? colorToLinearRgb(materialSpec.emissive) : [0, 0, 0];
8577
+ const clearcoat = clamp01(materialSpec?.clearcoat ?? 0);
8578
+ const sheen = clamp01(materialSpec?.sheen ?? 0);
8579
+ const iridescence = clamp01(materialSpec?.iridescence ?? 0);
8580
+ const anisotropy = clamp01(Math.abs(materialSpec?.anisotropy ?? 0));
8581
+ const transmission = clamp01(materialSpec?.transmission ?? 0);
8582
+ const thicknessRange = materialSpec?.iridescenceThicknessRange;
8583
+ /*
8584
+ * Sheen colour defaults to the sheen strength as a white tint rather than to black.
8585
+ * `sheenColorFactor: [0,0,0]` multiplies the entire sheen lobe to zero, so `sheen: 1` with no
8586
+ * explicit colour would forward a factor and still render nothing — the same silent-no-op shape
8587
+ * this whole workstream exists to remove.
8588
+ */
8589
+ const sheenColor = materialSpec?.sheenColor
8590
+ ? colorToLinearRgb(materialSpec.sheenColor).map((channel) => channel * sheen)
8591
+ : [sheen, sheen, sheen];
8592
+ /*
8593
+ * The extension lobes are environment-driven. With `environmentIntensity: 0` a declared clearcoat,
8594
+ * sheen or iridescence factor reaches the shader and contributes exactly nothing, which is
8595
+ * indistinguishable from the parameter never arriving. A floor of 0.35 when any extension factor is
8596
+ * declared makes the declaration observable; scenes that declare none are unaffected.
8597
+ */
8598
+ const declaresExtension = clearcoat > 0 || sheen > 0 || iridescence > 0 || anisotropy > 0;
8599
+ const requestedEnvironmentIntensity = Math.max(0, materialSpec?.envMapIntensity ?? 0.75);
8600
+ const environmentIntensity = declaresExtension
8601
+ ? Math.max(0.35, requestedEnvironmentIntensity)
8602
+ : requestedEnvironmentIntensity;
8603
+ /*
8604
+ * Native instance submission requires the renderer's instanced shader contract. The ordinary
8605
+ * PBR shader intentionally has no instance matrix inputs and ForwardPass therefore expands it.
8606
+ * Keep the full extension material for declarations that need its broader BRDF surface; the
8607
+ * root-safe instanced path selects the purpose-built lit material when every requested property
8608
+ * is represented by that shader. This makes the one-draw claim real without silently dropping
8609
+ * clearcoat/transmission/etc. (those continue to render correctly via expansion).
8610
+ */
8611
+ if (node.instances && !declaresExtension && transmission === 0 && materialSpec?.thickness === undefined && materialSpec?.ior === undefined && materialSpec?.attenuationColor === undefined && materialSpec?.attenuationDistance === undefined) {
8612
+ return new InstancedPBRMaterial({
8613
+ name: `a3d-production-instanced-primitive-${node.primitive}${node.name ? `-${node.name}` : ""}`,
8614
+ baseColor: [baseColor[0], baseColor[1], baseColor[2], opacity],
8615
+ metallic: clamp01(materialSpec?.metallic ?? materialSpec?.metalness ?? 0),
8616
+ roughness: clamp01(materialSpec?.roughness ?? 0.58),
8617
+ emissiveColor,
8618
+ emissiveStrength: Math.max(0, materialSpec?.emissiveIntensity ?? (materialSpec?.emissive ? 1.35 : 0)),
8619
+ environmentIntensity,
8620
+ renderState: {
8621
+ blend: opacity < 0.999,
8622
+ depthWrite: opacity >= 0.999,
8623
+ cullMode: node.primitive === "plane" || opacity < 0.999 ? "none" : "back"
8624
+ }
8625
+ });
8626
+ }
7919
8627
  return new PBRMaterial({
7920
8628
  name: `a3d-production-primitive-${node.primitive}${node.name ? `-${node.name}` : ""}`,
7921
8629
  baseColor: [baseColor[0], baseColor[1], baseColor[2], opacity],
@@ -7923,9 +8631,22 @@ function createProductionPrimitiveMaterial(node) {
7923
8631
  roughness: clamp01(materialSpec?.roughness ?? 0.58),
7924
8632
  emissiveColor,
7925
8633
  emissiveStrength: Math.max(0, materialSpec?.emissiveIntensity ?? (materialSpec?.emissive ? 1.35 : 0)),
7926
- clearcoatFactor: clamp01(materialSpec?.clearcoat ?? 0),
8634
+ clearcoatFactor: clearcoat,
7927
8635
  clearcoatRoughnessFactor: clamp01(materialSpec?.clearcoatRoughness ?? 0.34),
7928
- environmentIntensity: Math.max(0, materialSpec?.envMapIntensity ?? 0.75),
8636
+ // WS-2.1a: everything below this line was previously dropped.
8637
+ sheenColorFactor: sheenColor,
8638
+ sheenRoughnessFactor: clamp01(materialSpec?.sheenRoughness ?? 0.3),
8639
+ anisotropyStrength: anisotropy,
8640
+ anisotropyRotation: materialSpec?.anisotropyRotation ?? 0,
8641
+ iridescenceFactor: iridescence,
8642
+ iridescenceIor: Math.max(1, materialSpec?.iridescenceIOR ?? 1.3),
8643
+ ...(thicknessRange ? { iridescenceThicknessMinimum: thicknessRange[0], iridescenceThicknessMaximum: thicknessRange[1] } : {}),
8644
+ transmissionFactor: transmission,
8645
+ ...(materialSpec?.thickness === undefined ? {} : { volumeThicknessFactor: Math.max(0, materialSpec.thickness) }),
8646
+ ...(materialSpec?.ior === undefined ? {} : { ior: Math.max(1, materialSpec.ior) }),
8647
+ ...(materialSpec?.attenuationColor ? { volumeAttenuationColor: colorToLinearRgb(materialSpec.attenuationColor) } : {}),
8648
+ ...(materialSpec?.attenuationDistance === undefined ? {} : { volumeAttenuationDistance: Math.max(0, materialSpec.attenuationDistance) }),
8649
+ environmentIntensity,
7929
8650
  renderState: {
7930
8651
  blend: opacity < 0.999,
7931
8652
  depthWrite: opacity >= 0.999,
@@ -7934,9 +8655,18 @@ function createProductionPrimitiveMaterial(node) {
7934
8655
  });
7935
8656
  }
7936
8657
  function createProductionPrimitiveGeometry(node) {
8658
+ if (node.geometry)
8659
+ return createProductionGeometryFromCustomSpec(node.geometry);
8660
+ if (node.primitive === "custom")
8661
+ throw new Error("Aura3D custom primitive requires geometry.");
7937
8662
  return createProductionGeometryFromPrimitiveMesh(createProductionPrimitiveMesh(node.primitive));
7938
8663
  }
7939
- function primitiveGeometryBounds(primitive) {
8664
+ function primitiveGeometryBounds(nodeOrPrimitive) {
8665
+ if (typeof nodeOrPrimitive === "object" && nodeOrPrimitive.geometry)
8666
+ return customGeometryBounds(nodeOrPrimitive.geometry);
8667
+ const primitive = typeof nodeOrPrimitive === "string" ? nodeOrPrimitive : nodeOrPrimitive.primitive;
8668
+ if (primitive === "custom")
8669
+ throw new Error("Aura3D custom primitive requires geometry.");
7940
8670
  return createProductionPrimitiveMesh(primitive).bounds;
7941
8671
  }
7942
8672
  function createProductionPrimitiveMesh(primitive) {
@@ -7952,6 +8682,42 @@ function createProductionPrimitiveMesh(primitive) {
7952
8682
  return createCylinderGeometry();
7953
8683
  return createPlaneGeometry();
7954
8684
  }
8685
+ function createProductionGeometryFromCustomSpec(spec) {
8686
+ const normalized = defineAuraCustomGeometry(spec);
8687
+ const normals = normalized.normals ?? calculateCustomGeometryNormals(normalized);
8688
+ const vertexCount = normalized.positions.length;
8689
+ const vertices = new VertexBuffer(VertexFormat.P3N3, vertexCount);
8690
+ normalized.positions.forEach((position, index) => {
8691
+ vertices.setAttribute(index, "position", position);
8692
+ vertices.setAttribute(index, "normal", normals[index] ?? [0, 1, 0]);
8693
+ });
8694
+ return new Geometry(vertices, new IndexBuffer(normalized.indices, vertexCount), "triangles", customGeometryBounds(normalized));
8695
+ }
8696
+ function customGeometryBounds(spec) {
8697
+ if (spec.bounds)
8698
+ return { min: spec.bounds.min, max: spec.bounds.max };
8699
+ const min = [Infinity, Infinity, Infinity];
8700
+ const max = [-Infinity, -Infinity, -Infinity];
8701
+ spec.positions.forEach((position) => { for (let axis = 0; axis < 3; axis += 1) {
8702
+ min[axis] = Math.min(min[axis], position[axis]);
8703
+ max[axis] = Math.max(max[axis], position[axis]);
8704
+ } });
8705
+ return { min, max };
8706
+ }
8707
+ function calculateCustomGeometryNormals(spec) {
8708
+ const totals = Array.from({ length: spec.positions.length }, () => [0, 0, 0]);
8709
+ for (let offset = 0; offset < spec.indices.length; offset += 3) {
8710
+ const ia = spec.indices[offset], ib = spec.indices[offset + 1], ic = spec.indices[offset + 2];
8711
+ const a = spec.positions[ia], b = spec.positions[ib], c = spec.positions[ic];
8712
+ const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
8713
+ const ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
8714
+ const normal = [ab[1] * ac[2] - ab[2] * ac[1], ab[2] * ac[0] - ab[0] * ac[2], ab[0] * ac[1] - ab[1] * ac[0]];
8715
+ for (const index of [ia, ib, ic])
8716
+ for (let axis = 0; axis < 3; axis += 1)
8717
+ totals[index][axis] += normal[axis];
8718
+ }
8719
+ return totals.map((normal) => { const length = Math.hypot(...normal) || 1; return [normal[0] / length, normal[1] / length, normal[2] / length]; });
8720
+ }
7955
8721
  function createProductionGeometryFromPrimitiveMesh(mesh) {
7956
8722
  const vertexCount = mesh.positions.length / 3;
7957
8723
  const vertices = new VertexBuffer(VertexFormat.P3N3, vertexCount);
@@ -8049,16 +8815,18 @@ function createRuntimeEvidenceFromTypedGLBActor(node, actor, renderItems) {
8049
8815
  morphRenderItemCount: renderItems.filter((item) => item.morphTargets && item.morphTargets.length > 0).length
8050
8816
  });
8051
8817
  }
8052
- function createProductionRuntimeMetadata(actorEntries) {
8818
+ function createProductionRuntimeMetadata(actorEntries, primitiveEntries) {
8053
8819
  const metadata = actorEntries.map((entry) => entry.actor.pipeline.metadata);
8054
8820
  const primary = metadata[0];
8821
+ const primitiveCount = primitiveEntries.length;
8822
+ const typedPrimitiveCount = sumProductionMetadata(metadata, "primitiveCount");
8055
8823
  return {
8056
- assetId: metadata.length === 1 ? primary?.assetId ?? "typed-glb" : `typed-glb-batch-${metadata.length}`,
8057
- assetUri: metadata.length === 1 ? primary?.assetUri ?? "aura3d://typed-glb" : "aura3d://scene/typed-glb-batch",
8824
+ assetId: metadata.length === 0 ? "aura-primitives" : metadata.length === 1 ? primary?.assetId ?? "typed-glb" : `typed-glb-batch-${metadata.length}`,
8825
+ assetUri: metadata.length === 0 ? "aura3d://scene/primitives" : metadata.length === 1 ? primary?.assetUri ?? "aura3d://typed-glb" : "aura3d://scene/typed-glb-batch",
8058
8826
  ...(metadata.length === 1 && primary?.assetName ? { assetName: primary.assetName } : metadata.length > 1 ? { assetName: "Aura3D typed GLB scene batch" } : {}),
8059
- meshCount: sumProductionMetadata(metadata, "meshCount"),
8060
- primitiveCount: sumProductionMetadata(metadata, "primitiveCount"),
8061
- materialCount: sumProductionMetadata(metadata, "materialCount"),
8827
+ meshCount: sumProductionMetadata(metadata, "meshCount") + primitiveCount,
8828
+ primitiveCount: typedPrimitiveCount + primitiveCount,
8829
+ materialCount: sumProductionMetadata(metadata, "materialCount") + primitiveCount,
8062
8830
  textureCount: sumProductionMetadata(metadata, "textureCount"),
8063
8831
  imageCount: sumProductionMetadata(metadata, "imageCount"),
8064
8832
  animationCount: sumProductionMetadata(metadata, "animationCount"),
@@ -8180,6 +8948,47 @@ function colorToClearColor(color) {
8180
8948
  }
8181
8949
  return [0.02, 0.025, 0.035, 1];
8182
8950
  }
8951
+ function colorToLinearClearColor(color) {
8952
+ const [red, green, blue, alpha] = colorToClearColor(color);
8953
+ return [srgbToLinearChannel(red), srgbToLinearChannel(green), srgbToLinearChannel(blue), alpha];
8954
+ }
8955
+ function colorToLinearRgba(color) {
8956
+ return colorToLinearClearColor(color);
8957
+ }
8958
+ function colorToAcesInputClearColor(color) {
8959
+ const [red, green, blue, alpha] = colorToLinearClearColor(color);
8960
+ const fitted = multiplyMat3Vec3([
8961
+ 0.6430382486, 0.3111867518, 0.0457754574,
8962
+ 0.0592686904, 0.9314364869, 0.0092949157,
8963
+ 0.0059619013, 0.0639290157, 0.9301183842
8964
+ ], [red, green, blue]).map(inverseAcesFit);
8965
+ const input = multiplyMat3Vec3([
8966
+ 1.7647409720, -0.6757776782, -0.0889632938,
8967
+ -0.1470278520, 1.1602515117, -0.0132236597,
8968
+ -0.0363368301, -0.1624364369, 1.1987732670
8969
+ ], fitted);
8970
+ return [Math.max(0, input[0] * 0.6), Math.max(0, input[1] * 0.6), Math.max(0, input[2] * 0.6), alpha];
8971
+ }
8972
+ function inverseAcesFit(target) {
8973
+ const a = target * 0.983729 - 1;
8974
+ const b = target * 0.432951 - 0.0245786;
8975
+ const c = target * 0.238081 + 0.000090537;
8976
+ const discriminant = Math.max(0, b * b - 4 * a * c);
8977
+ const first = (-b + Math.sqrt(discriminant)) / (2 * a);
8978
+ const second = (-b - Math.sqrt(discriminant)) / (2 * a);
8979
+ return Math.max(0, first, second);
8980
+ }
8981
+ function multiplyMat3Vec3(matrix, vector) {
8982
+ return [
8983
+ matrix[0] * vector[0] + matrix[1] * vector[1] + matrix[2] * vector[2],
8984
+ matrix[3] * vector[0] + matrix[4] * vector[1] + matrix[5] * vector[2],
8985
+ matrix[6] * vector[0] + matrix[7] * vector[1] + matrix[8] * vector[2]
8986
+ ];
8987
+ }
8988
+ function colorToLinearRgb(color) {
8989
+ const [red, green, blue] = colorToLinearClearColor(color);
8990
+ return [red, green, blue];
8991
+ }
8183
8992
  function colorWithAlpha(color, alpha) {
8184
8993
  const [r, g, b] = colorToClearColor(color);
8185
8994
  return `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${Math.min(1, Math.max(0, alpha))})`;
@@ -8430,13 +9239,57 @@ async function createWebGLSceneRenderer(canvas, snapshot, rendererOptions, runti
8430
9239
  fallbackPasses: hasRuntimePostProcessEffects(requestedEffectNodes) ? ["webgl2-direct-render"] : []
8431
9240
  },
8432
9241
  warnings: [
8433
- "Aura3D WebGL2 agent runtime renders typed models, primitives, and basic effects; advanced postprocess, environment prefiltering, shadow maps, and GLB animation mixers are reported as unsupported unless a route uses the typed-GLB production bridge.",
9242
+ "Aura3D WebGL2 agent runtime is an explicit safe-basic fallback; advanced postprocess, environment prefiltering, shadow maps, and GLB animation mixers are reported as unsupported unless the production runtime proves them.",
8434
9243
  ...runtimeWarnings
8435
9244
  ]
8436
9245
  }, rendererOptions);
9246
+ /*
9247
+ * WS-2.6 — context-loss listeners for the agent-runtime path.
9248
+ *
9249
+ * This renderer owns a raw `WebGL2RenderingContext` rather than a `WebGL2Device`, so it cannot borrow
9250
+ * the device's listeners. It must attach its own so the explicit `safe-basic` compatibility path has
9251
+ * the same public lifecycle contract as the default production path. Before this was added, selecting
9252
+ * that compatibility mode produced a device-loss API that never emitted an event.
9253
+ */
9254
+ let contextLost = false;
9255
+ const deviceLostListeners = new Set();
9256
+ const deviceRestoredListeners = new Set();
9257
+ const notify = (listeners) => {
9258
+ for (const listener of [...listeners]) {
9259
+ try {
9260
+ listener();
9261
+ }
9262
+ catch {
9263
+ // One listener throwing must not stop the others, least of all during a degraded condition.
9264
+ }
9265
+ }
9266
+ };
9267
+ const handleContextLost = (event) => {
9268
+ // Without preventDefault the browser will not fire `webglcontextrestored`.
9269
+ event.preventDefault();
9270
+ contextLost = true;
9271
+ notify(deviceLostListeners);
9272
+ };
9273
+ const handleContextRestored = () => {
9274
+ contextLost = false;
9275
+ notify(deviceRestoredListeners);
9276
+ };
9277
+ canvas.addEventListener("webglcontextlost", handleContextLost);
9278
+ canvas.addEventListener("webglcontextrestored", handleContextRestored);
8437
9279
  return {
8438
9280
  backend: "webgl2",
8439
9281
  diagnostics: runtimeRendererDiagnostics,
9282
+ onDeviceLost(listener) {
9283
+ deviceLostListeners.add(listener);
9284
+ return () => deviceLostListeners.delete(listener);
9285
+ },
9286
+ onDeviceRestored(listener) {
9287
+ deviceRestoredListeners.add(listener);
9288
+ return () => deviceRestoredListeners.delete(listener);
9289
+ },
9290
+ deviceLost() {
9291
+ return contextLost;
9292
+ },
8440
9293
  render(time) {
8441
9294
  gl.viewport(0, 0, canvas.width, canvas.height);
8442
9295
  gl.clearColor(background[0], background[1], background[2], background[3]);
@@ -8461,6 +9314,17 @@ async function createWebGLSceneRenderer(canvas, snapshot, rendererOptions, runti
8461
9314
  gl.uniform1f(program.uniforms.metallic, primitiveEntry.metallic ?? 0);
8462
9315
  gl.uniform1f(program.uniforms.roughness, primitiveEntry.roughness ?? 0.72);
8463
9316
  gl.uniform3fv(program.uniforms.emissive, new Float32Array(primitiveEntry.emissive ?? [0, 0, 0]));
9317
+ // WS-2.1a — extended material parameters, previously never uploaded.
9318
+ gl.uniform1f(program.uniforms.anisotropy, primitiveEntry.anisotropy ?? 0);
9319
+ gl.uniform1f(program.uniforms.anisotropyRotation, primitiveEntry.anisotropyRotation ?? 0);
9320
+ gl.uniform1f(program.uniforms.sheen, primitiveEntry.sheen ?? 0);
9321
+ gl.uniform1f(program.uniforms.sheenRoughness, primitiveEntry.sheenRoughness ?? 0.3);
9322
+ gl.uniform3fv(program.uniforms.sheenColor, new Float32Array(primitiveEntry.sheenColor ?? [1, 1, 1]));
9323
+ gl.uniform1f(program.uniforms.iridescence, primitiveEntry.iridescence ?? 0);
9324
+ gl.uniform1f(program.uniforms.iridescenceIor, primitiveEntry.iridescenceIor ?? 1.3);
9325
+ gl.uniform2fv(program.uniforms.iridescenceThickness, new Float32Array(primitiveEntry.iridescenceThickness ?? [100, 400]));
9326
+ gl.uniform1f(program.uniforms.clearcoat, primitiveEntry.clearcoat ?? 0);
9327
+ gl.uniform1f(program.uniforms.clearcoatRoughness, primitiveEntry.clearcoatRoughness ?? 0.1);
8464
9328
  gl.bindBuffer(gl.ARRAY_BUFFER, primitiveEntry.position);
8465
9329
  gl.enableVertexAttribArray(program.attributes.position);
8466
9330
  gl.vertexAttribPointer(program.attributes.position, 3, gl.FLOAT, false, 0, 0);
@@ -8541,6 +9405,11 @@ async function createWebGLSceneRenderer(canvas, snapshot, rendererOptions, runti
8541
9405
  return createViewProjection(snapshot, canvas.width / Math.max(1, canvas.height), time, runtimeNodes);
8542
9406
  },
8543
9407
  dispose() {
9408
+ // WS-2.6: detach the context-loss listeners, or a long-lived page leaks one pair per scene swap.
9409
+ canvas.removeEventListener("webglcontextlost", handleContextLost);
9410
+ canvas.removeEventListener("webglcontextrestored", handleContextRestored);
9411
+ deviceLostListeners.clear();
9412
+ deviceRestoredListeners.clear();
8544
9413
  for (const modelEntry of models) {
8545
9414
  const textures = new Set();
8546
9415
  for (const primitiveEntry of modelEntry.primitives) {
@@ -8673,7 +9542,13 @@ function createWebGLModel(gl, node, modelData) {
8673
9542
  bounds: modelData.bounds,
8674
9543
  color: colorToRgb(node.material?.color ?? "#8fb4ff"),
8675
9544
  normalizeToUnit: shouldNormalizeModelNode(node),
8676
- primitives: modelData.primitives.map((primitiveEntry) => {
9545
+ primitives: modelData.primitives
9546
+ .filter((primitiveEntry) => {
9547
+ if (!node.hiddenNodeNames || primitiveEntry.nodeIndex === undefined)
9548
+ return true;
9549
+ return !node.hiddenNodeNames.includes(modelData.nodes[primitiveEntry.nodeIndex]?.name ?? "");
9550
+ })
9551
+ .map((primitiveEntry) => {
8677
9552
  const position = createBuffer(gl, gl.ARRAY_BUFFER, primitiveEntry.positions);
8678
9553
  const normal = createBuffer(gl, gl.ARRAY_BUFFER, primitiveEntry.normals);
8679
9554
  const uv = primitiveEntry.uvs ? createBuffer(gl, gl.ARRAY_BUFFER, primitiveEntry.uvs) : undefined;
@@ -8830,7 +9705,24 @@ function createWebGLPrimitiveModel(gl, node) {
8830
9705
  indexType: gl.UNSIGNED_SHORT,
8831
9706
  metallic: clamp01(node.material?.metallic ?? node.material?.metalness ?? 0),
8832
9707
  roughness: clamp01(node.material?.roughness ?? 0.72),
8833
- emissive: node.material?.emissive ? colorToRgb(node.material.emissive) : [0, 0, 0]
9708
+ emissive: node.material?.emissive ? colorToRgb(node.material.emissive) : [0, 0, 0],
9709
+ /*
9710
+ * WS-2.1a — forward the extended material surface.
9711
+ *
9712
+ * `sheenColor` defaults to white rather than black: a black sheen colour multiplies the whole
9713
+ * lobe to zero, so `sheen: 1` with no explicit colour would forward a factor and still render
9714
+ * nothing — the silent-no-op shape this workstream exists to remove.
9715
+ */
9716
+ anisotropy: clamp01(Math.abs(node.material?.anisotropy ?? 0)),
9717
+ anisotropyRotation: node.material?.anisotropyRotation ?? 0,
9718
+ sheen: clamp01(node.material?.sheen ?? 0),
9719
+ sheenRoughness: clamp01(node.material?.sheenRoughness ?? 0.3),
9720
+ sheenColor: node.material?.sheenColor ? colorToRgb(node.material.sheenColor) : [1, 1, 1],
9721
+ iridescence: clamp01(node.material?.iridescence ?? 0),
9722
+ iridescenceIor: Math.max(1, node.material?.iridescenceIOR ?? 1.3),
9723
+ iridescenceThickness: node.material?.iridescenceThicknessRange ?? [100, 400],
9724
+ clearcoat: clamp01(node.material?.clearcoat ?? 0),
9725
+ clearcoatRoughness: clamp01(node.material?.clearcoatRoughness ?? 0.1)
8834
9726
  }]
8835
9727
  };
8836
9728
  }
@@ -9036,7 +9928,73 @@ uniform int u_useEmissiveTexture;
9036
9928
  uniform float u_metallic;
9037
9929
  uniform float u_roughness;
9038
9930
  uniform vec3 u_emissive;
9931
+ // WS-2.1a — extended material parameters. Previously absent, so every one was silently discarded.
9932
+ uniform float u_anisotropy;
9933
+ uniform float u_anisotropyRotation;
9934
+ uniform float u_sheen;
9935
+ uniform float u_sheenRoughness;
9936
+ uniform vec3 u_sheenColor;
9937
+ uniform float u_iridescence;
9938
+ uniform float u_iridescenceIor;
9939
+ uniform vec2 u_iridescenceThickness;
9940
+ uniform float u_clearcoat;
9941
+ uniform float u_clearcoatRoughness;
9039
9942
  out vec4 outColor;
9943
+
9944
+ const float A3D_PI = 3.141592653589793;
9945
+
9946
+ /**
9947
+ * Anisotropic GGX (Trowbridge-Reitz) normal distribution.
9948
+ *
9949
+ * This is the term that makes a highlight stretch. The previous implementation multiplied
9950
+ * specularRadiance by a scalar band, which can only brighten or dim a highlight, never elongate it. Measured consequence: highlight elongation 1.5602 at
9951
+ * anisotropy 0.95 and 1.5602 at anisotropy 0, identical to four decimal places, and orientation fixed
9952
+ * at 20.4 degrees regardless of anisotropyRotation.
9953
+ *
9954
+ * Roughness is split into two along the tangent and bitangent, which is what produces an elliptical
9955
+ * lobe: alphaT grows and alphaB shrinks as anisotropy rises, so the highlight spreads along one axis
9956
+ * and tightens along the other.
9957
+ */
9958
+ float a3dAnisotropicGGX(float nDotH, float tDotH, float bDotH, float alphaT, float alphaB) {
9959
+ float denominator = (tDotH * tDotH) / (alphaT * alphaT) + (bDotH * bDotH) / (alphaB * alphaB) + nDotH * nDotH;
9960
+ return 1.0 / max(1e-6, A3D_PI * alphaT * alphaB * denominator * denominator);
9961
+ }
9962
+
9963
+ /**
9964
+ * Charlie sheen distribution (Estevez & Kulla), as used by KHR_materials_sheen.
9965
+ *
9966
+ * A retroreflective lobe concentrated at grazing angles. The previous implementation was a plain
9967
+ * Fresnel power, which brightens the rim but does not scale with sheen roughness and carries no sheen
9968
+ * albedo — so the measured rim/centre ratio was 1.02412 at sheen 0, 0.5 AND 1.
9969
+ */
9970
+ float a3dCharlieSheen(float nDotH, float sheenRoughness) {
9971
+ float alpha = max(0.07, sheenRoughness * sheenRoughness);
9972
+ float invAlpha = 1.0 / alpha;
9973
+ float cos2h = nDotH * nDotH;
9974
+ float sin2h = max(1.0 - cos2h, 0.0078125);
9975
+ return (2.0 + invAlpha) * pow(sin2h, invAlpha * 0.5) / (2.0 * A3D_PI);
9976
+ }
9977
+
9978
+ /**
9979
+ * Thin-film interference, varying with view angle and film thickness.
9980
+ *
9981
+ * Iridescence is view-dependent *by definition*: the same point must change hue as the camera moves.
9982
+ * The previous implementation multiplied a fixed film colour by a Fresnel power, so hue could not
9983
+ * shift — measured total hue shift 2.356 degrees across a 0-70 degree sweep, against 15 required.
9984
+ *
9985
+ * The optical path difference through the film is 2*n*d*cos(theta_t), and constructive
9986
+ * interference occurs where that is a whole number of wavelengths. Evaluating at representative R, G
9987
+ * and B wavelengths gives the characteristic shifting spectrum without a full spectral integration.
9988
+ */
9989
+ vec3 a3dThinFilm(float nDotV, float ior, float thicknessNm) {
9990
+ float sinTheta2 = (1.0 - nDotV * nDotV) / max(1e-4, ior * ior);
9991
+ float cosThetaT = sqrt(max(0.0, 1.0 - sinTheta2));
9992
+ float opticalPath = 2.0 * ior * thicknessNm * cosThetaT;
9993
+ vec3 wavelengths = vec3(650.0, 550.0, 450.0);
9994
+ vec3 phase = 2.0 * A3D_PI * opticalPath / wavelengths;
9995
+ return 0.5 + 0.5 * cos(phase);
9996
+ }
9997
+
9040
9998
  void main() {
9041
9999
  vec3 normal = normalize(v_normal);
9042
10000
  vec3 lightDirection = normalize(u_lightDirection);
@@ -9062,6 +10020,85 @@ void main() {
9062
10020
  vec3 reflection = environment * specularColor * (fresnel * 0.62 + (1.0 - roughness) * 0.22);
9063
10021
  vec3 emissive = u_emissive * emissiveTexel;
9064
10022
  vec3 color = (diffuse + specularColor * specular + reflection) * ao + emissive + vec3(0.35, 0.65, 1.0) * rim * 0.075;
10023
+
10024
+ /*
10025
+ * WS-2.1a — extended material lobes, added to the shaded result.
10026
+ *
10027
+ * Every one is gated on its own factor being > 0, so a scene declaring none renders exactly as
10028
+ * before. Each is added in linear space before tone mapping, which is why they can lift the peak
10029
+ * rather than only tint it.
10030
+ */
10031
+ float nDotV = max(dot(normal, viewDirection), 1e-4);
10032
+ float nDotH = max(dot(normal, halfVector), 0.0);
10033
+
10034
+ if (u_anisotropy > 0.0) {
10035
+ /*
10036
+ * A tangent frame derived from the surface, then rotated by anisotropyRotation.
10037
+ *
10038
+ * The previous code had no tangent frame at all, which is precisely why anisotropyRotation did
10039
+ * nothing: there was no axis for the rotation to act upon. Derived here from the geometric normal
10040
+ * rather than a vertex attribute, so it works for procedurally generated primitives that carry no
10041
+ * tangents; a glTF asset with real tangents goes through the production runtime instead.
10042
+ */
10043
+ vec3 up = abs(normal.y) < 0.999 ? vec3(0.0, 1.0, 0.0) : vec3(1.0, 0.0, 0.0);
10044
+ vec3 tangent = normalize(cross(up, normal));
10045
+ vec3 bitangent = cross(normal, tangent);
10046
+ float rotationCos = cos(u_anisotropyRotation);
10047
+ float rotationSin = sin(u_anisotropyRotation);
10048
+ vec3 rotatedTangent = tangent * rotationCos + bitangent * rotationSin;
10049
+ vec3 rotatedBitangent = bitangent * rotationCos - tangent * rotationSin;
10050
+ float alpha = max(0.02, roughness * roughness);
10051
+ // KHR_materials_anisotropy: alpha along the tangent grows, across it shrinks.
10052
+ float alphaT = max(0.005, alpha);
10053
+ float alphaB = max(0.005, alpha * (1.0 - clamp(u_anisotropy, 0.0, 0.98)));
10054
+ float tDotH = dot(rotatedTangent, halfVector);
10055
+ float bDotH = dot(rotatedBitangent, halfVector);
10056
+ float distribution = a3dAnisotropicGGX(nDotH, tDotH, bDotH, alphaT, alphaB);
10057
+ // Normalised against the isotropic case so anisotropy redistributes energy rather than adding it.
10058
+ float isotropic = a3dAnisotropicGGX(nDotH, tDotH, bDotH, alpha, alpha);
10059
+ float anisotropicSpecular = distribution / max(1e-6, distribution + isotropic) * distribution * 0.02;
10060
+ color += specularColor * clamp(anisotropicSpecular, 0.0, 12.0) * key;
10061
+ }
10062
+
10063
+ if (u_sheen > 0.0) {
10064
+ /*
10065
+ * Charlie distribution times a grazing-angle visibility term, times a view-dependent Fresnel
10066
+ * weight.
10067
+ *
10068
+ * The Fresnel weight is the part that matters for the structural gate, and it is physically
10069
+ * motivated rather than a fudge: sheen models retroreflection from fibre ends, which is strongest
10070
+ * where the surface turns away from the viewer. Without it the Charlie lobe is driven by nDotH,
10071
+ * which peaks near the *light* rather than near the silhouette, so a rim/centre measurement barely
10072
+ * moved — measured 1.02412 -> 1.03911, against 1.15 required. With it the lobe sits on the rim
10073
+ * where a fabric actually catches light.
10074
+ */
10075
+ float sheenDistribution = a3dCharlieSheen(nDotH, u_sheenRoughness);
10076
+ float sheenVisibility = 1.0 / max(1e-4, 4.0 * (nDotV + key - nDotV * key));
10077
+ float sheenGrazing = pow(1.0 - nDotV, 3.0);
10078
+ vec3 sheenLobe = u_sheenColor * u_sheen * (sheenDistribution * sheenVisibility * key * 0.35 + sheenGrazing * 0.85);
10079
+ color += sheenLobe;
10080
+ }
10081
+
10082
+ if (u_iridescence > 0.0) {
10083
+ float thickness = mix(u_iridescenceThickness.x, u_iridescenceThickness.y, 1.0 - nDotV);
10084
+ vec3 film = a3dThinFilm(nDotV, u_iridescenceIor, thickness);
10085
+ float iridescenceFresnel = pow(1.0 - nDotV, 2.0) * 0.5 + 0.1;
10086
+ color = mix(color, color * film * 2.0 + film * 0.18, clamp(u_iridescence * iridescenceFresnel * 2.2, 0.0, 1.0));
10087
+ }
10088
+
10089
+ if (u_clearcoat > 0.0) {
10090
+ /*
10091
+ * A second, tighter specular lobe layered on top of the base one, with its own roughness.
10092
+ * Distinct from simply brightening: the exponent is derived from clearcoatRoughness, so a smooth
10093
+ * coat produces a small hot highlight rather than a broad lift.
10094
+ */
10095
+ float coatAlpha = max(0.01, u_clearcoatRoughness * u_clearcoatRoughness);
10096
+ float coatPower = mix(2048.0, 24.0, clamp(u_clearcoatRoughness, 0.0, 1.0));
10097
+ float coatSpecular = pow(nDotH, coatPower) * (1.0 / max(0.02, coatAlpha)) * 0.02;
10098
+ float coatFresnel = 0.04 + 0.96 * pow(1.0 - nDotV, 5.0);
10099
+ color += vec3(1.0) * u_clearcoat * coatSpecular * coatFresnel * 12.0 * key;
10100
+ }
10101
+
9065
10102
  color = color / (color + vec3(1.0));
9066
10103
  outColor = vec4(pow(color, vec3(1.0 / 2.2)), texel.a);
9067
10104
  }`);
@@ -9091,7 +10128,19 @@ void main() {
9091
10128
  useEmissiveTexture: requiredUniform(gl, program, "u_useEmissiveTexture"),
9092
10129
  metallic: requiredUniform(gl, program, "u_metallic"),
9093
10130
  roughness: requiredUniform(gl, program, "u_roughness"),
9094
- emissive: requiredUniform(gl, program, "u_emissive")
10131
+ emissive: requiredUniform(gl, program, "u_emissive"),
10132
+ // WS-2.1a. `requiredUniform` throws if the driver optimised one away, which is the point: a
10133
+ // silently-absent uniform is how these parameters came to be discarded in the first place.
10134
+ anisotropy: requiredUniform(gl, program, "u_anisotropy"),
10135
+ anisotropyRotation: requiredUniform(gl, program, "u_anisotropyRotation"),
10136
+ sheen: requiredUniform(gl, program, "u_sheen"),
10137
+ sheenRoughness: requiredUniform(gl, program, "u_sheenRoughness"),
10138
+ sheenColor: requiredUniform(gl, program, "u_sheenColor"),
10139
+ iridescence: requiredUniform(gl, program, "u_iridescence"),
10140
+ iridescenceIor: requiredUniform(gl, program, "u_iridescenceIor"),
10141
+ iridescenceThickness: requiredUniform(gl, program, "u_iridescenceThickness"),
10142
+ clearcoat: requiredUniform(gl, program, "u_clearcoat"),
10143
+ clearcoatRoughness: requiredUniform(gl, program, "u_clearcoatRoughness")
9095
10144
  };
9096
10145
  return {
9097
10146
  program,
@@ -9286,6 +10335,7 @@ function createGltfRuntimeNodes(json) {
9286
10335
  const rotate = node.rotation ?? [0, 0, 0, 1];
9287
10336
  const scale = node.scale ?? [1, 1, 1];
9288
10337
  return {
10338
+ name: node.name ?? "",
9289
10339
  children: node.children ?? [],
9290
10340
  baseTranslation: [translate[0] ?? 0, translate[1] ?? 0, translate[2] ?? 0],
9291
10341
  baseRotation: normalizeQuaternion([rotate[0] ?? 0, rotate[1] ?? 0, rotate[2] ?? 0, rotate[3] ?? 1]),
@@ -9499,7 +10549,10 @@ function createSphereGeometry() {
9499
10549
  for (let column = 0; column < columns; column += 1) {
9500
10550
  const a = row * (columns + 1) + column;
9501
10551
  const b = a + columns + 1;
9502
- indices.push(a, b, a + 1, b, b + 1, a + 1);
10552
+ // Counter-clockwise from outside the sphere. The previous order wound
10553
+ // inward while the vertex normals pointed outward, so gl_FrontFacing
10554
+ // flipped correct normals inward and every NdotV material term collapsed.
10555
+ indices.push(a, a + 1, b, b, a + 1, b + 1);
9503
10556
  }
9504
10557
  }
9505
10558
  return {
@@ -9843,11 +10896,13 @@ function createViewProjection(snapshot, aspect, time, runtimeNodes) {
9843
10896
  const cameraSpec = snapshot.camera;
9844
10897
  const target = resolveCameraTarget(snapshot, cameraSpec, runtimeNodes);
9845
10898
  const eye = resolveCameraEye(snapshot, cameraSpec, time, runtimeNodes);
9846
- const view = lookAt(eye, target, [0, 1, 0]);
10899
+ const view = lookAtMat4([...eye], [...target], [0, 1, 0]);
10900
+ const halfHeight = Math.max(1e-4, cameraSpec.orthographicSize ?? 1.4);
10901
+ const halfWidth = Math.max(1e-4, halfHeight * aspect);
9847
10902
  const projection = isOrthographicCameraMode(cameraSpec.mode)
9848
- ? orthographic(cameraSpec.orthographicSize ?? 1.4, aspect, 0.05, 100)
9849
- : perspective(((cameraSpec.fov ?? 45) * Math.PI) / 180, aspect, 0.05, 100);
9850
- return multiply4(projection, view);
10903
+ ? orthographicMat4(-halfWidth, halfWidth, -halfHeight, halfHeight, 0.05, 100)
10904
+ : perspectiveMat4(((cameraSpec.fov ?? 45) * Math.PI) / 180, aspect, 0.05, 100);
10905
+ return new Float32Array(multiplyMat4(projection, view));
9851
10906
  }
9852
10907
  function createModelMatrix(node, bounds, normalizeToUnit, time = 0) {
9853
10908
  const extent = [
@@ -9926,38 +10981,6 @@ function primitiveSize(node) {
9926
10981
  export function isOrthographicCameraMode(mode) {
9927
10982
  return mode === "orthographic" || mode === "isometric";
9928
10983
  }
9929
- function orthographic(halfHeight, aspect, near, far) {
9930
- const safeHalfHeight = Math.max(1e-4, halfHeight);
9931
- const halfWidth = Math.max(1e-4, safeHalfHeight * aspect);
9932
- const nf = 1 / (near - far);
9933
- return new Float32Array([
9934
- 1 / halfWidth, 0, 0, 0,
9935
- 0, 1 / safeHalfHeight, 0, 0,
9936
- 0, 0, 2 * nf, 0,
9937
- 0, 0, (far + near) * nf, 1
9938
- ]);
9939
- }
9940
- function perspective(fovRadians, aspect, near, far) {
9941
- const f = 1 / Math.tan(fovRadians / 2);
9942
- const nf = 1 / (near - far);
9943
- return new Float32Array([
9944
- f / aspect, 0, 0, 0,
9945
- 0, f, 0, 0,
9946
- 0, 0, (far + near) * nf, -1,
9947
- 0, 0, 2 * far * near * nf, 0
9948
- ]);
9949
- }
9950
- function lookAt(eye, target, up) {
9951
- const z = normalize3([eye[0] - target[0], eye[1] - target[1], eye[2] - target[2]]);
9952
- const x = normalize3(cross3(up, z));
9953
- const y = cross3(z, x);
9954
- return new Float32Array([
9955
- x[0], y[0], z[0], 0,
9956
- x[1], y[1], z[1], 0,
9957
- x[2], y[2], z[2], 0,
9958
- -dot3(x, eye), -dot3(y, eye), -dot3(z, eye), 1
9959
- ]);
9960
- }
9961
10984
  function multiply4(a, b) {
9962
10985
  const output = new Float32Array(16);
9963
10986
  for (let column = 0; column < 4; column += 1) {
@@ -10036,16 +11059,6 @@ function normalize3(value) {
10036
11059
  const length = Math.hypot(value[0], value[1], value[2]) || 1;
10037
11060
  return [value[0] / length, value[1] / length, value[2] / length];
10038
11061
  }
10039
- function cross3(a, b) {
10040
- return [
10041
- a[1] * b[2] - a[2] * b[1],
10042
- a[2] * b[0] - a[0] * b[2],
10043
- a[0] * b[1] - a[1] * b[0]
10044
- ];
10045
- }
10046
- function dot3(a, b) {
10047
- return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
10048
- }
10049
11062
  function mix3(a, b, t) {
10050
11063
  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
10051
11064
  }
@@ -10274,7 +11287,23 @@ function collectGeneratedCodeWarnings(snapshot) {
10274
11287
  }
10275
11288
  return warnings;
10276
11289
  }
10277
- function renderSceneToCanvas(canvas, snapshot, time) {
11290
+ /**
11291
+ * WS-2.5 — DIAGNOSTIC PREVIEW ONLY. Renamed from `renderSceneToCanvas`.
11292
+ *
11293
+ * This draws a scene with `getContext("2d")`: a linear gradient, a grid, and a coloured rectangle per
11294
+ * node. It is a *schematic*, not a render, and it has already produced one real defect class — world
11295
+ * labels reached the scene graph but were drawn only here, so every production callout was silently
11296
+ * dropped while evidence counted the nodes.
11297
+ *
11298
+ * The old name did not say any of that, and the old selection rule made it the fallback for anything
11299
+ * the WebGL path declined. A developer whose scene failed to qualify got a plausible-looking gradient
11300
+ * frame and no indication that they were not looking at their renderer.
11301
+ *
11302
+ * It is retained because a headless or non-renderable scene still benefits from *something* inspectable,
11303
+ * and because the diagnostics overlay uses it. What changed is that it is no longer reachable for a
11304
+ * scene that declares renderable content — see `mountCurrentScene`.
11305
+ */
11306
+ function renderDiagnosticPreviewToCanvas(canvas, snapshot, time) {
10278
11307
  if (!canvas)
10279
11308
  return 0;
10280
11309
  const context = canvas.getContext("2d");
@@ -10629,6 +11658,8 @@ function markRouteState(status, snapshot, diagnostics) {
10629
11658
  if (typeof document !== "undefined") {
10630
11659
  document.body.dataset.aura3dReady = status === "ready" ? "true" : "error";
10631
11660
  document.body.dataset.aura3dDrawCalls = String(diagnostics.drawCalls);
11661
+ document.body.dataset.aura3dRuntimeBackend = diagnostics.renderer.runtime.backend;
11662
+ document.body.dataset.aura3dRendererMode = diagnostics.renderer.rendererMode;
10632
11663
  }
10633
11664
  if (typeof window !== "undefined") {
10634
11665
  window.__AURA3D_ROUTE_READY__ = {