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
@@ -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.
@@ -0,0 +1,140 @@
1
+ # Spec 04 — Schema Versioning
2
+
3
+ A `version` field and a migration chain, so the schema can change without
4
+ stranding six years of saved systems.
5
+
6
+ **Blocks:** 01 (hierarchy), 05 (assets) — both break the schema.
7
+ **Size:** small. Boring. Lands first.
8
+
9
+ ---
10
+
11
+ ## Why first
12
+
13
+ 01 turns `emitters[]` into a tree. 05 replaces inlined base64 with hash refs.
14
+ Both are breaking changes to the JSON.
15
+
16
+ There are systems in the wild right now — six years of them, in Discords, in
17
+ gists, in people's projects. If a new three-nebula rejects them, the goodwill
18
+ that survived six years of dormancy does not survive that.
19
+
20
+ This spec is the escape hatch that makes every later schema change cheap. It must
21
+ exist before the first one lands, not after.
22
+
23
+ ---
24
+
25
+ ## Stage 0 — Audit
26
+
27
+ 1. Does the JSON have any version marker today? Any at all?
28
+ 2. What does `System.fromJSON` / `fromJSONAsync` do on unrecognised fields —
29
+ throw, ignore, or crash?
30
+ 3. Are there existing JSON fixtures in the test suite? How many? Are they
31
+ representative?
32
+ 4. Is the JSON schema documented anywhere authoritative, or is the parser the spec?
33
+
34
+ ---
35
+
36
+ ## Stage 1 — The field
37
+
38
+ ```jsonc
39
+ {
40
+ "version": 2,
41
+ "emitters": [ /* ... */ ]
42
+ }
43
+ ```
44
+
45
+ **Integer, monotonic. Not semver.** Semver invites debate about what counts as a
46
+ minor change. An integer that increments whenever the shape changes has no
47
+ ambiguity and the migration chain is trivially ordered.
48
+
49
+ Rules:
50
+
51
+ - **Missing `version` → treat as `0`** (legacy, pre-versioning). This is the
52
+ entire back-compat story for existing content and it must never be removed.
53
+ - **`version` > current → clear, actionable error.** Not a crash, not silent
54
+ partial parse. `"This system was created with a newer version of three-nebula
55
+ (v5). This build supports up to v3. Update three-nebula."`
56
+ - **`version` < current → run the migration chain.**
57
+
58
+ ---
59
+
60
+ ## Stage 2 — Migration registry
61
+
62
+ ```js
63
+ const migrations = {
64
+ 0: migrate0to1, // legacy → versioned
65
+ 1: migrate1to2, // flat emitters → tree (spec 01)
66
+ 2: migrate2to3, // base64 textures → hash refs (spec 05)
67
+ };
68
+
69
+ function migrate(json) {
70
+ let v = json.version ?? 0;
71
+ while (v < CURRENT_VERSION) {
72
+ json = migrations[v](json);
73
+ v += 1;
74
+ json.version = v;
75
+ }
76
+ return json;
77
+ }
78
+ ```
79
+
80
+ Requirements:
81
+
82
+ - Each migration is a **pure function**: JSON in, JSON out. No side effects, no
83
+ network, no I/O. (05 needs async blob writes — see that spec for how it splits
84
+ the pure transform from the storage step.)
85
+ - Migrations are **append-only**. Once shipped, a migration is frozen forever.
86
+ Never edit a released migration; add a new one.
87
+ - `System.fromJSON` runs `migrate()` before anything else touches the object.
88
+ - Migrations must be individually unit-testable without instantiating a system.
89
+
90
+ ---
91
+
92
+ ## Stage 3 — The fixture corpus
93
+
94
+ This is what stops the guarantee from rotting.
95
+
96
+ - Collect **real** legacy JSON. Not synthesised — actual systems from the repo's
97
+ examples, the docs site, and (if you can get them) from users. Ask in the
98
+ Discords; people will send you their old files and it's a nice re-engagement
99
+ excuse.
100
+ - Store under `test/fixtures/schema/v0/`, `v1/`, etc.
101
+ - Golden test: every fixture at every version loads without error and produces
102
+ a system whose structure matches an expected snapshot.
103
+ - **Add a fixture for every version, at the time that version ships.** A v2
104
+ fixture written after v4 exists is not evidence of anything.
105
+
106
+ **Acceptance:** CI fails if any historical fixture stops loading.
107
+
108
+ ---
109
+
110
+ ## Stage 4 — Write path
111
+
112
+ - `System.toJSON()` always emits the current version. Never write old versions.
113
+ - No "save as v1" affordance. Downgrade is not a supported operation and
114
+ pretending otherwise creates an infinite matrix.
115
+ - The editor's export always produces current-version JSON.
116
+
117
+ ---
118
+
119
+ ## Interaction with 01 and 05
120
+
121
+ The migration for **01** (flat → tree) is mechanical: wrap each existing emitter
122
+ with `children: []`. Every legacy system is a tree of depth 1. Lossless.
123
+
124
+ The migration for **05** (base64 → refs) is *not* purely mechanical, because it
125
+ must write blobs somewhere. The pure transform can only rewrite the reference;
126
+ the blob extraction needs a storage target. See 05, Stage 3 — the resolution is
127
+ that the migration emits an intermediate form carrying the decoded bytes, and the
128
+ asset store consumes it.
129
+
130
+ Do not let 05's awkwardness leak into this spec's design. Migrations stay pure;
131
+ 05 adapts.
132
+
133
+ ---
134
+
135
+ ## Explicitly out of scope
136
+
137
+ - Downgrade / backward migration
138
+ - Schema validation (JSON Schema, zod, etc.) — worth doing, separate concern,
139
+ do not couple it to versioning
140
+ - Versioning the *bundle* format (05's container) — related but distinct
@@ -0,0 +1,288 @@
1
+ # Spec 05 — Content-Addressed Assets
2
+
3
+ Replace inlined base64 textures with content-addressed references, plus a
4
+ pluggable resolver and a bundle container.
5
+
6
+ **Depends on:** 04 (schema versioning) — this breaks the schema.
7
+ **Blocks:** 03 (audio blob storage)
8
+
9
+ ---
10
+
11
+ ## Context: the current design was correct
12
+
13
+ Textures are currently base64-encoded directly into the system JSON. **This was
14
+ the right call.** Nebula was an NW.js desktop app with no server: one portable
15
+ file, no asset resolution, no CORS, atomically versioned, works offline.
16
+
17
+ The constraint has changed, not the reasoning. Any consumer holding more than a
18
+ handful of systems needs dedup, CDN caching, and stable asset identity — none of
19
+ which are possible when assets are anonymous strings inside documents.
20
+
21
+ **Do not frame this as fixing a mistake. It's an inherited constraint being lifted.**
22
+
23
+ ---
24
+
25
+ ## What breaks at scale
26
+
27
+ | Problem | Consequence |
28
+ |---|---|
29
+ | No dedup | The same dozen smoke puffs stored once per system. 500 systems = 500 copies. |
30
+ | No CDN caching | Texture bytes ride inside the JSON; every fetch re-downloads them. Nothing is reusable across systems. |
31
+ | Flipbooks | A 4K 8×8 sheet is several MB raw, ~33% more base64'd. `JSON.parse` on a 10MB blob stalls the main thread. |
32
+ | No asset identity | Two systems using the same texture are unrelatable. "What uses this asset?" is unanswerable. |
33
+ | Useless diffs | Any texture tweak rewrites the whole document. |
34
+
35
+ Asset identity is the one with no workaround. With a content hash, "what uses
36
+ this?" is a single lookup; with inlined base64 the question cannot be asked.
37
+
38
+ ## Precedent
39
+
40
+ glTF ships exactly these three forms of the same problem:
41
+
42
+ - `.gltf` + external files → references
43
+ - `.gltf` with `data:` URIs → **the current design**
44
+ - `.glb` → binary container, JSON chunk + binary chunk, no base64
45
+
46
+ The industry converged on GLB, for precisely the 33% tax plus parse cost. Steal
47
+ the resolution, not just the problem statement — and lean on it in docs, because
48
+ three.js users already know this shape.
49
+
50
+ ---
51
+
52
+ ## Stage 0 — Audit
53
+
54
+ 1. How are textures currently represented in the JSON? Exact field paths.
55
+ 2. Is base64 the only form, or are URLs also accepted?
56
+ 3. Where does texture loading happen — is there a single choke point or is it
57
+ scattered across renderers/initializers?
58
+ 4. Is `Body` / sprite texture handling shared with anything else?
59
+ 5. Is there any caching of decoded textures today?
60
+
61
+ ---
62
+
63
+ ## Stage 1 — Reference format
64
+
65
+ ```jsonc
66
+ {
67
+ "renderer": {
68
+ "type": "sprite",
69
+ "texture": { "$ref": "sha256:a3f2c1..." }
70
+ }
71
+ }
72
+ ```
73
+
74
+ - **sha256 over the raw bytes**, hex-encoded, prefixed with the algorithm.
75
+ The prefix is cheap and buys algorithm agility.
76
+ - The hash **is** the identity. Immutable by construction.
77
+ - Same format for audio blobs (03) and any future asset type. One store.
78
+
79
+ ---
80
+
81
+ ## Stage 2 — Resolver interface
82
+
83
+ The runtime must not know or care where bytes come from.
84
+
85
+ ```ts
86
+ interface AssetResolver {
87
+ resolve(ref: string): Promise<ArrayBuffer>;
88
+ has(ref: string): boolean;
89
+ }
90
+ ```
91
+
92
+ Implementations:
93
+
94
+ - `MemoryResolver` — a `Map`, for tests and for the embed case
95
+ - `BundleResolver` — reads from an unpacked container (Stage 4)
96
+ - `HttpResolver` — `GET {baseUrl}/{hash}`, immutable cache semantics
97
+ - `IndexedDBResolver` — browser-local persistence
98
+ - `ChainResolver` — tries several in order
99
+
100
+ **Decoded-texture cache keyed by hash.** This is where dedup actually pays: a
101
+ thousand systems referencing the same gradient decode it once.
102
+
103
+ ---
104
+
105
+ ## Stage 3 — Migration from base64
106
+
107
+ 04 requires migrations to be pure functions. Blob extraction needs a storage
108
+ target. The resolution:
109
+
110
+ - The **pure** migration rewrites `"data:image/png;base64,..."` →
111
+ `{ "$ref": "sha256:..." }` and emits the decoded bytes into a side-channel on
112
+ the returned object (`__pendingAssets: Map<ref, ArrayBuffer>`).
113
+ - `System.fromJSON` (which is already async) drains `__pendingAssets` into
114
+ whatever resolver it was given, then discards the side-channel.
115
+ - Migrations stay pure. The async lives at the call site where it belongs.
116
+
117
+ **Back-compat is permanent.** Keep accepting `data:` URIs and plain URLs forever;
118
+ normalise to refs on read. Legacy systems must keep loading with no ceremony.
119
+
120
+ ---
121
+
122
+ ## Stage 4 — Bundle container
123
+
124
+ A system plus every asset it references, as one portable file. This is what makes
125
+ a system genuinely self-contained: a JSON whose textures live on somebody else's
126
+ server is not portable, it's a dangling dependency.
127
+
128
+ *Prior art:* Effekseer's `.efkpkg` does exactly this — an effect bundled with every
129
+ resource it references, travelling as one artifact. glTF's `.glb` is the same idea
130
+ in a container three.js users already know.
131
+
132
+ Two viable shapes:
133
+
134
+ - **Zip** — trivially inspectable, universal tooling, streams poorly.
135
+ - **GLB-style binary container** — JSON chunk + blob chunk, fast to parse,
136
+ requires custom tooling.
137
+
138
+ **Recommend zip for v1.** Deflate on already-compressed PNGs is near-free; store
139
+ them uncompressed. Universal tooling matters more than parse speed at this stage,
140
+ and the format is versioned separately from the schema (04) so it can change.
141
+
142
+ ```
143
+ system.nebula (zip)
144
+ ├── manifest.json { bundleVersion, systemRef, assets: [{ ref, mime, bytes }] }
145
+ ├── system.json (version-stamped, refs only)
146
+ └── assets/
147
+ ├── a3f2c1... (raw bytes, filename = hash)
148
+ └── 9b7e04...
149
+ ```
150
+
151
+ **Verify hashes on read.** A ref that doesn't match its bytes is a corrupt or
152
+ tampered bundle and should fail loudly.
153
+
154
+ ---
155
+
156
+ ## Stage 5 — Export modes
157
+
158
+ There are **three** export modes and they are not interchangeable. The bundle
159
+ (Stage 4) is the default. Base64 is a narrow third path, not the export format.
160
+
161
+ | Mode | Output | Use | Assets |
162
+ |---|---|---|---|
163
+ | `bundle` **(default)** | `.nebula` zip | Distributing a system to anyone else | Packed, hash-verified |
164
+ | `refs` | `system.json` + loose asset files | A build pipeline where the bundler/CDN handles assets | External |
165
+ | `embed` | one `system.json` | Copy-paste into a CodePen/gist, single-file demo, no build step | `data:` URIs |
166
+
167
+ **Why `bundle` beats `embed` almost everywhere:** a zip is *also* one
168
+ self-contained portable file. It just doesn't pay the ~33% base64 tax or the
169
+ `JSON.parse` cost on a multi-MB string. Anywhere the goal is "one file I can hand
170
+ someone," `bundle` is strictly better.
171
+
172
+ **`embed`'s only real advantage is pasteability.** A zip cannot be pasted into a
173
+ text editor. That is a genuine use case — plausibly how a lot of three.js
174
+ developers first try the library — so the mode stays and should be a first-class,
175
+ documented affordance. It is not the default and it is not the storage format.
176
+
177
+ **Guard rail:** `embed` should warn (not fail) above a size threshold — suggest
178
+ 2MB of decoded assets. The moment flipbooks are involved, `embed` stops being
179
+ viable and users should be told why rather than discovering it as a mystery stall.
180
+
181
+ Keep base64 as an **explicit opt-in export flag**, not the canonical form.
182
+
183
+ ```js
184
+ system.toBundle() // .nebula zip (default for sharing)
185
+ system.toJSON() // refs (default for build pipelines)
186
+ system.toJSON({ embedAssets: true }) // data: URIs, one self-contained file
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Stage 6 — Runtime & distribution
192
+
193
+ **The bundle is an interchange format, not a runtime format.** Nothing should
194
+ ship a zip to production. This is the single most important thing in this spec to
195
+ get right, and it is currently only implied by the resolver interface.
196
+
197
+ | Phase | Format | Who handles it |
198
+ |---|---|---|
199
+ | **Interchange** | `.nebula` zip | Publishing, downloading, handing a file to a colleague |
200
+ | **Build** | unpacked → `system.json` + loose assets | The bundler |
201
+ | **Runtime** | refs resolved to URLs | The app |
202
+
203
+ The zip exists to move a system from A to B intact. It is unpacked **once, at
204
+ build time**, and never appears in production — the same way nobody ships a zip
205
+ of `node_modules`.
206
+
207
+ ### Why runtime-unzip is actively wrong on the web
208
+
209
+ - **HTTP caching dies.** A zip is one opaque blob. Change one texture and the
210
+ whole thing redownloads. Loose hashed assets cache individually, forever.
211
+ - **No parallel fetch.** The browser will pull six textures concurrently. A zip
212
+ is one serial request, then a decompress.
213
+ - **Main-thread cost** for the unzip, on every load.
214
+ - **No transcoding.** Consumers may want webp/avif, or KTX2 for GPU textures.
215
+ You cannot run an image pipeline over bytes sealed in a zip.
216
+ - **No code splitting.** The system can't be lazily loaded per-route if it's
217
+ welded into a blob.
218
+
219
+ ### Consumer paths
220
+
221
+ **With a build pipeline (Vite/Next/webpack — the common case).**
222
+
223
+ Their bundler *already does content-addressing*: it emits `fire-tex.a3f2c1.png`
224
+ with immutable cache headers. Our hash refs map onto that almost exactly.
225
+
226
+ Ship a **bundler plugin** (`@nebula/vite-plugin`, others later):
227
+
228
+ ```js
229
+ import fireSystem from './effects/fire.nebula'
230
+ // → unpacked at build, assets emitted through Vite's pipeline,
231
+ // refs resolved to hashed URLs, tree-shaken, lazy-loadable
232
+ ```
233
+
234
+ Architecturally this is nearly free: the plugin constructs an `AssetResolver`
235
+ (Stage 2) backed by the bundler's emitted URLs. Small package, high DX leverage.
236
+
237
+ **Without a build step** — Webflow, a CMS, a marketing site, a `<script>` tag.
238
+
239
+ This is where `embed` (Stage 5) earns its keep. One JSON, base64 assets, no asset
240
+ pipeline to configure, no CORS. For an agency dropping one hero effect onto a
241
+ landing page with no tooling, this is the correct answer.
242
+
243
+ **CDN-hosted refs** — viable for a live web page (`HttpResolver`, immutable cache
244
+ keys). **Never for a shipped game**: offline breaks, latency, CORS, a GDPR
245
+ surface from player IPs, and a hard dependency on our uptime. Document it as
246
+ demo/web-embed convenience only, with no SLA.
247
+
248
+ ### Hard architectural constraint
249
+
250
+ **three-nebula core must never contain a zip decoder.**
251
+
252
+ Bundle handling lives in a separate package — `@nebula/bundle` — consumed by the
253
+ editing tools, packaging tools, and the bundler plugin. The runtime only ever
254
+ accepts an `AssetResolver`.
255
+
256
+ This keeps the core small, keeps fflate (or equivalent) out of every consumer's
257
+ bundle, and means the zip-vs-GLB decision in Stage 4 can be revisited later
258
+ without touching the runtime at all.
259
+
260
+ It also validates the Stage 2 resolver design: `HttpResolver` covers
261
+ agency-with-CDN, `MemoryResolver` covers embed, the bundler plugin covers the
262
+ build case — and none of them require the runtime to know that a bundle format
263
+ exists.
264
+
265
+ ---
266
+
267
+ ## Downstream (not this spec — just don't foreclose it)
268
+
269
+ Content-addressing makes several things tractable that are impossible while assets
270
+ are anonymous strings inside documents:
271
+
272
+ - **Shared asset libraries** — curated packs; "what uses this texture?" as a
273
+ query; one asset becoming a dependency of many systems.
274
+ - **Programmatic system generation** — with a tagged, hashed asset store, asset
275
+ selection becomes *retrieval* rather than *generation*.
276
+ - **Asset-level metadata** — licensing, attribution and provenance attach to the
277
+ hash once, rather than to every copy.
278
+
279
+ Nothing in this spec builds any of that. It only needs to avoid ruling it out.
280
+
281
+ ---
282
+
283
+ ## Explicitly out of scope
284
+
285
+ - Any specific storage backend — that's infrastructure, not runtime
286
+ - Texture tagging / search
287
+ - Licensing metadata schema — flag that it will attach to the hash, then leave it
288
+ - Compression / transcoding (KTX2, basis) — real future win, separate spec
@@ -0,0 +1,80 @@
1
+ # three-nebula Modernisation — Spec Index
2
+
3
+ ## Purpose
4
+
5
+ Five specs covering runtime modernisation work for the three-nebula library.
6
+
7
+ **The numbers are identifiers, not execution order.** See the dependency graph below.
8
+
9
+ ## Specs
10
+
11
+ | # | Spec | Shape |
12
+ |---|------|-------|
13
+ | 01 | Emitter Hierarchy (nested emitters, ribbon/trail renderer, pooling) | New capability + schema break |
14
+ | 02 | Determinism & Scrubbing | Architecture, invisible, blocking |
15
+ | 03 | Sound Renderer | New capability, additive |
16
+ | 04 | Schema Versioning | Architecture, enabling |
17
+ | 05 | Content-Addressed Assets | Architecture + schema break |
18
+
19
+ ## Dependency graph
20
+
21
+ ```
22
+ 04 (versioning) ──┬──> 01 (hierarchy)
23
+ │ ▲
24
+ └──> 05 (assets)
25
+
26
+ 02 (determinism) ───────────┴──> 03 (sound)
27
+ ```
28
+
29
+ **Hard constraints:**
30
+
31
+ - **04 lands first.** Both 01 and 05 break the JSON schema. Without a migration
32
+ path in place, every existing saved system is stranded. 04 is small and boring
33
+ and must precede both.
34
+ - **02 entangles with 01.** Child emitter instances need seeds derived from the
35
+ parent particle's ID. Landing 01 without 02 means touching every initializer
36
+ and behaviour twice.
37
+ - **03 depends on 02** for jitter (pitch/offset randomisation must be seeded) and
38
+ on **05** for audio blob storage.
39
+
40
+ Suggested landing order: **04 → 02 → 01 → 05 → 03**
41
+
42
+ 03 (sound) is deliberately last: it is the most additive and least entangled,
43
+ and it is the easiest to defer if time runs short.
44
+
45
+ ## On prior art
46
+
47
+ Several specs cite existing FX tools — PopcornFX, Niagara, Effekseer, glTF — where
48
+ they solved a problem first and solved it well. These are **references, not
49
+ comparisons**: the point is to learn from work that's already been done rather than
50
+ rediscover it, and to give an implementer somewhere concrete to read.
51
+
52
+ Where a design here differs from prior art, that's a deliberate choice and the
53
+ reasoning is stated. Where it copies, that's also deliberate.
54
+
55
+ ## Assumptions flagged for verification
56
+
57
+ These specs were written without repo access. Every spec contains an
58
+ **Audit** stage as Stage 0. Claude Code should run those audits first and
59
+ correct the specs before implementation. In particular, the following are
60
+ **guesses** and may already be solved:
61
+
62
+ - Whether particle pooling exists and how it works
63
+ - Whether a seeded PRNG exists, or whether `Math.random()` is called directly
64
+ - Whether particles carry a stable ID
65
+ - Whether flipbook / sprite-sheet playback already exists in some form
66
+ - The current renderer interface's exact lifecycle hooks
67
+ - Whether the sim uses a fixed timestep or raw delta
68
+
69
+ If the audits contradict a spec, **trust the repo**.
70
+
71
+ ## Out of scope
72
+
73
+ - Anything outside the three-nebula library itself
74
+ - Applications built on top of the library
75
+ - Backend, hosting, or distribution infrastructure
76
+ - **Offline rendering / baking.** Stepping a system headlessly and encoding the
77
+ frames to sheets or video is a *consumer* of this library, not a feature of it:
78
+ it needs `reset()`, `step()` and determinism (02) and nothing else from the
79
+ runtime. The rest is three.js, canvas and WebCodecs. The library's only
80
+ obligation is the headless contract in **02, Stage 6**.