three-nebula 10.0.3 → 11.1.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 (247) hide show
  1. package/.claude/rules/no-coauthor.md +4 -0
  2. package/CHANGELOG.md +33 -0
  3. package/dist/three-nebula.cjs +109 -0
  4. package/dist/three-nebula.cjs.map +1 -0
  5. package/dist/three-nebula.mjs +2968 -0
  6. package/dist/three-nebula.mjs.map +1 -0
  7. package/dist/three-nebula.umd.js +109 -0
  8. package/dist/three-nebula.umd.js.map +1 -0
  9. package/package.json +32 -56
  10. package/specs/01-emitter-hierarchy.md +200 -0
  11. package/specs/02-determinism-scrubbing.md +188 -0
  12. package/specs/03-sound-renderer.md +183 -0
  13. package/specs/04-schema-versioning.md +140 -0
  14. package/specs/05-content-addressed-assets.md +288 -0
  15. package/specs/README.md +80 -0
  16. package/build/cjs/behaviour/Alpha.js +0 -189
  17. package/build/cjs/behaviour/Attraction.js +0 -189
  18. package/build/cjs/behaviour/Behaviour.js +0 -253
  19. package/build/cjs/behaviour/Collision.js +0 -146
  20. package/build/cjs/behaviour/Color.js +0 -148
  21. package/build/cjs/behaviour/CrossZone.js +0 -141
  22. package/build/cjs/behaviour/Force.js +0 -126
  23. package/build/cjs/behaviour/Gravity.js +0 -78
  24. package/build/cjs/behaviour/RandomDrift.js +0 -153
  25. package/build/cjs/behaviour/Repulsion.js +0 -123
  26. package/build/cjs/behaviour/Rotate.js +0 -265
  27. package/build/cjs/behaviour/Scale.js +0 -177
  28. package/build/cjs/behaviour/Spring.js +0 -133
  29. package/build/cjs/behaviour/constants.js +0 -28
  30. package/build/cjs/behaviour/index.js +0 -112
  31. package/build/cjs/behaviour/types.js +0 -33
  32. package/build/cjs/constants/index.js +0 -61
  33. package/build/cjs/core/Particle.js +0 -390
  34. package/build/cjs/core/Pool.js +0 -203
  35. package/build/cjs/core/System.js +0 -366
  36. package/build/cjs/core/constants.js +0 -163
  37. package/build/cjs/core/fromJSON.js +0 -128
  38. package/build/cjs/core/fromJSONAsync.js +0 -239
  39. package/build/cjs/core/index.js +0 -32
  40. package/build/cjs/core/three/Euler.js +0 -233
  41. package/build/cjs/core/three/Math.js +0 -91
  42. package/build/cjs/core/three/Matrix4.js +0 -745
  43. package/build/cjs/core/three/Quaternion.js +0 -451
  44. package/build/cjs/core/three/Vector3.js +0 -464
  45. package/build/cjs/core/three/constants.js +0 -298
  46. package/build/cjs/core/three/index.js +0 -48
  47. package/build/cjs/core/types.js +0 -13
  48. package/build/cjs/debug/Debug.js +0 -209
  49. package/build/cjs/debug/constants.js +0 -19
  50. package/build/cjs/debug/index.js +0 -24
  51. package/build/cjs/debug/log.js +0 -38
  52. package/build/cjs/ease/index.js +0 -149
  53. package/build/cjs/emitter/Emitter.js +0 -791
  54. package/build/cjs/emitter/FollowEmitter.js +0 -146
  55. package/build/cjs/emitter/constants.js +0 -20
  56. package/build/cjs/emitter/index.js +0 -24
  57. package/build/cjs/emitter/types.js +0 -11
  58. package/build/cjs/events/EventDispatcher.js +0 -105
  59. package/build/cjs/events/constants.js +0 -23
  60. package/build/cjs/events/index.js +0 -27
  61. package/build/cjs/index.js +0 -151
  62. package/build/cjs/initializer/Body.js +0 -122
  63. package/build/cjs/initializer/BodySprite.js +0 -146
  64. package/build/cjs/initializer/Initializer.js +0 -109
  65. package/build/cjs/initializer/InitializerUtil.js +0 -54
  66. package/build/cjs/initializer/Life.js +0 -106
  67. package/build/cjs/initializer/Mass.js +0 -101
  68. package/build/cjs/initializer/Position.js +0 -152
  69. package/build/cjs/initializer/Radius.js +0 -118
  70. package/build/cjs/initializer/Rate.js +0 -150
  71. package/build/cjs/initializer/Texture.js +0 -139
  72. package/build/cjs/initializer/Velocity/PolarVelocity.js +0 -102
  73. package/build/cjs/initializer/Velocity/RadialVelocity.js +0 -111
  74. package/build/cjs/initializer/Velocity/VectorVelocity.js +0 -108
  75. package/build/cjs/initializer/Velocity/Velocity.js +0 -94
  76. package/build/cjs/initializer/Velocity/index.js +0 -32
  77. package/build/cjs/initializer/constants.js +0 -43
  78. package/build/cjs/initializer/index.js +0 -100
  79. package/build/cjs/initializer/types.js +0 -35
  80. package/build/cjs/math/ArraySpan.js +0 -103
  81. package/build/cjs/math/Box.js +0 -46
  82. package/build/cjs/math/ColorSpan.js +0 -113
  83. package/build/cjs/math/MathUtils.js +0 -57
  84. package/build/cjs/math/Polar3D.js +0 -115
  85. package/build/cjs/math/Span.js +0 -88
  86. package/build/cjs/math/Vector3D.js +0 -76
  87. package/build/cjs/math/constants.js +0 -35
  88. package/build/cjs/math/index.js +0 -122
  89. package/build/cjs/math/integration.js +0 -59
  90. package/build/cjs/math/types.js +0 -19
  91. package/build/cjs/renderer/BaseRenderer.js +0 -108
  92. package/build/cjs/renderer/CustomRenderer.js +0 -65
  93. package/build/cjs/renderer/GPURenderer/Desktop/index.js +0 -350
  94. package/build/cjs/renderer/GPURenderer/Desktop/shaders/fragmentShader.js +0 -13
  95. package/build/cjs/renderer/GPURenderer/Desktop/shaders/index.js +0 -22
  96. package/build/cjs/renderer/GPURenderer/Desktop/shaders/vertexShader.js +0 -17
  97. package/build/cjs/renderer/GPURenderer/Mobile/index.js +0 -364
  98. package/build/cjs/renderer/GPURenderer/Mobile/shaders/fragmentShader.js +0 -13
  99. package/build/cjs/renderer/GPURenderer/Mobile/shaders/index.js +0 -22
  100. package/build/cjs/renderer/GPURenderer/Mobile/shaders/vertexShader.js +0 -15
  101. package/build/cjs/renderer/GPURenderer/common/ParticleBuffer/constants.js +0 -53
  102. package/build/cjs/renderer/GPURenderer/common/ParticleBuffer/index.js +0 -93
  103. package/build/cjs/renderer/GPURenderer/common/TextureAtlas/constants.js +0 -9
  104. package/build/cjs/renderer/GPURenderer/common/TextureAtlas/index.js +0 -221
  105. package/build/cjs/renderer/GPURenderer/common/constants.js +0 -22
  106. package/build/cjs/renderer/GPURenderer/common/index.js +0 -38
  107. package/build/cjs/renderer/GPURenderer/common/shaders/constants.js +0 -9
  108. package/build/cjs/renderer/GPURenderer/common/stores/Target.js +0 -43
  109. package/build/cjs/renderer/GPURenderer/common/stores/UniqueList.js +0 -51
  110. package/build/cjs/renderer/GPURenderer/common/stores/index.js +0 -22
  111. package/build/cjs/renderer/GPURenderer/index.js +0 -97
  112. package/build/cjs/renderer/MeshRenderer.js +0 -136
  113. package/build/cjs/renderer/SpriteRenderer.js +0 -63
  114. package/build/cjs/renderer/index.js +0 -40
  115. package/build/cjs/renderer/types.js +0 -21
  116. package/build/cjs/utils/ColorUtil.js +0 -39
  117. package/build/cjs/utils/PUID.js +0 -26
  118. package/build/cjs/utils/THREEUtil.js +0 -40
  119. package/build/cjs/utils/Util.js +0 -69
  120. package/build/cjs/utils/index.js +0 -61
  121. package/build/cjs/utils/uid.js +0 -14
  122. package/build/cjs/zone/BoxZone.js +0 -178
  123. package/build/cjs/zone/LineZone.js +0 -101
  124. package/build/cjs/zone/MeshZone.js +0 -101
  125. package/build/cjs/zone/PointZone.js +0 -99
  126. package/build/cjs/zone/ScreenZone.js +0 -164
  127. package/build/cjs/zone/SphereZone.js +0 -155
  128. package/build/cjs/zone/Zone.js +0 -172
  129. package/build/cjs/zone/index.js +0 -56
  130. package/build/cjs/zone/types.js +0 -21
  131. package/build/esm/behaviour/Alpha.js +0 -134
  132. package/build/esm/behaviour/Attraction.js +0 -135
  133. package/build/esm/behaviour/Behaviour.js +0 -215
  134. package/build/esm/behaviour/Collision.js +0 -100
  135. package/build/esm/behaviour/Color.js +0 -101
  136. package/build/esm/behaviour/CrossZone.js +0 -82
  137. package/build/esm/behaviour/Force.js +0 -83
  138. package/build/esm/behaviour/Gravity.js +0 -40
  139. package/build/esm/behaviour/RandomDrift.js +0 -97
  140. package/build/esm/behaviour/Repulsion.js +0 -81
  141. package/build/esm/behaviour/Rotate.js +0 -214
  142. package/build/esm/behaviour/Scale.js +0 -131
  143. package/build/esm/behaviour/Spring.js +0 -90
  144. package/build/esm/behaviour/constants.js +0 -11
  145. package/build/esm/behaviour/index.js +0 -14
  146. package/build/esm/behaviour/types.js +0 -14
  147. package/build/esm/constants/index.js +0 -42
  148. package/build/esm/core/Particle.js +0 -357
  149. package/build/esm/core/Pool.js +0 -161
  150. package/build/esm/core/System.js +0 -320
  151. package/build/esm/core/constants.js +0 -127
  152. package/build/esm/core/fromJSON.js +0 -107
  153. package/build/esm/core/fromJSONAsync.js +0 -210
  154. package/build/esm/core/index.js +0 -4
  155. package/build/esm/core/three/Euler.js +0 -224
  156. package/build/esm/core/three/Math.js +0 -84
  157. package/build/esm/core/three/Matrix4.js +0 -739
  158. package/build/esm/core/three/Quaternion.js +0 -445
  159. package/build/esm/core/three/Vector3.js +0 -457
  160. package/build/esm/core/three/constants.js +0 -148
  161. package/build/esm/core/three/index.js +0 -5
  162. package/build/esm/core/types.js +0 -4
  163. package/build/esm/debug/Debug.js +0 -195
  164. package/build/esm/debug/constants.js +0 -7
  165. package/build/esm/debug/index.js +0 -3
  166. package/build/esm/debug/log.js +0 -31
  167. package/build/esm/ease/index.js +0 -113
  168. package/build/esm/emitter/Emitter.js +0 -701
  169. package/build/esm/emitter/FollowEmitter.js +0 -97
  170. package/build/esm/emitter/constants.js +0 -7
  171. package/build/esm/emitter/index.js +0 -3
  172. package/build/esm/emitter/types.js +0 -3
  173. package/build/esm/events/EventDispatcher.js +0 -82
  174. package/build/esm/events/constants.js +0 -9
  175. package/build/esm/events/index.js +0 -4
  176. package/build/esm/index.js +0 -13
  177. package/build/esm/initializer/Body.js +0 -83
  178. package/build/esm/initializer/BodySprite.js +0 -108
  179. package/build/esm/initializer/Initializer.js +0 -84
  180. package/build/esm/initializer/InitializerUtil.js +0 -48
  181. package/build/esm/initializer/Life.js +0 -66
  182. package/build/esm/initializer/Mass.js +0 -60
  183. package/build/esm/initializer/Position.js +0 -98
  184. package/build/esm/initializer/Radius.js +0 -75
  185. package/build/esm/initializer/Rate.js +0 -107
  186. package/build/esm/initializer/Texture.js +0 -101
  187. package/build/esm/initializer/Velocity/PolarVelocity.js +0 -63
  188. package/build/esm/initializer/Velocity/RadialVelocity.js +0 -72
  189. package/build/esm/initializer/Velocity/VectorVelocity.js +0 -69
  190. package/build/esm/initializer/Velocity/Velocity.js +0 -52
  191. package/build/esm/initializer/Velocity/index.js +0 -4
  192. package/build/esm/initializer/constants.js +0 -26
  193. package/build/esm/initializer/index.js +0 -11
  194. package/build/esm/initializer/types.js +0 -15
  195. package/build/esm/math/ArraySpan.js +0 -62
  196. package/build/esm/math/Box.js +0 -25
  197. package/build/esm/math/ColorSpan.js +0 -71
  198. package/build/esm/math/MathUtils.js +0 -48
  199. package/build/esm/math/Polar3D.js +0 -81
  200. package/build/esm/math/Span.js +0 -63
  201. package/build/esm/math/Vector3D.js +0 -34
  202. package/build/esm/math/constants.js +0 -24
  203. package/build/esm/math/index.js +0 -10
  204. package/build/esm/math/integration.js +0 -47
  205. package/build/esm/math/types.js +0 -7
  206. package/build/esm/renderer/BaseRenderer.js +0 -78
  207. package/build/esm/renderer/CustomRenderer.js +0 -23
  208. package/build/esm/renderer/GPURenderer/Desktop/index.js +0 -331
  209. package/build/esm/renderer/GPURenderer/Desktop/shaders/fragmentShader.js +0 -23
  210. package/build/esm/renderer/GPURenderer/Desktop/shaders/index.js +0 -3
  211. package/build/esm/renderer/GPURenderer/Desktop/shaders/vertexShader.js +0 -33
  212. package/build/esm/renderer/GPURenderer/Mobile/index.js +0 -349
  213. package/build/esm/renderer/GPURenderer/Mobile/shaders/fragmentShader.js +0 -20
  214. package/build/esm/renderer/GPURenderer/Mobile/shaders/index.js +0 -3
  215. package/build/esm/renderer/GPURenderer/Mobile/shaders/vertexShader.js +0 -30
  216. package/build/esm/renderer/GPURenderer/common/ParticleBuffer/constants.js +0 -27
  217. package/build/esm/renderer/GPURenderer/common/ParticleBuffer/index.js +0 -70
  218. package/build/esm/renderer/GPURenderer/common/TextureAtlas/constants.js +0 -2
  219. package/build/esm/renderer/GPURenderer/common/TextureAtlas/index.js +0 -206
  220. package/build/esm/renderer/GPURenderer/common/constants.js +0 -13
  221. package/build/esm/renderer/GPURenderer/common/index.js +0 -4
  222. package/build/esm/renderer/GPURenderer/common/shaders/constants.js +0 -2
  223. package/build/esm/renderer/GPURenderer/common/stores/Target.js +0 -24
  224. package/build/esm/renderer/GPURenderer/common/stores/UniqueList.js +0 -29
  225. package/build/esm/renderer/GPURenderer/common/stores/index.js +0 -3
  226. package/build/esm/renderer/GPURenderer/index.js +0 -57
  227. package/build/esm/renderer/MeshRenderer.js +0 -94
  228. package/build/esm/renderer/SpriteRenderer.js +0 -26
  229. package/build/esm/renderer/index.js +0 -5
  230. package/build/esm/renderer/types.js +0 -8
  231. package/build/esm/utils/ColorUtil.js +0 -32
  232. package/build/esm/utils/PUID.js +0 -19
  233. package/build/esm/utils/THREEUtil.js +0 -31
  234. package/build/esm/utils/Util.js +0 -60
  235. package/build/esm/utils/index.js +0 -13
  236. package/build/esm/utils/uid.js +0 -3
  237. package/build/esm/zone/BoxZone.js +0 -133
  238. package/build/esm/zone/LineZone.js +0 -61
  239. package/build/esm/zone/MeshZone.js +0 -60
  240. package/build/esm/zone/PointZone.js +0 -59
  241. package/build/esm/zone/ScreenZone.js +0 -116
  242. package/build/esm/zone/SphereZone.js +0 -113
  243. package/build/esm/zone/Zone.js +0 -141
  244. package/build/esm/zone/index.js +0 -7
  245. package/build/esm/zone/types.js +0 -8
  246. package/build/three-nebula.js +0 -2
  247. package/build/three-nebula.js.map +0 -1
package/package.json CHANGED
@@ -1,47 +1,38 @@
1
1
  {
2
2
  "name": "three-nebula",
3
- "version": "10.0.3",
3
+ "version": "11.1.0",
4
4
  "description": "WebGL based 3D particle engine",
5
- "main": "./build/cjs/index.js",
6
- "module": "./build/esm/index.js",
7
- "bundlesize": [
8
- {
9
- "path": "./build/three-nebula.js",
10
- "maxSize": "105 kB",
11
- "compression": "none"
5
+ "main": "./dist/three-nebula.cjs",
6
+ "module": "./dist/three-nebula.mjs",
7
+ "unpkg": "./dist/three-nebula.umd.js",
8
+ "jsdelivr": "./dist/three-nebula.umd.js",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/three-nebula.mjs",
12
+ "require": "./dist/three-nebula.cjs"
12
13
  }
13
- ],
14
+ },
14
15
  "directories": {
15
16
  "docs": "docs",
16
17
  "website": "website"
17
18
  },
18
19
  "scripts": {
19
- "build": "npm run build:clean && npm run build:umd && npm run build:cjs && npm run build:esm",
20
- "build:clean": "rm -rf ./build",
21
- "build:umd": "webpack",
22
- "build:cjs": "babel src --out-dir build/cjs --extensions \".js\" --source-maps inline --presets @babel/env",
23
- "build:esm": "babel src --out-dir build/esm --extensions \".js\" --source-maps inline",
24
- "dev": "webpack --mode=development --watch",
25
- "sandbox": "concurrently -k \"npm:sandbox:*\"",
26
- "sandbox:build": "webpack --config webpack.config.sandbox.js --mode=development --watch",
27
- "sandbox:serve": "npx serve sandbox",
20
+ "build": "vite build",
21
+ "dev": "vite build --watch",
28
22
  "docs": "serve docs",
29
23
  "docs:build": "./scripts/docs-build",
30
- "test": "NODE_ENV=test mocha --require @babel/register --colors './test/**/*.spec.js'",
31
- "test:only": "NODE_ENV=test mocha --require @babel/register --colors",
32
- "test:watch": "NODE_ENV=test mocha --require @babel/register --colors -w './test/**/*.spec.js'",
33
- "test:watch-only": "NODE_ENV=test mocha --require @babel/register --colors -w ",
24
+ "test": "vitest run",
25
+ "test:watch": "vitest",
26
+ "coverage": "vitest run --coverage",
34
27
  "lint": "eslint src test && npx madge --circular src",
35
28
  "lint:fix": "eslint --fix",
36
- "coverage": "NODE_ENV=test npm run coverage:generate && npm run coverage:report",
37
- "coverage:view": "open ./coverage/lcov-report/index.html",
38
- "coverage:generate": "nyc mocha './test/**/*.spec.js' -- --require @babel/register",
39
- "coverage:report": "nyc report --reporter=lcov",
40
29
  "git:publish": "./scripts/publish",
41
- "ci:src": "npm run lint && npm run test && npm run coverage",
42
- "ci:bundlesize": "./scripts/check-bundlesize",
43
- "ci:website": "npm run ci --prefix website",
44
- "analyze": "webpack-bundle-analyzer build/stats.json"
30
+ "vr": "node vr/capture-all.mjs",
31
+ "vr:baseline": "node vr/capture-all.mjs --baselines",
32
+ "vr:diff": "node vr/diff.mjs",
33
+ "vr:montage": "node vr/montage.mjs",
34
+ "ci:src": "npm run lint && npm run coverage",
35
+ "ci:website": "npm run ci --prefix website"
45
36
  },
46
37
  "repository": {
47
38
  "type": "git",
@@ -62,41 +53,26 @@
62
53
  "url": "https://github.com/creativelifeform/three-nebula/issues"
63
54
  },
64
55
  "homepage": "https://github.com/creativelifeform/three-nebula#readme",
65
- "dependencies": {
66
- "lodash": "^4.17.21",
67
- "potpack": "^1.0.1",
68
- "uuid": "^3.3.2"
69
- },
70
56
  "peerDependencies": {
71
57
  "three": ">=0.122.0 <1.0.0"
72
58
  },
73
59
  "devDependencies": {
74
- "@babel/cli": "^7.16.0",
75
- "@babel/core": "^7.16.0",
76
- "@babel/eslint-parser": "^7.16.0",
77
- "@babel/plugin-proposal-object-rest-spread": "^7.16.0",
78
- "@babel/plugin-transform-runtime": "^7.16.0",
79
- "@babel/preset-env": "^7.10.1",
80
- "@babel/register": "^7.16.0",
81
- "@babel/runtime": "^7.16.0",
82
- "@istanbuljs/nyc-config-babel": "^3.0.0",
83
- "babel-loader": "^8.2.3",
84
- "babel-plugin-istanbul": "^6.0.0",
85
- "bundlesize": "^0.18.1",
60
+ "@playwright/test": "^1.61.1",
61
+ "@vitest/coverage-v8": "^3.2.6",
86
62
  "chai": "^4.2.0",
87
- "concurrently": "^6.3.0",
88
- "coveralls": "^3.0.3",
89
63
  "domino": "^2.1.1",
90
64
  "eslint": "^8.1.0",
91
65
  "eslint-plugin-import": "^2.25.2",
92
- "eslint-webpack-plugin": "^3.1.0",
93
- "mocha": "^9.1.3",
94
- "nyc": "^15.1.0",
66
+ "lodash": "^4.17.21",
67
+ "pixelmatch": "^7.2.0",
68
+ "pngjs": "^7.0.0",
69
+ "potpack": "^1.0.1",
95
70
  "prettier": "^1.16.4",
71
+ "seedrandom": "^3.0.5",
96
72
  "sinon": "^7.1.1",
97
- "three": "^0.127.0",
98
- "webpack": "^5.61.0",
99
- "webpack-bundle-analyzer": "^4.5.0",
100
- "webpack-cli": "^4.9.1"
73
+ "three": "^0.185.1",
74
+ "uuid": "^11.1.1",
75
+ "vite": "^8.1.4",
76
+ "vitest": "^3.2.6"
101
77
  }
102
78
  }
@@ -0,0 +1,200 @@
1
+ # Spec 01 — Emitter Hierarchy
2
+
3
+ Nested child emitters, ribbon/trail renderers, and the pooling required to make
4
+ both survivable.
5
+
6
+ **Depends on:** 04 (schema versioning) — this spec breaks the schema.
7
+ **Entangled with:** 02 (determinism) — child seeds derive from parent particle IDs.
8
+
9
+ ---
10
+
11
+ ## Problem
12
+
13
+ Today three-nebula emitters are siblings: a flat list, all anchored to the system
14
+ transform, running in parallel with no knowledge of each other. This is fine for
15
+ effects that happen *at one place* — fire, rain, explosions.
16
+
17
+ It cannot express effects that ride individual particles. *Every spark leaves its
18
+ own smoke trail* is unrepresentable: a sibling smoke emitter has exactly one
19
+ position and the sparks have twenty.
20
+
21
+ Parent-child emitters change the cardinality. The child is instanced **once per
22
+ parent particle**. 20 sparks → 20 live child emitter instances, each riding one
23
+ spark, each dying with it.
24
+
25
+ Ribbons and trails are grouped in here because they are the payoff: a child
26
+ emitter with `onCreate` inheritance leaving a ribbon along a parent's path is the
27
+ sword-arc effect, and it only exists once hierarchy exists.
28
+
29
+ ---
30
+
31
+ ## Stage 0 — Audit
32
+
33
+ Before writing code, establish:
34
+
35
+ 1. Does particle pooling exist? Where? Is it per-emitter or global?
36
+ 2. Do particles carry a stable, monotonic ID? If not, what identifies them?
37
+ 3. What is the exact renderer interface? Enumerate its lifecycle hooks.
38
+ 4. How does `System.update()` order emitter updates today?
39
+ 5. Is there any existing concept of emitter-to-emitter reference?
40
+ 6. How are emitters serialised in the current JSON schema?
41
+
42
+ **Write the answers into this spec before proceeding.** Stages 1–4 assume answers
43
+ that may be wrong.
44
+
45
+ ---
46
+
47
+ ## Stage 1 — Pooling foundation
48
+
49
+ Pooling is currently a nice-to-have. After Stage 2 it is load-bearing: nested
50
+ emitters multiply cardinality (100 parents × 50 children = 5,000 particles and
51
+ 100 emitter instances), and child instances must be recycled on parent death or
52
+ the system leaks.
53
+
54
+ **Deliverables:**
55
+
56
+ - A pool for particles (may already exist — see audit)
57
+ - A pool for **emitter instances** (new)
58
+ - Explicit `acquire()` / `release()` with no allocation in the hot path
59
+ - A global cap on live emitter instances, configurable, with a documented
60
+ overflow policy (drop-newest is usually right for FX)
61
+ - Instrumentation: live counts for particles, emitter instances, pool hits/misses
62
+
63
+ **Acceptance:** a 60-second run of a nested system shows zero growth in heap
64
+ allocation attributable to particle or emitter instance churn.
65
+
66
+ ---
67
+
68
+ ## Stage 2 — The emitter tree
69
+
70
+ **Schema change.** `system.emitters[]` becomes a tree. Each emitter node gains
71
+ `children: Emitter[]`.
72
+
73
+ ```jsonc
74
+ {
75
+ "version": 2,
76
+ "emitters": [
77
+ {
78
+ "id": "sparks",
79
+ "rate": { /* ... */ },
80
+ "initializers": [ /* ... */ ],
81
+ "behaviours": [ /* ... */ ],
82
+ "renderer": { "type": "sprite", /* ... */ },
83
+ "children": [
84
+ {
85
+ "id": "spark-smoke",
86
+ "inherit": { "position": "always", "rotation": "none", "scale": "none" },
87
+ "rate": { /* ... */ },
88
+ "renderer": { "type": "sprite", /* ... */ },
89
+ "children": []
90
+ }
91
+ ]
92
+ }
93
+ ]
94
+ }
95
+ ```
96
+
97
+ **Semantics:**
98
+
99
+ - A child emitter is **instantiated once per parent particle**, at that particle's
100
+ spawn.
101
+ - The child instance's origin is the parent particle, not the system.
102
+ - The child instance is destroyed when its parent particle dies. Its already-emitted
103
+ particles may either die with it or live out their lifetimes — this is a per-node
104
+ flag, `orphanPolicy: "kill" | "detach"`. Default `detach` (a spark's smoke should
105
+ outlive the spark).
106
+ - Update order is **topological**: a parent's particles must resolve before its
107
+ children read their transforms. Depth-first is fine.
108
+
109
+ **Guards:**
110
+
111
+ - `maxDepth` — hard recursion limit. Self-triggering recursive nodes are a known
112
+ hazard in any hierarchical particle system: every ancestor instance is retained
113
+ until its last descendant dies, so an unbounded chain leaks until it exhausts
114
+ memory. Cap depth (suggest 4) and cap total instances (Stage 1).
115
+ *Prior art:* Effekseer permits recursion in its node tree — its documentation on
116
+ instance retention is worth reading before designing this guard.
117
+ - Reject cycles at parse time.
118
+
119
+ **Acceptance:** a two-level system (sparks → smoke trails) renders correctly;
120
+ killing the system releases all child instances; instance count is bounded.
121
+
122
+ ---
123
+
124
+ ## Stage 3 — Inheritance modes
125
+
126
+ Per channel — `position`, `rotation`, `scale` — with three modes:
127
+
128
+ | Mode | Behaviour | Use |
129
+ |------|-----------|-----|
130
+ | `always` | Child continuously tracks parent particle | Trails, attached FX |
131
+ | `onCreate` | Child snapshots parent transform at spawn, then free | Ribbons, tracks, debris |
132
+ | `none` | Child ignores parent transform (system-space) | Rare; events |
133
+
134
+ `onCreate` vs `always` is the same hierarchy producing opposite looks from one
135
+ dropdown. It is the single highest-value nuance in this spec — do not skip it.
136
+
137
+ **Acceptance:** switching `position` from `always` to `onCreate` on the same
138
+ system visibly converts an attached trail into a left-behind band.
139
+
140
+ ---
141
+
142
+ ## Stage 4 — Ribbon and Trail renderers
143
+
144
+ **These are two different things and should be two renderers.** Conflating them
145
+ is a common mistake.
146
+
147
+ ### `RibbonRenderer`
148
+
149
+ Connects **sibling particles of one emitter** into a continuous strip, in spawn
150
+ order. The particles are the spine.
151
+
152
+ - Requires stable spawn ordering (see 02 — particle IDs)
153
+ - Width driven by a curve over particle age
154
+ - Texture UV: `stretch` (U maps 0→1 across the whole ribbon) or `tile`
155
+ (U repeats per segment). Both are needed; stretch is the common default.
156
+ - Geometry: triangle strip, camera-facing by default; optional axis-aligned
157
+ - Handle the degenerate cases: fewer than 2 live particles, particles at
158
+ identical positions (zero-length segments produce NaN normals)
159
+
160
+ ### `TrailRenderer`
161
+
162
+ Each particle keeps a **ring buffer of its own past positions** and renders a tail.
163
+ No sibling relationship.
164
+
165
+ - `trailLength` (samples) and `sampleInterval` (seconds) per particle
166
+ - Memory cost is `particles × trailLength × 3 floats` — must be pooled (Stage 1)
167
+ - Same width-over-age and UV modes as ribbon
168
+
169
+ **Which to build first:** Ribbon. It composes with Stage 3's `onCreate` mode to
170
+ give sword arcs, and it's cheaper. Trail is the fallback for when you want a tail
171
+ without a child emitter.
172
+
173
+ **Acceptance:** a child emitter with `inherit.position: "onCreate"` and a
174
+ `RibbonRenderer` produces a coherent band along the parent's path.
175
+
176
+ ---
177
+
178
+ ## Stage 5 — Events (deferred, scope permitting)
179
+
180
+ Distinct from attachment. Attachment = child lives alongside parent for its
181
+ lifetime (Stages 2–3). Events = a discrete trigger that spawns a burst which then
182
+ **outlives** the parent.
183
+
184
+ - `onDeath` → spawn burst at parent's final position
185
+ - `onCollision` → spawn burst at contact point
186
+
187
+ Fireworks want events. Trails want attachment. Ship attachment first; events are
188
+ a clean additive follow-on and should not block Stages 1–4.
189
+
190
+ ---
191
+
192
+ ## Notes for implementation
193
+
194
+ - Particles stay dumb. Nothing about hierarchy enters the particle struct except
195
+ an ID (which 02 needs anyway). Child emitter instances hold the parent reference,
196
+ not the other way round.
197
+ - The child instance is an *emitter*, not a *particle*. Do not try to model it as
198
+ a special particle type.
199
+ - Every new random draw in this spec must go through the seeded PRNG from 02.
200
+ Child instance seeds derive from `hash(systemSeed, emitterId, parentParticleId)`.
@@ -0,0 +1,188 @@
1
+ # Spec 02 — Determinism & Scrubbing
2
+
3
+ Make the simulation reproducible: same seed + same step count = byte-identical
4
+ particle state, every time, on every machine running the same JS engine.
5
+
6
+ **Blocks:** 03 (sound jitter), and any offline/headless rendering built on the library
7
+ **Entangled with:** 01 (hierarchy) — child seeds derive from parent particle IDs
8
+
9
+ ---
10
+
11
+ ## Why this is not optional
12
+
13
+ Determinism looks like a nicety. It is actually the load-bearing property under
14
+ four separate product features:
15
+
16
+ 1. **Reproducible rendering** — a headless render must match what was seen live.
17
+ 2. **Offline rendering** — two renders of one system must be byte-identical.
18
+ 3. **Seek and scrub** — step, rewind, edit-while-paused in any editing tool built
19
+ on the library. The single most-used affordance in mature FX tooling.
20
+ 4. **Programmatic iteration** — render → inspect → revise loops only work if the
21
+ output is a pure function of the input.
22
+
23
+ Retrofitting determinism means touching every initializer and behaviour. Doing it
24
+ while already in there for 01 costs a fraction.
25
+
26
+ ---
27
+
28
+ ## Stage 0 — Audit
29
+
30
+ 1. Grep for `Math.random`. Every hit is a defect. Count them.
31
+ 2. Grep for `Date.now`, `performance.now`, `new Date` inside the sim path.
32
+ 3. Does the sim use a fixed timestep or raw `delta` from rAF?
33
+ 4. Do particles have IDs? Are they stable across frames?
34
+ 5. Is there any iteration over `Set` / `Map` / `Object.keys` whose order affects
35
+ simulation output?
36
+ 6. Is there existing seed handling anywhere?
37
+
38
+ ---
39
+
40
+ ## Stage 1 — Seeded PRNG
41
+
42
+ Replace all `Math.random()` with an injected, seedable generator.
43
+
44
+ - Suggested: `mulberry32` or `xoshiro128**`. Small state, fast, good enough
45
+ distribution for FX. Do not use anything cryptographic.
46
+ - The PRNG instance must be reachable from every initializer, behaviour, and
47
+ renderer that draws randomness. Pass it down; do not use a module singleton
48
+ (two systems on one page must not share a stream).
49
+
50
+ **Acceptance:** `grep -r "Math.random" src/` returns zero hits in the sim path.
51
+
52
+ ---
53
+
54
+ ## Stage 2 — Seed derivation hierarchy
55
+
56
+ Randomness must be *addressable*, not merely seeded. A single sequential stream
57
+ breaks the moment emitters update in a different order.
58
+
59
+ ```
60
+ systemSeed (user-set or random at creation)
61
+ └─ emitterSeed = hash(systemSeed, emitterId)
62
+ └─ particleSeed = hash(emitterSeed, particleId)
63
+ └─ childEmitterSeed = hash(systemSeed, childEmitterId, particleId)
64
+ ```
65
+
66
+ - `particleId` — monotonic counter per emitter, never reused, survives pooling
67
+ (i.e. a recycled particle object gets a *new* ID)
68
+ - Each particle carries its own PRNG state, seeded from `particleSeed`
69
+ - This is what makes 01's child emitters reproducible: the child's stream is a
70
+ pure function of which parent particle it rides
71
+
72
+ **Acceptance:** reordering emitters in the JSON does not change any individual
73
+ emitter's output.
74
+
75
+ ---
76
+
77
+ ## Stage 3 — Fixed timestep
78
+
79
+ The sim must advance in fixed increments, decoupled from rAF.
80
+
81
+ - Accumulator pattern: accumulate real delta, consume in fixed steps
82
+ (suggest 1/60s, configurable)
83
+ - Clamp max steps per frame to avoid spiral-of-death on a slow frame
84
+ - Optionally interpolate render state between steps (defer — not needed for v1)
85
+
86
+ Without this, the same system produces different results on a 60Hz and a 144Hz
87
+ display, and rendering offline at a different fps produces a different effect.
88
+
89
+ **Acceptance:** stepping the sim 600× at 1/60 produces identical state regardless
90
+ of wall-clock time taken.
91
+
92
+ ---
93
+
94
+ ## Stage 4 — Seek / scrub
95
+
96
+ **You cannot run a stochastic sim backwards.** Do not try.
97
+
98
+ Seek is implemented as **reset + fast-forward**:
99
+
100
+ ```
101
+ seek(t):
102
+ reset to step 0 with the same seed
103
+ run ceil(t / dt) fixed steps with rendering disabled
104
+ ```
105
+
106
+ - Forward seek from the current position is an optimisation, not a requirement —
107
+ correctness first
108
+ - **Checkpointing** (snapshot full particle state every N steps, restore + replay
109
+ the remainder) is the obvious speed-up if seek becomes slow. Defer until measured.
110
+ - Scrubbing must not fire audio (see 03) or any other side effect
111
+ - Expose `step()`, `seek(t)`, `reset()`, `setTimeScale()` on the system
112
+
113
+ **Acceptance:** `seek(2.0)` twice from different starting states produces
114
+ identical particle buffers.
115
+
116
+ ---
117
+
118
+ ## Stage 5 — The determinism test
119
+
120
+ This is the deliverable that keeps the property from rotting.
121
+
122
+ - Hash the full particle buffer (positions, velocities, colors, ages) at
123
+ step N into a stable digest
124
+ - Golden-file test: a corpus of systems, each with an expected digest at several
125
+ step counts
126
+ - Run in CI. Any change that breaks a digest is either a bug or an intentional
127
+ change requiring a new golden file
128
+ - Include at least one nested system once 01 lands
129
+
130
+ **Acceptance:** CI fails if determinism regresses.
131
+
132
+ ---
133
+
134
+ ## Stage 6 — Headless contract
135
+
136
+ Determinism is necessary but not sufficient for offline rendering. The system must
137
+ also be *drivable from outside*, with no environmental dependencies.
138
+
139
+ Consumers that step the library headlessly — in a Web Worker against an
140
+ `OffscreenCanvas`, or under headless Chromium — are out of scope for this repo, but
141
+ the contract that makes them possible is not.
142
+
143
+ **Requirements:**
144
+
145
+ - **The library never owns a loop.** No internal `requestAnimationFrame`. The caller
146
+ supplies `dt` and decides when to advance. (Verify in Stage 0 — this may already
147
+ hold.)
148
+ - **No wall-clock reads in the sim path.** Covered by Stage 3, restated here because
149
+ it is the requirement that breaks headless stepping when violated.
150
+ - **Simulation and rendering are separable.** A caller must be able to step N times
151
+ without rendering, or render the same state twice, in any order.
152
+ - **No DOM access in the sim path.** No `document`, no `window`, no
153
+ `HTMLImageElement`. Textures arrive as decoded data via the resolver (05), not as
154
+ DOM elements. This is the requirement most likely to be quietly violated today.
155
+ - **No `HTMLCanvasElement` assumption.** Anything canvas-shaped must accept an
156
+ `OffscreenCanvas`.
157
+
158
+ **Acceptance:** a system steps 600 times inside a Web Worker with no DOM present,
159
+ and produces particle state digests identical to the same run on the main thread.
160
+
161
+ **Explicitly not this repo's job:** frame capture, sprite-sheet packing, video
162
+ encoding, camera framing, warmup heuristics, loop-point selection, CLI, queueing.
163
+ Those belong to whatever consumes this contract.
164
+
165
+ ---
166
+
167
+ ## Known non-determinism sources to eliminate
168
+
169
+ | Source | Fix |
170
+ |--------|-----|
171
+ | `Math.random()` | Seeded PRNG (Stage 1) |
172
+ | `Date.now()` / `performance.now()` in sim | Fixed timestep (Stage 3) |
173
+ | Variable rAF delta | Fixed timestep (Stage 3) |
174
+ | `Set`/`Map` iteration order | Sort, or use arrays |
175
+ | Pool reuse order affecting IDs | IDs from a counter, not pool index |
176
+ | Async texture load racing spawn | Resolve all assets before first step |
177
+
178
+ **Not a concern:** IEEE-754 float behaviour is deterministic within a given JS
179
+ engine on a given platform. Cross-platform float divergence matters for lockstep
180
+ netcode, not for this. Do not over-engineer.
181
+
182
+ ---
183
+
184
+ ## Explicitly out of scope
185
+
186
+ - Cross-runtime determinism (three-nebula ↔ a port to another engine)
187
+ - Rewind / reverse simulation
188
+ - Deterministic GPU simulation (revisit if/when a compute path exists)