@flighthq/scene3d-gl 0.2.1-next.840.857425c

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 (284) hide show
  1. package/dist/anisotropyPbrGlMeshMaterialRenderer.d.ts +4 -0
  2. package/dist/anisotropyPbrGlMeshMaterialRenderer.d.ts.map +1 -0
  3. package/dist/anisotropyPbrGlMeshMaterialRenderer.js +45 -0
  4. package/dist/anisotropyPbrGlMeshMaterialRenderer.js.map +1 -0
  5. package/dist/blinnPhongGlMeshMaterialRenderer.d.ts +4 -0
  6. package/dist/blinnPhongGlMeshMaterialRenderer.d.ts.map +1 -0
  7. package/dist/blinnPhongGlMeshMaterialRenderer.js +102 -0
  8. package/dist/blinnPhongGlMeshMaterialRenderer.js.map +1 -0
  9. package/dist/clearcoatPbrGlMeshMaterialRenderer.d.ts +4 -0
  10. package/dist/clearcoatPbrGlMeshMaterialRenderer.d.ts.map +1 -0
  11. package/dist/clearcoatPbrGlMeshMaterialRenderer.js +45 -0
  12. package/dist/clearcoatPbrGlMeshMaterialRenderer.js.map +1 -0
  13. package/dist/customShaderGlMeshMaterialRenderer.d.ts +6 -0
  14. package/dist/customShaderGlMeshMaterialRenderer.d.ts.map +1 -0
  15. package/dist/customShaderGlMeshMaterialRenderer.js +136 -0
  16. package/dist/customShaderGlMeshMaterialRenderer.js.map +1 -0
  17. package/dist/depthGlMeshMaterialRenderer.d.ts +4 -0
  18. package/dist/depthGlMeshMaterialRenderer.d.ts.map +1 -0
  19. package/dist/depthGlMeshMaterialRenderer.js +36 -0
  20. package/dist/depthGlMeshMaterialRenderer.js.map +1 -0
  21. package/dist/drawGlScene3D.d.ts +3 -0
  22. package/dist/drawGlScene3D.d.ts.map +1 -0
  23. package/dist/drawGlScene3D.js +251 -0
  24. package/dist/drawGlScene3D.js.map +1 -0
  25. package/dist/emissiveGlMeshMaterialRenderer.d.ts +4 -0
  26. package/dist/emissiveGlMeshMaterialRenderer.d.ts.map +1 -0
  27. package/dist/emissiveGlMeshMaterialRenderer.js +49 -0
  28. package/dist/emissiveGlMeshMaterialRenderer.js.map +1 -0
  29. package/dist/enableGlScene3DColorSpaceGuards.d.ts +4 -0
  30. package/dist/enableGlScene3DColorSpaceGuards.d.ts.map +1 -0
  31. package/dist/enableGlScene3DColorSpaceGuards.js +22 -0
  32. package/dist/enableGlScene3DColorSpaceGuards.js.map +1 -0
  33. package/dist/enableGlScene3DCustomShaderGuards.d.ts +4 -0
  34. package/dist/enableGlScene3DCustomShaderGuards.d.ts.map +1 -0
  35. package/dist/enableGlScene3DCustomShaderGuards.js +71 -0
  36. package/dist/enableGlScene3DCustomShaderGuards.js.map +1 -0
  37. package/dist/enableGlScene3DDeformGuards.d.ts +4 -0
  38. package/dist/enableGlScene3DDeformGuards.d.ts.map +1 -0
  39. package/dist/enableGlScene3DDeformGuards.js +42 -0
  40. package/dist/enableGlScene3DDeformGuards.js.map +1 -0
  41. package/dist/enableGlScene3DForwardLightSelectionGuards.d.ts +4 -0
  42. package/dist/enableGlScene3DForwardLightSelectionGuards.d.ts.map +1 -0
  43. package/dist/enableGlScene3DForwardLightSelectionGuards.js +21 -0
  44. package/dist/enableGlScene3DForwardLightSelectionGuards.js.map +1 -0
  45. package/dist/explainGlScene3DForwardLightSelection.d.ts +3 -0
  46. package/dist/explainGlScene3DForwardLightSelection.d.ts.map +1 -0
  47. package/dist/explainGlScene3DForwardLightSelection.js +19 -0
  48. package/dist/explainGlScene3DForwardLightSelection.js.map +1 -0
  49. package/dist/glClassicPrelude.d.ts +9 -0
  50. package/dist/glClassicPrelude.d.ts.map +1 -0
  51. package/dist/glClassicPrelude.js +288 -0
  52. package/dist/glClassicPrelude.js.map +1 -0
  53. package/dist/glDebugPrelude.d.ts +9 -0
  54. package/dist/glDebugPrelude.d.ts.map +1 -0
  55. package/dist/glDebugPrelude.js +143 -0
  56. package/dist/glDebugPrelude.js.map +1 -0
  57. package/dist/glEnvironmentCube.d.ts +5 -0
  58. package/dist/glEnvironmentCube.d.ts.map +1 -0
  59. package/dist/glEnvironmentCube.js +65 -0
  60. package/dist/glEnvironmentCube.js.map +1 -0
  61. package/dist/glEnvironmentIblBake.d.ts +4 -0
  62. package/dist/glEnvironmentIblBake.d.ts.map +1 -0
  63. package/dist/glEnvironmentIblBake.js +365 -0
  64. package/dist/glEnvironmentIblBake.js.map +1 -0
  65. package/dist/glEnvironmentSkybox.d.ts +3 -0
  66. package/dist/glEnvironmentSkybox.d.ts.map +1 -0
  67. package/dist/glEnvironmentSkybox.js +95 -0
  68. package/dist/glEnvironmentSkybox.js.map +1 -0
  69. package/dist/glLitProgram.d.ts +5 -0
  70. package/dist/glLitProgram.d.ts.map +1 -0
  71. package/dist/glLitProgram.js +211 -0
  72. package/dist/glLitProgram.js.map +1 -0
  73. package/dist/glMatcapPrelude.d.ts +8 -0
  74. package/dist/glMatcapPrelude.d.ts.map +1 -0
  75. package/dist/glMatcapPrelude.js +118 -0
  76. package/dist/glMatcapPrelude.js.map +1 -0
  77. package/dist/glMeshMaterialRegistry.d.ts +5 -0
  78. package/dist/glMeshMaterialRegistry.d.ts.map +1 -0
  79. package/dist/glMeshMaterialRegistry.js +29 -0
  80. package/dist/glMeshMaterialRegistry.js.map +1 -0
  81. package/dist/glMeshProgram.d.ts +14 -0
  82. package/dist/glMeshProgram.d.ts.map +1 -0
  83. package/dist/glMeshProgram.js +193 -0
  84. package/dist/glMeshProgram.js.map +1 -0
  85. package/dist/glMeshUpload.d.ts +4 -0
  86. package/dist/glMeshUpload.d.ts.map +1 -0
  87. package/dist/glMeshUpload.js +190 -0
  88. package/dist/glMeshUpload.js.map +1 -0
  89. package/dist/glParticleEmitter3D.d.ts +4 -0
  90. package/dist/glParticleEmitter3D.d.ts.map +1 -0
  91. package/dist/glParticleEmitter3D.js +342 -0
  92. package/dist/glParticleEmitter3D.js.map +1 -0
  93. package/dist/glPbrPrelude.d.ts +8 -0
  94. package/dist/glPbrPrelude.d.ts.map +1 -0
  95. package/dist/glPbrPrelude.js +569 -0
  96. package/dist/glPbrPrelude.js.map +1 -0
  97. package/dist/glPbrProgramCache.d.ts +4 -0
  98. package/dist/glPbrProgramCache.d.ts.map +1 -0
  99. package/dist/glPbrProgramCache.js +63 -0
  100. package/dist/glPbrProgramCache.js.map +1 -0
  101. package/dist/glPbrStandardBlock.d.ts +13 -0
  102. package/dist/glPbrStandardBlock.d.ts.map +1 -0
  103. package/dist/glPbrStandardBlock.js +107 -0
  104. package/dist/glPbrStandardBlock.js.map +1 -0
  105. package/dist/glScene3DRuntime.d.ts +5 -0
  106. package/dist/glScene3DRuntime.d.ts.map +1 -0
  107. package/dist/glScene3DRuntime.js +99 -0
  108. package/dist/glScene3DRuntime.js.map +1 -0
  109. package/dist/glScene3DTestHelper.d.ts +20 -0
  110. package/dist/glScene3DTestHelper.d.ts.map +1 -0
  111. package/dist/glScene3DTestHelper.js +178 -0
  112. package/dist/glScene3DTestHelper.js.map +1 -0
  113. package/dist/glScene3DTime.d.ts +4 -0
  114. package/dist/glScene3DTime.d.ts.map +1 -0
  115. package/dist/glScene3DTime.js +18 -0
  116. package/dist/glScene3DTime.js.map +1 -0
  117. package/dist/glShadedBuiltInModifiers.d.ts +11 -0
  118. package/dist/glShadedBuiltInModifiers.d.ts.map +1 -0
  119. package/dist/glShadedBuiltInModifiers.js +386 -0
  120. package/dist/glShadedBuiltInModifiers.js.map +1 -0
  121. package/dist/glShadedModifierSnippet.d.ts +4 -0
  122. package/dist/glShadedModifierSnippet.d.ts.map +1 -0
  123. package/dist/glShadedModifierSnippet.js +24 -0
  124. package/dist/glShadedModifierSnippet.js.map +1 -0
  125. package/dist/glShadedPrelude.d.ts +5 -0
  126. package/dist/glShadedPrelude.d.ts.map +1 -0
  127. package/dist/glShadedPrelude.js +393 -0
  128. package/dist/glShadedPrelude.js.map +1 -0
  129. package/dist/glShadowMap.d.ts +3 -0
  130. package/dist/glShadowMap.d.ts.map +1 -0
  131. package/dist/glShadowMap.js +150 -0
  132. package/dist/glShadowMap.js.map +1 -0
  133. package/dist/glToonPrelude.d.ts +7 -0
  134. package/dist/glToonPrelude.d.ts.map +1 -0
  135. package/dist/glToonPrelude.js +172 -0
  136. package/dist/glToonPrelude.js.map +1 -0
  137. package/dist/glUnlitPrelude.d.ts +9 -0
  138. package/dist/glUnlitPrelude.d.ts.map +1 -0
  139. package/dist/glUnlitPrelude.js +160 -0
  140. package/dist/glUnlitPrelude.js.map +1 -0
  141. package/dist/glWireframePrelude.d.ts +6 -0
  142. package/dist/glWireframePrelude.d.ts.map +1 -0
  143. package/dist/glWireframePrelude.js +56 -0
  144. package/dist/glWireframePrelude.js.map +1 -0
  145. package/dist/glWireframeUpload.d.ts +4 -0
  146. package/dist/glWireframeUpload.d.ts.map +1 -0
  147. package/dist/glWireframeUpload.js +85 -0
  148. package/dist/glWireframeUpload.js.map +1 -0
  149. package/dist/index.d.ts +56 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +56 -0
  152. package/dist/index.js.map +1 -0
  153. package/dist/iridescencePbrGlMeshMaterialRenderer.d.ts +4 -0
  154. package/dist/iridescencePbrGlMeshMaterialRenderer.d.ts.map +1 -0
  155. package/dist/iridescencePbrGlMeshMaterialRenderer.js +54 -0
  156. package/dist/iridescencePbrGlMeshMaterialRenderer.js.map +1 -0
  157. package/dist/lambertGlMeshMaterialRenderer.d.ts +4 -0
  158. package/dist/lambertGlMeshMaterialRenderer.d.ts.map +1 -0
  159. package/dist/lambertGlMeshMaterialRenderer.js +70 -0
  160. package/dist/lambertGlMeshMaterialRenderer.js.map +1 -0
  161. package/dist/matcapGlMeshMaterialRenderer.d.ts +4 -0
  162. package/dist/matcapGlMeshMaterialRenderer.d.ts.map +1 -0
  163. package/dist/matcapGlMeshMaterialRenderer.js +49 -0
  164. package/dist/matcapGlMeshMaterialRenderer.js.map +1 -0
  165. package/dist/normalGlMeshMaterialRenderer.d.ts +4 -0
  166. package/dist/normalGlMeshMaterialRenderer.d.ts.map +1 -0
  167. package/dist/normalGlMeshMaterialRenderer.js +39 -0
  168. package/dist/normalGlMeshMaterialRenderer.js.map +1 -0
  169. package/dist/phongGlMeshMaterialRenderer.d.ts +4 -0
  170. package/dist/phongGlMeshMaterialRenderer.d.ts.map +1 -0
  171. package/dist/phongGlMeshMaterialRenderer.js +90 -0
  172. package/dist/phongGlMeshMaterialRenderer.js.map +1 -0
  173. package/dist/prepareGlScene3DForwardLights.d.ts +3 -0
  174. package/dist/prepareGlScene3DForwardLights.d.ts.map +1 -0
  175. package/dist/prepareGlScene3DForwardLights.js +117 -0
  176. package/dist/prepareGlScene3DForwardLights.js.map +1 -0
  177. package/dist/presentGlScene3D.d.ts +3 -0
  178. package/dist/presentGlScene3D.d.ts.map +1 -0
  179. package/dist/presentGlScene3D.js +21 -0
  180. package/dist/presentGlScene3D.js.map +1 -0
  181. package/dist/registerStandardPbrGlMaterial.d.ts +3 -0
  182. package/dist/registerStandardPbrGlMaterial.d.ts.map +1 -0
  183. package/dist/registerStandardPbrGlMaterial.js +11 -0
  184. package/dist/registerStandardPbrGlMaterial.js.map +1 -0
  185. package/dist/shadedGlMeshMaterialRenderer.d.ts +4 -0
  186. package/dist/shadedGlMeshMaterialRenderer.d.ts.map +1 -0
  187. package/dist/shadedGlMeshMaterialRenderer.js +135 -0
  188. package/dist/shadedGlMeshMaterialRenderer.js.map +1 -0
  189. package/dist/sheenPbrGlMeshMaterialRenderer.d.ts +4 -0
  190. package/dist/sheenPbrGlMeshMaterialRenderer.d.ts.map +1 -0
  191. package/dist/sheenPbrGlMeshMaterialRenderer.js +54 -0
  192. package/dist/sheenPbrGlMeshMaterialRenderer.js.map +1 -0
  193. package/dist/specularGlossinessPbrGlMeshMaterialRenderer.d.ts +4 -0
  194. package/dist/specularGlossinessPbrGlMeshMaterialRenderer.d.ts.map +1 -0
  195. package/dist/specularGlossinessPbrGlMeshMaterialRenderer.js +121 -0
  196. package/dist/specularGlossinessPbrGlMeshMaterialRenderer.js.map +1 -0
  197. package/dist/specularPbrGlMeshMaterialRenderer.d.ts +4 -0
  198. package/dist/specularPbrGlMeshMaterialRenderer.d.ts.map +1 -0
  199. package/dist/specularPbrGlMeshMaterialRenderer.js +55 -0
  200. package/dist/specularPbrGlMeshMaterialRenderer.js.map +1 -0
  201. package/dist/standardPbrGlMeshMaterialRenderer.d.ts +3 -0
  202. package/dist/standardPbrGlMeshMaterialRenderer.d.ts.map +1 -0
  203. package/dist/standardPbrGlMeshMaterialRenderer.js +35 -0
  204. package/dist/standardPbrGlMeshMaterialRenderer.js.map +1 -0
  205. package/dist/subsurfacePbrGlMeshMaterialRenderer.d.ts +4 -0
  206. package/dist/subsurfacePbrGlMeshMaterialRenderer.d.ts.map +1 -0
  207. package/dist/subsurfacePbrGlMeshMaterialRenderer.js +57 -0
  208. package/dist/subsurfacePbrGlMeshMaterialRenderer.js.map +1 -0
  209. package/dist/toonGlMeshMaterialRenderer.d.ts +4 -0
  210. package/dist/toonGlMeshMaterialRenderer.d.ts.map +1 -0
  211. package/dist/toonGlMeshMaterialRenderer.js +81 -0
  212. package/dist/toonGlMeshMaterialRenderer.js.map +1 -0
  213. package/dist/transmissionVolumePbrGlMeshMaterialRenderer.d.ts +4 -0
  214. package/dist/transmissionVolumePbrGlMeshMaterialRenderer.d.ts.map +1 -0
  215. package/dist/transmissionVolumePbrGlMeshMaterialRenderer.js +62 -0
  216. package/dist/transmissionVolumePbrGlMeshMaterialRenderer.js.map +1 -0
  217. package/dist/unlitGlMeshMaterialRenderer.d.ts +4 -0
  218. package/dist/unlitGlMeshMaterialRenderer.d.ts.map +1 -0
  219. package/dist/unlitGlMeshMaterialRenderer.js +61 -0
  220. package/dist/unlitGlMeshMaterialRenderer.js.map +1 -0
  221. package/dist/vertexColorGlMeshMaterialRenderer.d.ts +4 -0
  222. package/dist/vertexColorGlMeshMaterialRenderer.d.ts.map +1 -0
  223. package/dist/vertexColorGlMeshMaterialRenderer.js +54 -0
  224. package/dist/vertexColorGlMeshMaterialRenderer.js.map +1 -0
  225. package/dist/wireframeGlMeshMaterialRenderer.d.ts +4 -0
  226. package/dist/wireframeGlMeshMaterialRenderer.d.ts.map +1 -0
  227. package/dist/wireframeGlMeshMaterialRenderer.js +50 -0
  228. package/dist/wireframeGlMeshMaterialRenderer.js.map +1 -0
  229. package/package.json +58 -0
  230. package/src/anisotropyPbrGlMeshMaterialRenderer.test.ts +86 -0
  231. package/src/blinnPhongGlMeshMaterialRenderer.test.ts +94 -0
  232. package/src/clearcoatPbrGlMeshMaterialRenderer.test.ts +81 -0
  233. package/src/customShaderGlMeshMaterialRenderer.test.ts +169 -0
  234. package/src/depthGlMeshMaterialRenderer.test.ts +69 -0
  235. package/src/drawGlScene3D.test.ts +304 -0
  236. package/src/emissiveGlMeshMaterialRenderer.test.ts +60 -0
  237. package/src/enableGlScene3DColorSpaceGuards.test.ts +63 -0
  238. package/src/enableGlScene3DCustomShaderGuards.test.ts +85 -0
  239. package/src/enableGlScene3DDeformGuards.test.ts +98 -0
  240. package/src/enableGlScene3DForwardLightSelectionGuards.test.ts +84 -0
  241. package/src/explainGlScene3DForwardLightSelection.test.ts +32 -0
  242. package/src/glClassicPrelude.test.ts +214 -0
  243. package/src/glDebugPrelude.test.ts +94 -0
  244. package/src/glEnvironmentCube.test.ts +71 -0
  245. package/src/glEnvironmentIblBake.test.ts +31 -0
  246. package/src/glEnvironmentSkybox.test.ts +24 -0
  247. package/src/glLitProgram.test.ts +181 -0
  248. package/src/glMatcapPrelude.test.ts +74 -0
  249. package/src/glMeshMaterialRegistry.test.ts +52 -0
  250. package/src/glMeshProgram.test.ts +406 -0
  251. package/src/glMeshUpload.test.ts +311 -0
  252. package/src/glParticleEmitter3D.test.ts +280 -0
  253. package/src/glPbrPrelude.test.ts +178 -0
  254. package/src/glPbrProgramCache.test.ts +116 -0
  255. package/src/glPbrStandardBlock.test.ts +85 -0
  256. package/src/glScene3DRuntime.test.ts +108 -0
  257. package/src/glScene3DTime.test.ts +31 -0
  258. package/src/glShadedBuiltInModifiers.test.ts +377 -0
  259. package/src/glShadedModifierSnippet.test.ts +46 -0
  260. package/src/glShadedPrelude.test.ts +177 -0
  261. package/src/glShadowMap.test.ts +219 -0
  262. package/src/glToonPrelude.test.ts +118 -0
  263. package/src/glUnlitPrelude.test.ts +156 -0
  264. package/src/glWireframePrelude.test.ts +46 -0
  265. package/src/glWireframeUpload.test.ts +50 -0
  266. package/src/iridescencePbrGlMeshMaterialRenderer.test.ts +86 -0
  267. package/src/lambertGlMeshMaterialRenderer.test.ts +92 -0
  268. package/src/matcapGlMeshMaterialRenderer.test.ts +70 -0
  269. package/src/normalGlMeshMaterialRenderer.test.ts +69 -0
  270. package/src/phongGlMeshMaterialRenderer.test.ts +94 -0
  271. package/src/prepareGlScene3DForwardLights.test.ts +106 -0
  272. package/src/presentGlScene3D.test.ts +98 -0
  273. package/src/registerStandardPbrGlMaterial.test.ts +14 -0
  274. package/src/shadedGlMeshMaterialRenderer.test.ts +106 -0
  275. package/src/sheenPbrGlMeshMaterialRenderer.test.ts +83 -0
  276. package/src/specularGlossinessPbrGlMeshMaterialRenderer.test.ts +96 -0
  277. package/src/specularPbrGlMeshMaterialRenderer.test.ts +83 -0
  278. package/src/standardPbrGlMeshMaterialRenderer.test.ts +88 -0
  279. package/src/subsurfacePbrGlMeshMaterialRenderer.test.ts +86 -0
  280. package/src/toonGlMeshMaterialRenderer.test.ts +92 -0
  281. package/src/transmissionVolumePbrGlMeshMaterialRenderer.test.ts +93 -0
  282. package/src/unlitGlMeshMaterialRenderer.test.ts +88 -0
  283. package/src/vertexColorGlMeshMaterialRenderer.test.ts +60 -0
  284. package/src/wireframeGlMeshMaterialRenderer.test.ts +69 -0
@@ -0,0 +1,311 @@
1
+ import {
2
+ createBoxMeshGeometry,
3
+ createMeshGeometry,
4
+ setMeshGeometrySkinBindPose,
5
+ updateMeshMorph,
6
+ } from '@flighthq/mesh';
7
+ import { createMesh } from '@flighthq/scene3d';
8
+ import type { Mesh, MeshMorph, MeshSkinBindPose, VertexAttributeLayout, GlMeshUpload } from '@flighthq/types';
9
+
10
+ import { destroyGlMeshUpload, ensureGlMeshUpload } from './glMeshUpload';
11
+ import { getGlScene3DRuntime } from './glScene3DRuntime';
12
+ import { makeGlScene3DState } from './glScene3DTestHelper';
13
+
14
+ describe('destroyGlMeshUpload', () => {
15
+ it('deletes the VAO and the vertex + index buffers of an indexed upload', () => {
16
+ const { state, gl } = makeGlScene3DState();
17
+ const upload = ensureGlMeshUpload(state, createBoxMeshGeometry());
18
+ destroyGlMeshUpload(state, upload);
19
+ expect(gl.calls.filter((c) => c.name === 'deleteVertexArray').length).toBe(1);
20
+ expect(gl.calls.filter((c) => c.name === 'deleteBuffer').length).toBe(2);
21
+ });
22
+
23
+ it('skips the index buffer when the upload is non-indexed', () => {
24
+ const { state, gl } = makeGlScene3DState();
25
+ const upload: GlMeshUpload = {
26
+ indexBuffer: null,
27
+ indexCount: 0,
28
+ indexType: gl.UNSIGNED_SHORT,
29
+ primitiveMode: gl.TRIANGLES,
30
+ vao: {} as WebGLVertexArrayObject,
31
+ version: 0,
32
+ vertexBuffer: {} as WebGLBuffer,
33
+ };
34
+ destroyGlMeshUpload(state, upload);
35
+ expect(gl.calls.filter((c) => c.name === 'deleteVertexArray').length).toBe(1);
36
+ expect(gl.calls.filter((c) => c.name === 'deleteBuffer').length).toBe(1);
37
+ });
38
+ });
39
+
40
+ describe('ensureGlMeshUpload', () => {
41
+ it('uploads vertex + index buffers into a VAO and reports the indexed draw shape', () => {
42
+ const { state, gl } = makeGlScene3DState();
43
+ const geometry = createBoxMeshGeometry();
44
+ const upload = ensureGlMeshUpload(state, geometry);
45
+
46
+ expect(upload.vao).not.toBeNull();
47
+ expect(upload.vertexBuffer).not.toBeNull();
48
+ expect(upload.indexBuffer).not.toBeNull();
49
+ expect(upload.indexCount).toBe(geometry.indices!.length);
50
+ const expectedType = geometry.indices instanceof Uint32Array ? gl.UNSIGNED_INT : gl.UNSIGNED_SHORT;
51
+ expect(upload.indexType).toBe(expectedType);
52
+ expect(upload.version).toBe(geometry.version);
53
+ expect(upload.primitiveMode).toBe(gl.TRIANGLES);
54
+ expect(gl.calls.some((c) => c.name === 'bufferData')).toBe(true);
55
+ });
56
+
57
+ it('reports the vertex count for a non-indexed upload', () => {
58
+ const { state, gl } = makeGlScene3DState();
59
+ const layout: VertexAttributeLayout = {
60
+ attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
61
+ stride: 12,
62
+ };
63
+ const geometry = createMeshGeometry({ layout, vertices: new Float32Array(9) });
64
+ const upload = ensureGlMeshUpload(state, geometry);
65
+
66
+ expect(upload.indexBuffer).toBeNull();
67
+ expect(upload.indexCount).toBe(3);
68
+ expect(upload.primitiveMode).toBe(gl.TRIANGLES);
69
+ });
70
+
71
+ it('refreshes the primitive mode on a cached upload without re-uploading buffers', () => {
72
+ const { state, gl } = makeGlScene3DState();
73
+ const geometry = createBoxMeshGeometry();
74
+ const upload = ensureGlMeshUpload(state, geometry);
75
+ const bufferDataCount = gl.calls.filter((call) => call.name === 'bufferData').length;
76
+
77
+ geometry.topology = 'triangle-strip';
78
+ const reused = ensureGlMeshUpload(state, geometry);
79
+
80
+ expect(reused).toBe(upload);
81
+ expect(reused.primitiveMode).toBe(gl.TRIANGLE_STRIP);
82
+ expect(gl.calls.filter((call) => call.name === 'bufferData').length).toBe(bufferDataCount);
83
+ });
84
+
85
+ it('deletes an obsolete index buffer when geometry becomes non-indexed', () => {
86
+ const { state, gl } = makeGlScene3DState();
87
+ const geometry = createBoxMeshGeometry();
88
+ const upload = ensureGlMeshUpload(state, geometry);
89
+ expect(upload.indexBuffer).not.toBeNull();
90
+
91
+ geometry.indices = null;
92
+ geometry.version++;
93
+ ensureGlMeshUpload(state, geometry);
94
+
95
+ expect(upload.indexBuffer).toBeNull();
96
+ expect(gl.calls.filter((call) => call.name === 'deleteBuffer').length).toBe(1);
97
+ });
98
+
99
+ it('caches by geometry and reuses the upload when the version is unchanged', () => {
100
+ const { state, gl } = makeGlScene3DState();
101
+ const geometry = createBoxMeshGeometry();
102
+ const first = ensureGlMeshUpload(state, geometry);
103
+ const bufferDataCount = gl.calls.filter((c) => c.name === 'bufferData').length;
104
+ const second = ensureGlMeshUpload(state, geometry);
105
+ expect(second).toBe(first);
106
+ // No re-upload: bufferData not called again, only the VAO re-bound.
107
+ expect(gl.calls.filter((c) => c.name === 'bufferData').length).toBe(bufferDataCount);
108
+ expect(getGlScene3DRuntime(state).uploadCache.get(geometry)).toBe(first);
109
+ });
110
+
111
+ it('re-uploads when the geometry version is bumped, reusing the GL objects', () => {
112
+ const { state, gl } = makeGlScene3DState();
113
+ const geometry = createBoxMeshGeometry();
114
+ const first = ensureGlMeshUpload(state, geometry);
115
+ geometry.version++;
116
+ const bufferDataCount = gl.calls.filter((c) => c.name === 'bufferData').length;
117
+ const second = ensureGlMeshUpload(state, geometry);
118
+ expect(second).toBe(first);
119
+ expect(second.vao).toBe(first.vao);
120
+ expect(gl.calls.filter((c) => c.name === 'bufferData').length).toBeGreaterThan(bufferDataCount);
121
+ expect(second.version).toBe(geometry.version);
122
+ });
123
+
124
+ it('converts packed integer storage into the float inputs declared by mesh shaders', () => {
125
+ const { state, gl } = makeGlScene3DState();
126
+ const layout: VertexAttributeLayout = {
127
+ attributes: [
128
+ { byteOffset: 0, format: 'float32x3', semantic: 'position' },
129
+ { byteOffset: 12, format: 'uint8x4', semantic: 'joints0' },
130
+ { byteOffset: 16, format: 'unorm8x4', semantic: 'weights0' },
131
+ ],
132
+ stride: 20,
133
+ };
134
+ ensureGlMeshUpload(state, createMeshGeometry({ layout, vertices: new Float32Array(5) }));
135
+
136
+ const jointCall = gl.calls.find((call) => call.name === 'vertexAttribPointer' && call.args[0] === 6);
137
+ const weightCall = gl.calls.find((call) => call.name === 'vertexAttribPointer' && call.args[0] === 7);
138
+ expect(jointCall?.args.slice(1)).toEqual([4, gl.UNSIGNED_BYTE, false, 20, 12]);
139
+ expect(weightCall?.args.slice(1)).toEqual([4, gl.UNSIGNED_BYTE, true, 20, 16]);
140
+ expect(gl.calls.some((call) => call.name === 'vertexAttribIPointer')).toBe(false);
141
+ });
142
+
143
+ // The double-skinning fix: a GPU-skinned draw must upload the STATIC bind pose, not the CPU-posed
144
+ // geometry.vertices updateMeshSkin writes each frame — else the shader would skin an already-skinned
145
+ // vertex (M²). Captures the box's original positions as the bind pose, then displaces every vertex to
146
+ // simulate a CPU pose and asserts the uploaded buffer carries the bind position, not the displaced one.
147
+ it('uploads the skin bind pose (not the CPU-posed vertices) for a GPU-skinned draw', () => {
148
+ const { state, gl } = makeGlScene3DState();
149
+ const geometry = createBoxMeshGeometry(2, 2, 2);
150
+ const floatsPerVertex = geometry.layout.stride / 4;
151
+ const vertexCount = geometry.vertices.length / floatsPerVertex;
152
+ const positions = new Float32Array(vertexCount * 3);
153
+ const normals = new Float32Array(vertexCount * 3);
154
+ for (let v = 0; v < vertexCount; v++) {
155
+ const b = v * floatsPerVertex;
156
+ positions[v * 3] = geometry.vertices[b]!;
157
+ positions[v * 3 + 1] = geometry.vertices[b + 1]!;
158
+ positions[v * 3 + 2] = geometry.vertices[b + 2]!;
159
+ normals[v * 3] = geometry.vertices[b + 3]!;
160
+ normals[v * 3 + 1] = geometry.vertices[b + 4]!;
161
+ normals[v * 3 + 2] = geometry.vertices[b + 5]!;
162
+ }
163
+ const bindPose: MeshSkinBindPose = {
164
+ joints: new Float32Array(0),
165
+ normals,
166
+ positions,
167
+ skinnedNormals: new Float32Array(0),
168
+ skinnedPositions: new Float32Array(0),
169
+ weights: new Float32Array(0),
170
+ };
171
+ setMeshGeometrySkinBindPose(geometry, bindPose);
172
+ // Simulate updateMeshSkin having posed the vertices: shift every position by +1000.
173
+ for (let b = 0; b < geometry.vertices.length; b += floatsPerVertex) geometry.vertices[b] += 1000;
174
+ geometry.version++;
175
+
176
+ ensureGlMeshUpload(state, geometry, true);
177
+ const uploaded = gl.calls
178
+ .filter((c) => c.name === 'bufferData')
179
+ .map((c) => c.args[1])
180
+ .find((data): data is Float32Array => data instanceof Float32Array)!;
181
+ // Vertex 0's uploaded position.x is the bind value, not the +1000 CPU-posed value.
182
+ expect(uploaded[0]).toBeCloseTo(positions[0]!);
183
+ expect(uploaded[0]).not.toBeCloseTo(positions[0]! + 1000);
184
+ });
185
+
186
+ it('reuses the bind-pose upload across version bumps for a GPU-skinned draw (no per-frame re-skin)', () => {
187
+ const { state, gl } = makeGlScene3DState();
188
+ const geometry = createBoxMeshGeometry(2, 2, 2);
189
+ const floatsPerVertex = geometry.layout.stride / 4;
190
+ const vertexCount = geometry.vertices.length / floatsPerVertex;
191
+ const positions = new Float32Array(vertexCount * 3);
192
+ for (let v = 0; v < vertexCount; v++) {
193
+ const b = v * floatsPerVertex;
194
+ positions[v * 3] = geometry.vertices[b]!;
195
+ positions[v * 3 + 1] = geometry.vertices[b + 1]!;
196
+ positions[v * 3 + 2] = geometry.vertices[b + 2]!;
197
+ }
198
+ setMeshGeometrySkinBindPose(geometry, {
199
+ joints: new Float32Array(0),
200
+ normals: new Float32Array(vertexCount * 3),
201
+ positions,
202
+ skinnedNormals: new Float32Array(0),
203
+ skinnedPositions: new Float32Array(0),
204
+ weights: new Float32Array(0),
205
+ });
206
+ ensureGlMeshUpload(state, geometry, true);
207
+ const afterFirst = gl.calls.filter((c) => c.name === 'bufferData').length;
208
+ geometry.version++; // a per-frame updateMeshSkin bump
209
+ ensureGlMeshUpload(state, geometry, true);
210
+ // The static bind buffer is not re-uploaded on the version bump.
211
+ expect(gl.calls.filter((c) => c.name === 'bufferData').length).toBe(afterFirst);
212
+ });
213
+
214
+ // Morph + skin compose on GPU: when a geometry is both morphed and GPU-skinned, the per-frame morphed
215
+ // vertices (not the frozen skin bind pose) are what the shader skins, and they re-upload each frame.
216
+ it('uploads the morphed vertices (not the frozen skin bind pose) for a morphed + GPU-skinned draw', () => {
217
+ const { state, gl } = makeGlScene3DState();
218
+ const geometry = createMeshGeometry({
219
+ layout: { attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }], stride: 12 },
220
+ vertices: new Float32Array([1, 0, 0]),
221
+ });
222
+ // A captured skin bind pose at the rest position x = 1 — what the freeze would pin the buffer to.
223
+ setMeshGeometrySkinBindPose(geometry, {
224
+ joints: new Float32Array(0),
225
+ normals: new Float32Array(3),
226
+ positions: new Float32Array([1, 0, 0]),
227
+ skinnedNormals: new Float32Array(0),
228
+ skinnedPositions: new Float32Array(0),
229
+ weights: new Float32Array(0),
230
+ });
231
+
232
+ // prepareScene3DMorph blends the morph into geometry.vertices (x → 5) and captures the morph bind pose.
233
+ const mesh = { geometry, materials: [], morph: undefined } as unknown as Mesh;
234
+ mesh.morph = {
235
+ targets: [{ normalDeltas: null, positionDeltas: new Float32Array([4, 0, 0]), tangentDeltas: null }],
236
+ weights: new Float32Array([1]),
237
+ };
238
+ updateMeshMorph(mesh);
239
+
240
+ ensureGlMeshUpload(state, geometry, true);
241
+ const firstUpload = lastFloat32BufferData(gl.calls);
242
+ // The morphed value (5), NOT the frozen skin bind (1): the morph is not discarded.
243
+ expect(firstUpload[0]).toBeCloseTo(5);
244
+
245
+ const uploadsAfterFirst = gl.calls.filter((c) => c.name === 'bufferData').length;
246
+
247
+ // A moving morph weight re-uploads the freshly-morphed bind (x → 9), unlike the skin-only freeze.
248
+ mesh.morph!.weights[0] = 2;
249
+ updateMeshMorph(mesh);
250
+ ensureGlMeshUpload(state, geometry, true);
251
+ expect(gl.calls.filter((c) => c.name === 'bufferData').length).toBeGreaterThan(uploadsAfterFirst);
252
+ expect(lastFloat32BufferData(gl.calls)[0]).toBeCloseTo(9);
253
+ });
254
+
255
+ // The GL vertex morph path is CPU-blend-then-upload: updateMeshMorph blends base + Σ wᵢ·targetᵢ into
256
+ // geometry.vertices and bumps the version, and ensureGlMeshUpload (the non-skinned path) re-uploads the
257
+ // deformed vertices — no HAS_MORPH shader permutation. (Folded from the former glMeshMorph.test.ts,
258
+ // whose source lives here in glMeshUpload.ts.)
259
+ it('uploads the morph-blended vertices for a morphed mesh (CPU-blend-then-upload GL path)', () => {
260
+ const { state, gl } = makeGlScene3DState();
261
+ const layout: VertexAttributeLayout = {
262
+ attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
263
+ stride: 12,
264
+ };
265
+ const geometry = createMeshGeometry({ layout, vertices: new Float32Array([0, 0, 0, 1, 0, 0]) });
266
+ const morph: MeshMorph = {
267
+ targets: [{ normalDeltas: null, positionDeltas: new Float32Array([0, 5, 0, 0, 5, 0]), tangentDeltas: null }],
268
+ weights: new Float32Array([1]),
269
+ };
270
+ const mesh = createMesh(geometry, []);
271
+ mesh.morph = morph;
272
+
273
+ ensureGlMeshUpload(state, geometry, false); // first upload before morph blends
274
+ updateMeshMorph(mesh); // weight 1 raises both vertices by 5 in y
275
+ ensureGlMeshUpload(state, geometry, false);
276
+
277
+ expect(Array.from(lastFloat32BufferData(gl.calls))).toEqual([0, 5, 0, 1, 5, 0]);
278
+ });
279
+
280
+ it('re-uploads as the morph weights change frame to frame', () => {
281
+ const { state, gl } = makeGlScene3DState();
282
+ const layout: VertexAttributeLayout = {
283
+ attributes: [{ byteOffset: 0, format: 'float32x3', semantic: 'position' }],
284
+ stride: 12,
285
+ };
286
+ const geometry = createMeshGeometry({ layout, vertices: new Float32Array([0, 0, 0]) });
287
+ const morph: MeshMorph = {
288
+ targets: [{ normalDeltas: null, positionDeltas: new Float32Array([2, 0, 0]), tangentDeltas: null }],
289
+ weights: new Float32Array([1]),
290
+ };
291
+ const mesh = createMesh(geometry, []);
292
+ mesh.morph = morph;
293
+
294
+ updateMeshMorph(mesh);
295
+ ensureGlMeshUpload(state, geometry, false);
296
+ expect(Array.from(lastFloat32BufferData(gl.calls))).toEqual([2, 0, 0]);
297
+
298
+ morph.weights[0] = 0.5;
299
+ updateMeshMorph(mesh);
300
+ ensureGlMeshUpload(state, geometry, false);
301
+ expect(Array.from(lastFloat32BufferData(gl.calls))).toEqual([1, 0, 0]);
302
+ });
303
+ });
304
+
305
+ function lastFloat32BufferData(calls: readonly { name: string; args: readonly unknown[] }[]): Float32Array {
306
+ const data = calls
307
+ .filter((c) => c.name === 'bufferData')
308
+ .map((c) => c.args[1])
309
+ .filter((d): d is Float32Array => d instanceof Float32Array);
310
+ return data[data.length - 1]!;
311
+ }
@@ -0,0 +1,280 @@
1
+ import { createCamera3D } from '@flighthq/camera';
2
+ import { createMatrix4, setVector3 } from '@flighthq/geometry';
3
+ import { addNodeChild, invalidateNodeLocalTransform } from '@flighthq/node';
4
+ import { createParticleEmitter3D, reserveParticleEmitter3D } from '@flighthq/particleemitter';
5
+ import { createNode3D, Node3DKind } from '@flighthq/scene3d';
6
+ import type { ParticleEmitter3D, Scene3DLightsLike } from '@flighthq/types';
7
+
8
+ import { destroyGlParticleEmitter3DShader, drawGlScene3DParticleEmitter3Ds } from './glParticleEmitter3D';
9
+ import { makeGlScene3DState } from './glScene3DTestHelper';
10
+
11
+ function makeCamera() {
12
+ const cam = createCamera3D({
13
+ far: 100,
14
+ near: 0.1,
15
+ projection: { aspect: 1, fovY: Math.PI / 3, kind: 'perspective' },
16
+ });
17
+ cam.view = createMatrix4();
18
+ return cam;
19
+ }
20
+
21
+ function makeLights(): Scene3DLightsLike {
22
+ return { ambient: null, directional: null };
23
+ }
24
+
25
+ function makeEmitterWithParticles(count: number): ParticleEmitter3D {
26
+ const emitter = createParticleEmitter3D();
27
+ reserveParticleEmitter3D(emitter, count);
28
+ const data = emitter.data;
29
+ data.particleCount = count;
30
+ for (let i = 0; i < count; i++) {
31
+ const tt = i * 4;
32
+ data.transforms[tt] = i;
33
+ data.transforms[tt + 1] = i * 2;
34
+ data.transforms[tt + 2] = 0;
35
+ data.transforms[tt + 3] = 1;
36
+ data.positionsZ[i] = i * 3;
37
+ data.alphas[i] = 1;
38
+ const ct = i * 3;
39
+ data.colors[ct] = 1;
40
+ data.colors[ct + 1] = 1;
41
+ data.colors[ct + 2] = 1;
42
+ data.ids[i] = 0;
43
+ }
44
+ return emitter;
45
+ }
46
+
47
+ // A single-particle emitter carrying a texture atlas whose sole region is regionWidth×regionHeight
48
+ // pixels within a 128×128 image. Exercises the atlas draw path — texture upload/bind and quad-size
49
+ // normalization — that the plain makeEmitterWithParticles (atlas === null) never reaches.
50
+ function makeAtlasEmitter(regionWidth: number, regionHeight: number): ParticleEmitter3D {
51
+ const emitter = makeEmitterWithParticles(1);
52
+ const img = document.createElement('img');
53
+ emitter.data.atlas = {
54
+ image: { source: img, width: 128, height: 128 },
55
+ regions: [{ id: 0, x: 0, y: 0, width: regionWidth, height: regionHeight }],
56
+ } as unknown as NonNullable<ParticleEmitter3D['data']['atlas']>;
57
+ return emitter;
58
+ }
59
+
60
+ describe('destroyGlParticleEmitter3DShader', () => {
61
+ it('is a no-op when no shader was created', () => {
62
+ const { state } = makeGlScene3DState();
63
+ expect(() => destroyGlParticleEmitter3DShader(state)).not.toThrow();
64
+ });
65
+
66
+ it('deletes GPU resources after a draw', () => {
67
+ const { state, gl } = makeGlScene3DState();
68
+ const scene = createNode3D(Node3DKind);
69
+ const emitter = makeEmitterWithParticles(1);
70
+ addNodeChild(scene, emitter);
71
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
72
+ destroyGlParticleEmitter3DShader(state);
73
+ expect(gl.calls.some((c) => c.name === 'deleteProgram')).toBe(true);
74
+ expect(gl.calls.filter((c) => c.name === 'deleteBuffer').length).toBeGreaterThanOrEqual(3);
75
+ });
76
+ });
77
+
78
+ describe('drawGlScene3DParticleEmitter3Ds', () => {
79
+ it('is a no-op when scene has no particle emitter 3D nodes', () => {
80
+ const { state, gl } = makeGlScene3DState();
81
+ const scene = createNode3D(Node3DKind);
82
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
83
+ expect(gl.calls.some((c) => c.name === 'drawElementsInstanced')).toBe(false);
84
+ });
85
+
86
+ it('skips emitters with zero particles', () => {
87
+ const { state, gl } = makeGlScene3DState();
88
+ const scene = createNode3D(Node3DKind);
89
+ const emitter = createParticleEmitter3D();
90
+ addNodeChild(scene, emitter);
91
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
92
+ expect(gl.calls.some((c) => c.name === 'drawElementsInstanced')).toBe(false);
93
+ });
94
+
95
+ it('issues an instanced draw for particles', () => {
96
+ const { state, gl } = makeGlScene3DState();
97
+ const scene = createNode3D(Node3DKind);
98
+ const emitter = makeEmitterWithParticles(5);
99
+ addNodeChild(scene, emitter);
100
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
101
+ const draw = gl.calls.find((c) => c.name === 'drawElementsInstanced');
102
+ expect(draw).toBeDefined();
103
+ expect(draw!.args[4]).toBe(5);
104
+ });
105
+
106
+ it('applies the emitter node world transform for local-space particles', () => {
107
+ const { state, gl } = makeGlScene3DState();
108
+ const scene = createNode3D(Node3DKind);
109
+ const emitter = makeEmitterWithParticles(1);
110
+ emitter.data.transforms[0] = 1;
111
+ emitter.data.transforms[1] = 2;
112
+ emitter.data.positionsZ[0] = 3;
113
+ setVector3(emitter.position, 10, 20, 30);
114
+ invalidateNodeLocalTransform(emitter);
115
+ addNodeChild(scene, emitter);
116
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
117
+ const instanceData = gl.calls.find((c) => c.name === 'bufferSubData')!.args[2] as Float32Array;
118
+ // Local particles ride the emitter node: local (1,2,3) + node translation (10,20,30).
119
+ expect(instanceData[0]).toBeCloseTo(11);
120
+ expect(instanceData[1]).toBeCloseTo(22);
121
+ expect(instanceData[2]).toBeCloseTo(33);
122
+ });
123
+
124
+ it('skips the node world transform for world-space particles already baked into world coordinates', () => {
125
+ const { state, gl } = makeGlScene3DState();
126
+ const scene = createNode3D(Node3DKind);
127
+ const emitter = makeEmitterWithParticles(1);
128
+ emitter.data.transforms[0] = 1;
129
+ emitter.data.transforms[1] = 2;
130
+ emitter.data.positionsZ[0] = 3;
131
+ setVector3(emitter.position, 10, 20, 30);
132
+ invalidateNodeLocalTransform(emitter);
133
+ emitter.data.worldSpace = true;
134
+ addNodeChild(scene, emitter);
135
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
136
+ const instanceData = gl.calls.find((c) => c.name === 'bufferSubData')!.args[2] as Float32Array;
137
+ // World-space particles already hold world coordinates, so the node translation must NOT be
138
+ // re-applied: the instance position stays at the baked (1,2,3).
139
+ expect(instanceData[0]).toBeCloseTo(1);
140
+ expect(instanceData[1]).toBeCloseTo(2);
141
+ expect(instanceData[2]).toBeCloseTo(3);
142
+ });
143
+
144
+ it('compiles the shader program on first call', () => {
145
+ const { state, gl } = makeGlScene3DState();
146
+ const scene = createNode3D(Node3DKind);
147
+ addNodeChild(scene, makeEmitterWithParticles(1));
148
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
149
+ expect(gl.calls.some((c) => c.name === 'createProgram')).toBe(true);
150
+ });
151
+
152
+ it('reuses the shader program on subsequent calls', () => {
153
+ const { state, gl } = makeGlScene3DState();
154
+ const scene = createNode3D(Node3DKind);
155
+ addNodeChild(scene, makeEmitterWithParticles(1));
156
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
157
+ const programCount = gl.calls.filter((c) => c.name === 'createProgram').length;
158
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
159
+ expect(gl.calls.filter((c) => c.name === 'createProgram').length).toBe(programCount);
160
+ });
161
+
162
+ it('enables depth test and alpha blending', () => {
163
+ const { state, gl } = makeGlScene3DState();
164
+ const scene = createNode3D(Node3DKind);
165
+ addNodeChild(scene, makeEmitterWithParticles(1));
166
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
167
+ expect(gl.calls.some((c) => c.name === 'enable' && c.args[0] === gl.DEPTH_TEST)).toBe(true);
168
+ expect(gl.calls.some((c) => c.name === 'enable' && c.args[0] === gl.BLEND)).toBe(true);
169
+ expect(gl.calls.some((c) => c.name === 'depthMask' && c.args[0] === false)).toBe(true);
170
+ });
171
+
172
+ it('applies the emitter blend mode: additive for add, premultiplied over-blend for normal', () => {
173
+ const { state, gl } = makeGlScene3DState();
174
+ const scene = createNode3D(Node3DKind);
175
+ const additive = makeEmitterWithParticles(1);
176
+ additive.blendMode = 'add';
177
+ const normal = makeEmitterWithParticles(1);
178
+ addNodeChild(scene, additive);
179
+ addNodeChild(scene, normal);
180
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
181
+ const blendFuncs = gl.calls.filter((c) => c.name === 'blendFunc');
182
+ // add: straight ONE/ONE additive sum.
183
+ expect(blendFuncs.some((c) => c.args[0] === gl.ONE && c.args[1] === gl.ONE)).toBe(true);
184
+ // normal: premultiplied over-blend (ONE, ONE_MINUS_SRC_ALPHA), NOT SRC_ALPHA — the fragment
185
+ // shader emits premultiplied color, so SRC_ALPHA would double-apply alpha and darken.
186
+ expect(blendFuncs.some((c) => c.args[0] === gl.ONE && c.args[1] === gl.ONE_MINUS_SRC_ALPHA)).toBe(true);
187
+ expect(blendFuncs.some((c) => c.args[0] === gl.SRC_ALPHA)).toBe(false);
188
+ });
189
+
190
+ it('uploads and binds the atlas image (never a null texture) for textured particles', () => {
191
+ const { state, gl } = makeGlScene3DState();
192
+ const scene = createNode3D(Node3DKind);
193
+ addNodeChild(scene, makeAtlasEmitter(64, 32));
194
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
195
+ // The atlas image is uploaded to a GL texture on first use.
196
+ expect(gl.calls.some((c) => c.name === 'texImage2D')).toBe(true);
197
+ // Every texture bind targets a real texture object. Binding null here — as the bug did — leaves
198
+ // an incomplete sampler that reads (0,0,0,1), turning each particle into a solid black quad.
199
+ const textureBinds = gl.calls.filter((c) => c.name === 'bindTexture');
200
+ expect(textureBinds.length).toBeGreaterThan(0);
201
+ expect(textureBinds.every((c) => c.args[1] !== null)).toBe(true);
202
+ // u_hasTexture is signaled on so the fragment shader samples the atlas.
203
+ expect(
204
+ gl.calls.some(
205
+ (c) => c.name === 'uniform1i' && (c.args[0] as { name: string }).name === 'u_hasTexture' && c.args[1] === 1,
206
+ ),
207
+ ).toBe(true);
208
+ });
209
+
210
+ it('normalizes the particle quad to an aspect-ratio unit square, not the region pixel size', () => {
211
+ const { state, gl } = makeGlScene3DState();
212
+ const scene = createNode3D(Node3DKind);
213
+ // A 64×32 region: the larger axis normalizes to 1, the shorter to its aspect ratio (0.5). The
214
+ // raw pixel dims would make a 64-world-unit quad — screen-covering, with crippling overdraw.
215
+ addNodeChild(scene, makeAtlasEmitter(64, 32));
216
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
217
+ const upload = gl.calls.find((c) => c.name === 'bufferSubData');
218
+ expect(upload).toBeDefined();
219
+ const instanceData = upload!.args[2] as Float32Array;
220
+ // a_size occupies instance floats [13] (width) and [14] (height) of the first particle.
221
+ expect(instanceData[13]).toBeCloseTo(1);
222
+ expect(instanceData[14]).toBeCloseTo(0.5);
223
+ });
224
+
225
+ it('does not upload a texture and signals u_hasTexture off for atlas-less emitters', () => {
226
+ const { state, gl } = makeGlScene3DState();
227
+ const scene = createNode3D(Node3DKind);
228
+ addNodeChild(scene, makeEmitterWithParticles(1));
229
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
230
+ expect(gl.calls.some((c) => c.name === 'texImage2D')).toBe(false);
231
+ expect(
232
+ gl.calls.some(
233
+ (c) => c.name === 'uniform1i' && (c.args[0] as { name: string }).name === 'u_hasTexture' && c.args[1] === 0,
234
+ ),
235
+ ).toBe(true);
236
+ });
237
+
238
+ it('restores depth write and disables blend after draw', () => {
239
+ const { state, gl } = makeGlScene3DState();
240
+ const scene = createNode3D(Node3DKind);
241
+ addNodeChild(scene, makeEmitterWithParticles(1));
242
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
243
+ const depthMaskCalls = gl.calls.filter((c) => c.name === 'depthMask');
244
+ expect(depthMaskCalls[depthMaskCalls.length - 1].args[0]).toBe(true);
245
+ const disableCalls = gl.calls.filter((c) => c.name === 'disable');
246
+ expect(disableCalls.some((c) => c.args[0] === gl.BLEND)).toBe(true);
247
+ });
248
+
249
+ it('uploads view-projection and camera vectors as uniforms', () => {
250
+ const { state, gl } = makeGlScene3DState();
251
+ const scene = createNode3D(Node3DKind);
252
+ addNodeChild(scene, makeEmitterWithParticles(1));
253
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
254
+ expect(gl.calls.some((c) => c.name === 'uniformMatrix4fv')).toBe(true);
255
+ const uniform3fCalls = gl.calls.filter((c) => c.name === 'uniform3f');
256
+ expect(uniform3fCalls.length).toBeGreaterThanOrEqual(2);
257
+ });
258
+
259
+ it('draws multiple emitters in one call', () => {
260
+ const { state, gl } = makeGlScene3DState();
261
+ const scene = createNode3D(Node3DKind);
262
+ addNodeChild(scene, makeEmitterWithParticles(3));
263
+ addNodeChild(scene, makeEmitterWithParticles(7));
264
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
265
+ const draws = gl.calls.filter((c) => c.name === 'drawElementsInstanced');
266
+ expect(draws.length).toBe(2);
267
+ expect(draws[0].args[4]).toBe(3);
268
+ expect(draws[1].args[4]).toBe(7);
269
+ });
270
+
271
+ it('skips disabled emitters', () => {
272
+ const { state, gl } = makeGlScene3DState();
273
+ const scene = createNode3D(Node3DKind);
274
+ const emitter = makeEmitterWithParticles(5);
275
+ emitter.enabled = false;
276
+ addNodeChild(scene, emitter);
277
+ drawGlScene3DParticleEmitter3Ds(state, scene, makeCamera(), makeLights());
278
+ expect(gl.calls.some((c) => c.name === 'drawElementsInstanced')).toBe(false);
279
+ });
280
+ });