three-nebula 11.0.0 → 11.1.1

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 (248) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/three-nebula.cjs +109 -0
  3. package/dist/three-nebula.cjs.map +1 -0
  4. package/dist/three-nebula.mjs +2969 -0
  5. package/dist/three-nebula.mjs.map +1 -0
  6. package/dist/three-nebula.umd.js +109 -0
  7. package/dist/three-nebula.umd.js.map +1 -0
  8. package/package.json +23 -47
  9. package/specs/01-emitter-hierarchy.md +200 -0
  10. package/specs/02-determinism-scrubbing.md +188 -0
  11. package/specs/03-sound-renderer.md +183 -0
  12. package/specs/04-schema-versioning.md +140 -0
  13. package/specs/05-content-addressed-assets.md +288 -0
  14. package/specs/README.md +80 -0
  15. package/build/cjs/behaviour/Alpha.js +0 -189
  16. package/build/cjs/behaviour/Attraction.js +0 -189
  17. package/build/cjs/behaviour/Behaviour.js +0 -253
  18. package/build/cjs/behaviour/Collision.js +0 -146
  19. package/build/cjs/behaviour/Color.js +0 -148
  20. package/build/cjs/behaviour/CrossZone.js +0 -141
  21. package/build/cjs/behaviour/Force.js +0 -126
  22. package/build/cjs/behaviour/Gravity.js +0 -78
  23. package/build/cjs/behaviour/RandomDrift.js +0 -153
  24. package/build/cjs/behaviour/Repulsion.js +0 -123
  25. package/build/cjs/behaviour/Rotate.js +0 -265
  26. package/build/cjs/behaviour/Scale.js +0 -177
  27. package/build/cjs/behaviour/Spring.js +0 -133
  28. package/build/cjs/behaviour/constants.js +0 -28
  29. package/build/cjs/behaviour/index.js +0 -112
  30. package/build/cjs/behaviour/types.js +0 -33
  31. package/build/cjs/constants/index.js +0 -61
  32. package/build/cjs/core/Particle.js +0 -390
  33. package/build/cjs/core/Pool.js +0 -203
  34. package/build/cjs/core/System.js +0 -366
  35. package/build/cjs/core/constants.js +0 -163
  36. package/build/cjs/core/fromJSON.js +0 -128
  37. package/build/cjs/core/fromJSONAsync.js +0 -239
  38. package/build/cjs/core/index.js +0 -32
  39. package/build/cjs/core/three/Euler.js +0 -233
  40. package/build/cjs/core/three/Math.js +0 -91
  41. package/build/cjs/core/three/Matrix4.js +0 -745
  42. package/build/cjs/core/three/Quaternion.js +0 -451
  43. package/build/cjs/core/three/Vector3.js +0 -464
  44. package/build/cjs/core/three/constants.js +0 -298
  45. package/build/cjs/core/three/index.js +0 -48
  46. package/build/cjs/core/types.js +0 -13
  47. package/build/cjs/debug/Debug.js +0 -209
  48. package/build/cjs/debug/constants.js +0 -19
  49. package/build/cjs/debug/index.js +0 -24
  50. package/build/cjs/debug/log.js +0 -38
  51. package/build/cjs/ease/index.js +0 -149
  52. package/build/cjs/emitter/Emitter.js +0 -798
  53. package/build/cjs/emitter/FollowEmitter.js +0 -146
  54. package/build/cjs/emitter/constants.js +0 -20
  55. package/build/cjs/emitter/index.js +0 -24
  56. package/build/cjs/emitter/types.js +0 -11
  57. package/build/cjs/events/EventDispatcher.js +0 -105
  58. package/build/cjs/events/constants.js +0 -23
  59. package/build/cjs/events/index.js +0 -27
  60. package/build/cjs/index.js +0 -151
  61. package/build/cjs/initializer/Body.js +0 -122
  62. package/build/cjs/initializer/BodySprite.js +0 -146
  63. package/build/cjs/initializer/Initializer.js +0 -109
  64. package/build/cjs/initializer/InitializerUtil.js +0 -54
  65. package/build/cjs/initializer/Life.js +0 -106
  66. package/build/cjs/initializer/Mass.js +0 -101
  67. package/build/cjs/initializer/Position.js +0 -152
  68. package/build/cjs/initializer/Radius.js +0 -118
  69. package/build/cjs/initializer/Rate.js +0 -150
  70. package/build/cjs/initializer/Rotation.js +0 -95
  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 -108
  79. package/build/cjs/initializer/types.js +0 -37
  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 -371
  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 -385
  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 -58
  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 -45
  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 -68
  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 -12
  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 -708
  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/Rotation.js +0 -54
  187. package/build/esm/initializer/Texture.js +0 -101
  188. package/build/esm/initializer/Velocity/PolarVelocity.js +0 -63
  189. package/build/esm/initializer/Velocity/RadialVelocity.js +0 -72
  190. package/build/esm/initializer/Velocity/VectorVelocity.js +0 -69
  191. package/build/esm/initializer/Velocity/Velocity.js +0 -52
  192. package/build/esm/initializer/Velocity/index.js +0 -4
  193. package/build/esm/initializer/constants.js +0 -26
  194. package/build/esm/initializer/index.js +0 -12
  195. package/build/esm/initializer/types.js +0 -16
  196. package/build/esm/math/ArraySpan.js +0 -62
  197. package/build/esm/math/Box.js +0 -25
  198. package/build/esm/math/ColorSpan.js +0 -71
  199. package/build/esm/math/MathUtils.js +0 -48
  200. package/build/esm/math/Polar3D.js +0 -81
  201. package/build/esm/math/Span.js +0 -63
  202. package/build/esm/math/Vector3D.js +0 -34
  203. package/build/esm/math/constants.js +0 -24
  204. package/build/esm/math/index.js +0 -10
  205. package/build/esm/math/integration.js +0 -47
  206. package/build/esm/math/types.js +0 -7
  207. package/build/esm/renderer/BaseRenderer.js +0 -78
  208. package/build/esm/renderer/CustomRenderer.js +0 -23
  209. package/build/esm/renderer/GPURenderer/Desktop/index.js +0 -357
  210. package/build/esm/renderer/GPURenderer/Desktop/shaders/fragmentShader.js +0 -30
  211. package/build/esm/renderer/GPURenderer/Desktop/shaders/index.js +0 -3
  212. package/build/esm/renderer/GPURenderer/Desktop/shaders/vertexShader.js +0 -36
  213. package/build/esm/renderer/GPURenderer/Mobile/index.js +0 -375
  214. package/build/esm/renderer/GPURenderer/Mobile/shaders/fragmentShader.js +0 -27
  215. package/build/esm/renderer/GPURenderer/Mobile/shaders/index.js +0 -3
  216. package/build/esm/renderer/GPURenderer/Mobile/shaders/vertexShader.js +0 -33
  217. package/build/esm/renderer/GPURenderer/common/ParticleBuffer/constants.js +0 -30
  218. package/build/esm/renderer/GPURenderer/common/ParticleBuffer/index.js +0 -70
  219. package/build/esm/renderer/GPURenderer/common/TextureAtlas/constants.js +0 -2
  220. package/build/esm/renderer/GPURenderer/common/TextureAtlas/index.js +0 -206
  221. package/build/esm/renderer/GPURenderer/common/constants.js +0 -13
  222. package/build/esm/renderer/GPURenderer/common/index.js +0 -4
  223. package/build/esm/renderer/GPURenderer/common/shaders/constants.js +0 -2
  224. package/build/esm/renderer/GPURenderer/common/stores/Target.js +0 -26
  225. package/build/esm/renderer/GPURenderer/common/stores/UniqueList.js +0 -29
  226. package/build/esm/renderer/GPURenderer/common/stores/index.js +0 -3
  227. package/build/esm/renderer/GPURenderer/index.js +0 -57
  228. package/build/esm/renderer/MeshRenderer.js +0 -93
  229. package/build/esm/renderer/SpriteRenderer.js +0 -30
  230. package/build/esm/renderer/index.js +0 -5
  231. package/build/esm/renderer/types.js +0 -8
  232. package/build/esm/utils/ColorUtil.js +0 -32
  233. package/build/esm/utils/PUID.js +0 -19
  234. package/build/esm/utils/THREEUtil.js +0 -31
  235. package/build/esm/utils/Util.js +0 -60
  236. package/build/esm/utils/index.js +0 -13
  237. package/build/esm/utils/uid.js +0 -3
  238. package/build/esm/zone/BoxZone.js +0 -133
  239. package/build/esm/zone/LineZone.js +0 -61
  240. package/build/esm/zone/MeshZone.js +0 -60
  241. package/build/esm/zone/PointZone.js +0 -59
  242. package/build/esm/zone/ScreenZone.js +0 -116
  243. package/build/esm/zone/SphereZone.js +0 -113
  244. package/build/esm/zone/Zone.js +0 -141
  245. package/build/esm/zone/index.js +0 -7
  246. package/build/esm/zone/types.js +0 -8
  247. package/build/three-nebula.js +0 -2
  248. package/build/three-nebula.js.map +0 -1
package/package.json CHANGED
@@ -1,43 +1,38 @@
1
1
  {
2
2
  "name": "three-nebula",
3
- "version": "11.0.0",
3
+ "version": "11.1.1",
4
4
  "description": "WebGL based 3D particle engine",
5
- "main": "./build/cjs/index.js",
6
- "module": "./build/esm/index.js",
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"
13
+ }
14
+ },
7
15
  "directories": {
8
16
  "docs": "docs",
9
17
  "website": "website"
10
18
  },
11
19
  "scripts": {
12
- "build": "npm run build:clean && npm run build:umd && npm run build:cjs && npm run build:esm",
13
- "build:clean": "rm -rf ./build",
14
- "build:umd": "webpack",
15
- "build:cjs": "babel src --out-dir build/cjs --extensions \".js\" --source-maps inline --presets @babel/env",
16
- "build:esm": "babel src --out-dir build/esm --extensions \".js\" --source-maps inline",
17
- "dev": "webpack --mode=development --watch",
18
- "sandbox": "concurrently -k \"npm:sandbox:*\"",
19
- "sandbox:build": "webpack --config webpack.config.sandbox.js --mode=development --watch",
20
- "sandbox:serve": "npx serve sandbox",
20
+ "build": "vite build",
21
+ "dev": "vite build --watch",
21
22
  "docs": "serve docs",
22
23
  "docs:build": "./scripts/docs-build",
23
- "test": "NODE_ENV=test mocha --require @babel/register --colors './test/**/*.spec.js'",
24
- "test:only": "NODE_ENV=test mocha --require @babel/register --colors",
25
- "test:watch": "NODE_ENV=test mocha --require @babel/register --colors -w './test/**/*.spec.js'",
26
- "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",
27
27
  "lint": "eslint src test && npx madge --circular src",
28
28
  "lint:fix": "eslint --fix",
29
- "coverage": "NODE_ENV=test npm run coverage:generate && npm run coverage:report",
30
- "coverage:view": "open ./coverage/lcov-report/index.html",
31
- "coverage:generate": "nyc mocha './test/**/*.spec.js' -- --require @babel/register",
32
- "coverage:report": "nyc report --reporter=lcov",
33
29
  "git:publish": "./scripts/publish",
34
30
  "vr": "node vr/capture-all.mjs",
35
31
  "vr:baseline": "node vr/capture-all.mjs --baselines",
36
32
  "vr:diff": "node vr/diff.mjs",
37
33
  "vr:montage": "node vr/montage.mjs",
38
- "ci:src": "npm run lint && npm run test && npm run coverage",
39
- "ci:website": "npm run ci --prefix website",
40
- "analyze": "webpack-bundle-analyzer build/stats.json"
34
+ "ci:src": "npm run lint && npm run coverage",
35
+ "ci:website": "npm run ci --prefix website"
41
36
  },
42
37
  "repository": {
43
38
  "type": "git",
@@ -58,45 +53,26 @@
58
53
  "url": "https://github.com/creativelifeform/three-nebula/issues"
59
54
  },
60
55
  "homepage": "https://github.com/creativelifeform/three-nebula#readme",
61
- "dependencies": {
62
- "lodash": "^4.17.21",
63
- "potpack": "^1.0.1",
64
- "uuid": "^11.1.1"
65
- },
66
56
  "peerDependencies": {
67
57
  "three": ">=0.122.0 <1.0.0"
68
58
  },
69
59
  "devDependencies": {
70
- "@babel/cli": "^7.16.0",
71
- "@babel/core": "^7.16.0",
72
- "@babel/eslint-parser": "^7.16.0",
73
- "@babel/plugin-proposal-object-rest-spread": "^7.16.0",
74
- "@babel/plugin-transform-runtime": "^7.16.0",
75
- "@babel/preset-env": "^7.10.1",
76
- "@babel/register": "^7.16.0",
77
- "@babel/runtime": "^7.16.0",
78
- "@istanbuljs/nyc-config-babel": "^3.0.0",
79
60
  "@playwright/test": "^1.61.1",
80
- "babel-loader": "^8.2.3",
81
- "babel-plugin-istanbul": "^6.0.0",
61
+ "@vitest/coverage-v8": "^3.2.6",
82
62
  "chai": "^4.2.0",
83
- "concurrently": "^6.3.0",
84
- "coveralls": "^3.0.3",
85
63
  "domino": "^2.1.1",
86
64
  "eslint": "^8.1.0",
87
65
  "eslint-plugin-import": "^2.25.2",
88
- "eslint-webpack-plugin": "^3.1.0",
89
- "mocha": "^9.1.3",
90
- "nyc": "^15.1.0",
66
+ "lodash": "^4.17.21",
91
67
  "pixelmatch": "^7.2.0",
92
68
  "pngjs": "^7.0.0",
69
+ "potpack": "^1.0.1",
93
70
  "prettier": "^1.16.4",
94
71
  "seedrandom": "^3.0.5",
95
72
  "sinon": "^7.1.1",
96
73
  "three": "^0.185.1",
74
+ "uuid": "^11.1.1",
97
75
  "vite": "^8.1.4",
98
- "webpack": "^5.61.0",
99
- "webpack-bundle-analyzer": "^4.5.0",
100
- "webpack-cli": "^4.9.1"
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)
@@ -0,0 +1,183 @@
1
+ # Spec 03 — Sound Renderer
2
+
3
+ Treat particles as sound sources. A renderer that emits voices instead of pixels.
4
+
5
+ **Depends on:** 02 (seeded jitter, scrub muting), 05 (audio blob storage)
6
+ **Priority:** lowest of the five. Most additive, least entangled, easiest to defer.
7
+
8
+ ---
9
+
10
+ ## The model
11
+
12
+ **This is not a sound-type particle.** It is an ordinary particle that a sound
13
+ renderer happens to be reading.
14
+
15
+ The particle stays dumb — position, life, age, alpha, size. It has no idea it's
16
+ audible. The *renderer* decides to interpret the stream as voices. Nothing new
17
+ enters the particle struct. The simulation does not branch.
18
+
19
+ Sound sits alongside billboard, mesh, ribbon and light as a peer renderer.
20
+
21
+ *Prior art:* this is PopcornFX's model — its renderer list is billboard, mesh,
22
+ ribbon, light, **sound**, decal, triangle, where the sound renderer simply treats
23
+ particles as sound sources in the world. It's the right shape and worth copying
24
+ closely.
25
+
26
+ **Corollary:** a layer can carry both renderers. An ember that glows *and* crackles
27
+ is one emitter with a `SpriteRenderer` and a `SoundRenderer` reading the same
28
+ particles. No duplicate emitter.
29
+
30
+ **Corollary 2:** triggering is already built. "Play a sound 0.5s in" is an emitter
31
+ with `delay: 0.5`, `burst: 1`, `life: <sound duration>`, and a sound renderer. It
32
+ draws nothing. It exists to be heard. The existing rate/burst/delay controls *are*
33
+ the sound sequencer.
34
+
35
+ ---
36
+
37
+ ## Stage 0 — Audit
38
+
39
+ 1. Exact renderer interface — confirm the lifecycle hooks. Assumed:
40
+ `onParticleCreated`, `onParticleUpdate`, `onParticleDead`, `onSystemUpdate`.
41
+ 2. Is there any existing audio anywhere in the codebase? (Assume no.)
42
+ 3. How does the system expose the camera? The audio listener must track it.
43
+
44
+ ---
45
+
46
+ ## Stage 1 — One-shot voices
47
+
48
+ **v1 is one-shot only.** Fire and forget. Persistent/attached sounds are Stage 5
49
+ and may never happen.
50
+
51
+ ```
52
+ onParticleCreated(particle):
53
+ if (!canPlay()) return // voice cap, cooldown, probability
54
+ const src = ctx.createBufferSource()
55
+ src.buffer = resolvedBuffer
56
+ src.detune.value = jitter(particle.prng, detuneRange)
57
+ src.connect(panner).connect(gain).connect(ctx.destination)
58
+ src.start(ctx.currentTime, startOffsetJitter(particle.prng))
59
+ ```
60
+
61
+ That's it. No handle retained. The voice finishes and GCs itself.
62
+
63
+ **Config on the renderer (not the particle):**
64
+
65
+ ```jsonc
66
+ {
67
+ "type": "sound",
68
+ "src": { "$ref": "sha256:..." },
69
+ "volume": 0.8,
70
+ "detuneCents": [-200, 200],
71
+ "startOffsetMs": [0, 30],
72
+ "maxVoices": 8,
73
+ "cooldownMs": 40,
74
+ "probability": 0.2,
75
+ "attenuation": { "refDistance": 1, "maxDistance": 50, "rolloff": 1 },
76
+ "gainFrom": "alpha", // optional particle attribute → gain
77
+ "pitchFrom": null
78
+ }
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Stage 2 — Voice limiting
84
+
85
+ **This is the feature.** 500 sparks × a sound renderer = 500 voices = catastrophe.
86
+
87
+ Three independent throttles, all needed:
88
+
89
+ 1. **`maxVoices`** — hard cap per renderer. When exceeded, steal the
90
+ lowest-priority live voice or drop the new one (drop is usually better for FX).
91
+ 2. **`cooldownMs`** — minimum gap between triggers on this renderer. Kills the
92
+ machine-gun case where 50 particles spawn in one frame.
93
+ 3. **`probability`** — play on only N% of particles. The cheapest and most
94
+ effective control. 500 sparks, 5% probability → 25 crackles. Sounds right,
95
+ costs nothing.
96
+
97
+ **Priority heuristic warning.** The obvious voice-stealing heuristic — rank by
98
+ `volume × attenuation` — is naive, and it's worth understanding why before
99
+ shipping it. It reads the *configured* volume, not perceived loudness: a quiet
100
+ sample at volume 1.0 outranks a hot sample at 0.1, which is backwards.
101
+
102
+ The pragmatic answer is to use the heuristic anyway and require **normalised
103
+ source audio** (all samples at roughly equal perceptual loudness), then **say so
104
+ in the docs**. Do not attempt loudness analysis in v1.
105
+
106
+ *Prior art:* PopcornFX prioritises exactly this way, and its docs are refreshingly
107
+ candid that the heuristic misjudges for precisely this reason — their guidance is
108
+ to normalise source audio so it behaves. Their sound renderer page is worth reading
109
+ before implementing this stage.
110
+
111
+ ---
112
+
113
+ ## Stage 3 — Jitter (required, not polish)
114
+
115
+ Many copies of the same sample within a few milliseconds phase-cancel into
116
+ flanging mush. Two mitigations, both mandatory:
117
+
118
+ - **Detune jitter** — per-voice `detune` in cents, drawn from the *particle's*
119
+ seeded PRNG (02). Suggest ±200 cents default.
120
+ - **Start-offset jitter** — a few ms of random offset into the buffer. Decorrelates
121
+ transients even at identical pitch.
122
+
123
+ Both must be deterministic — the same system must produce the same pitches on
124
+ every run. (Audio is not captured by offline rendering anyway — see below — but
125
+ the property should hold regardless.)
126
+
127
+ ---
128
+
129
+ ## Stage 4 — Context lifecycle and scrub
130
+
131
+ **Autoplay policy.** `AudioContext` starts `suspended` until a user gesture. This
132
+ is not a bug to work around; design for it:
133
+
134
+ - The system must function fully with audio unavailable. Sound is decoration on
135
+ a working sim, never a dependency.
136
+ - Expose `system.audio.resume()` for the host to call from a click handler.
137
+ - Default to silence; opt in on an explicit user action. Any consumer displaying
138
+ many systems at once must not auto-play audio.
139
+
140
+ **Mute conditions** — the renderer must not fire when:
141
+
142
+ - Seeking / scrubbing (02, Stage 4)
143
+ - `timeScale !== 1` (pitch would be wrong and it's not worth correcting)
144
+ - The system is being stepped headlessly (02, Stage 6)
145
+ - `ctx.state !== "running"`
146
+
147
+ **Clock note.** Web Audio's `currentTime` and rAF are different clocks. Spawning
148
+ voices off rAF gives up to ~16ms jitter. That is tolerable for FX and not worth
149
+ fixing in v1. If anything rhythmic ever matters, schedule against `ctx.currentTime`
150
+ with lookahead — do not do this now.
151
+
152
+ ---
153
+
154
+ ## Stage 5 — Persistent voices (deferred, possibly never)
155
+
156
+ A sound that travels with a particle and stops on its death. Requires holding a
157
+ handle per voice, updating the panner in `onParticleUpdate`, and stopping in
158
+ `onParticleDead`.
159
+
160
+ *Prior art:* Niagara splits exactly here. `Play Audio` is the one-shot — cheapest,
161
+ fires and forgets, and once triggered it cannot be changed or stopped and keeps
162
+ playing even if the simulation does not. `Play Persistent Audio` retains a
163
+ reference per voice so it can be updated over time, requires a paired update
164
+ module, and is notably trickier to set up.
165
+
166
+ That two-feature split is a good signal: the persistent path is meaningfully
167
+ harder and most effects don't need it. **Do not build this until something
168
+ concrete demands it.**
169
+
170
+ ---
171
+
172
+ ## Known limitations to document, not solve
173
+
174
+ - **Offline rendering loses audio.** A sprite sheet has no audio track. A system
175
+ with a sound renderer renders silently, so sound and any sheet/video export path
176
+ do not compose. Not fatal; be explicit about it in the docs.
177
+ - **No external mixer.** A native engine has audio middleware (Wwise, FMOD)
178
+ downstream to arbitrate voice budgets, ducking and concurrency — engine
179
+ integrations of FX tools typically defer to it, and PopcornFX's UE integration
180
+ has you configure max concurrency per sound asset for exactly this reason. On the
181
+ web there is nothing downstream. The library owns its own voice budget, which is
182
+ why the limiting in Stage 2 is the substance of this spec rather than an
183
+ optimisation.