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.
- package/.claude/rules/no-coauthor.md +4 -0
- package/CHANGELOG.md +33 -0
- package/dist/three-nebula.cjs +109 -0
- package/dist/three-nebula.cjs.map +1 -0
- package/dist/three-nebula.mjs +2968 -0
- package/dist/three-nebula.mjs.map +1 -0
- package/dist/three-nebula.umd.js +109 -0
- package/dist/three-nebula.umd.js.map +1 -0
- package/package.json +32 -56
- package/specs/01-emitter-hierarchy.md +200 -0
- package/specs/02-determinism-scrubbing.md +188 -0
- package/specs/03-sound-renderer.md +183 -0
- package/specs/04-schema-versioning.md +140 -0
- package/specs/05-content-addressed-assets.md +288 -0
- package/specs/README.md +80 -0
- package/build/cjs/behaviour/Alpha.js +0 -189
- package/build/cjs/behaviour/Attraction.js +0 -189
- package/build/cjs/behaviour/Behaviour.js +0 -253
- package/build/cjs/behaviour/Collision.js +0 -146
- package/build/cjs/behaviour/Color.js +0 -148
- package/build/cjs/behaviour/CrossZone.js +0 -141
- package/build/cjs/behaviour/Force.js +0 -126
- package/build/cjs/behaviour/Gravity.js +0 -78
- package/build/cjs/behaviour/RandomDrift.js +0 -153
- package/build/cjs/behaviour/Repulsion.js +0 -123
- package/build/cjs/behaviour/Rotate.js +0 -265
- package/build/cjs/behaviour/Scale.js +0 -177
- package/build/cjs/behaviour/Spring.js +0 -133
- package/build/cjs/behaviour/constants.js +0 -28
- package/build/cjs/behaviour/index.js +0 -112
- package/build/cjs/behaviour/types.js +0 -33
- package/build/cjs/constants/index.js +0 -61
- package/build/cjs/core/Particle.js +0 -390
- package/build/cjs/core/Pool.js +0 -203
- package/build/cjs/core/System.js +0 -366
- package/build/cjs/core/constants.js +0 -163
- package/build/cjs/core/fromJSON.js +0 -128
- package/build/cjs/core/fromJSONAsync.js +0 -239
- package/build/cjs/core/index.js +0 -32
- package/build/cjs/core/three/Euler.js +0 -233
- package/build/cjs/core/three/Math.js +0 -91
- package/build/cjs/core/three/Matrix4.js +0 -745
- package/build/cjs/core/three/Quaternion.js +0 -451
- package/build/cjs/core/three/Vector3.js +0 -464
- package/build/cjs/core/three/constants.js +0 -298
- package/build/cjs/core/three/index.js +0 -48
- package/build/cjs/core/types.js +0 -13
- package/build/cjs/debug/Debug.js +0 -209
- package/build/cjs/debug/constants.js +0 -19
- package/build/cjs/debug/index.js +0 -24
- package/build/cjs/debug/log.js +0 -38
- package/build/cjs/ease/index.js +0 -149
- package/build/cjs/emitter/Emitter.js +0 -791
- package/build/cjs/emitter/FollowEmitter.js +0 -146
- package/build/cjs/emitter/constants.js +0 -20
- package/build/cjs/emitter/index.js +0 -24
- package/build/cjs/emitter/types.js +0 -11
- package/build/cjs/events/EventDispatcher.js +0 -105
- package/build/cjs/events/constants.js +0 -23
- package/build/cjs/events/index.js +0 -27
- package/build/cjs/index.js +0 -151
- package/build/cjs/initializer/Body.js +0 -122
- package/build/cjs/initializer/BodySprite.js +0 -146
- package/build/cjs/initializer/Initializer.js +0 -109
- package/build/cjs/initializer/InitializerUtil.js +0 -54
- package/build/cjs/initializer/Life.js +0 -106
- package/build/cjs/initializer/Mass.js +0 -101
- package/build/cjs/initializer/Position.js +0 -152
- package/build/cjs/initializer/Radius.js +0 -118
- package/build/cjs/initializer/Rate.js +0 -150
- package/build/cjs/initializer/Texture.js +0 -139
- package/build/cjs/initializer/Velocity/PolarVelocity.js +0 -102
- package/build/cjs/initializer/Velocity/RadialVelocity.js +0 -111
- package/build/cjs/initializer/Velocity/VectorVelocity.js +0 -108
- package/build/cjs/initializer/Velocity/Velocity.js +0 -94
- package/build/cjs/initializer/Velocity/index.js +0 -32
- package/build/cjs/initializer/constants.js +0 -43
- package/build/cjs/initializer/index.js +0 -100
- package/build/cjs/initializer/types.js +0 -35
- package/build/cjs/math/ArraySpan.js +0 -103
- package/build/cjs/math/Box.js +0 -46
- package/build/cjs/math/ColorSpan.js +0 -113
- package/build/cjs/math/MathUtils.js +0 -57
- package/build/cjs/math/Polar3D.js +0 -115
- package/build/cjs/math/Span.js +0 -88
- package/build/cjs/math/Vector3D.js +0 -76
- package/build/cjs/math/constants.js +0 -35
- package/build/cjs/math/index.js +0 -122
- package/build/cjs/math/integration.js +0 -59
- package/build/cjs/math/types.js +0 -19
- package/build/cjs/renderer/BaseRenderer.js +0 -108
- package/build/cjs/renderer/CustomRenderer.js +0 -65
- package/build/cjs/renderer/GPURenderer/Desktop/index.js +0 -350
- package/build/cjs/renderer/GPURenderer/Desktop/shaders/fragmentShader.js +0 -13
- package/build/cjs/renderer/GPURenderer/Desktop/shaders/index.js +0 -22
- package/build/cjs/renderer/GPURenderer/Desktop/shaders/vertexShader.js +0 -17
- package/build/cjs/renderer/GPURenderer/Mobile/index.js +0 -364
- package/build/cjs/renderer/GPURenderer/Mobile/shaders/fragmentShader.js +0 -13
- package/build/cjs/renderer/GPURenderer/Mobile/shaders/index.js +0 -22
- package/build/cjs/renderer/GPURenderer/Mobile/shaders/vertexShader.js +0 -15
- package/build/cjs/renderer/GPURenderer/common/ParticleBuffer/constants.js +0 -53
- package/build/cjs/renderer/GPURenderer/common/ParticleBuffer/index.js +0 -93
- package/build/cjs/renderer/GPURenderer/common/TextureAtlas/constants.js +0 -9
- package/build/cjs/renderer/GPURenderer/common/TextureAtlas/index.js +0 -221
- package/build/cjs/renderer/GPURenderer/common/constants.js +0 -22
- package/build/cjs/renderer/GPURenderer/common/index.js +0 -38
- package/build/cjs/renderer/GPURenderer/common/shaders/constants.js +0 -9
- package/build/cjs/renderer/GPURenderer/common/stores/Target.js +0 -43
- package/build/cjs/renderer/GPURenderer/common/stores/UniqueList.js +0 -51
- package/build/cjs/renderer/GPURenderer/common/stores/index.js +0 -22
- package/build/cjs/renderer/GPURenderer/index.js +0 -97
- package/build/cjs/renderer/MeshRenderer.js +0 -136
- package/build/cjs/renderer/SpriteRenderer.js +0 -63
- package/build/cjs/renderer/index.js +0 -40
- package/build/cjs/renderer/types.js +0 -21
- package/build/cjs/utils/ColorUtil.js +0 -39
- package/build/cjs/utils/PUID.js +0 -26
- package/build/cjs/utils/THREEUtil.js +0 -40
- package/build/cjs/utils/Util.js +0 -69
- package/build/cjs/utils/index.js +0 -61
- package/build/cjs/utils/uid.js +0 -14
- package/build/cjs/zone/BoxZone.js +0 -178
- package/build/cjs/zone/LineZone.js +0 -101
- package/build/cjs/zone/MeshZone.js +0 -101
- package/build/cjs/zone/PointZone.js +0 -99
- package/build/cjs/zone/ScreenZone.js +0 -164
- package/build/cjs/zone/SphereZone.js +0 -155
- package/build/cjs/zone/Zone.js +0 -172
- package/build/cjs/zone/index.js +0 -56
- package/build/cjs/zone/types.js +0 -21
- package/build/esm/behaviour/Alpha.js +0 -134
- package/build/esm/behaviour/Attraction.js +0 -135
- package/build/esm/behaviour/Behaviour.js +0 -215
- package/build/esm/behaviour/Collision.js +0 -100
- package/build/esm/behaviour/Color.js +0 -101
- package/build/esm/behaviour/CrossZone.js +0 -82
- package/build/esm/behaviour/Force.js +0 -83
- package/build/esm/behaviour/Gravity.js +0 -40
- package/build/esm/behaviour/RandomDrift.js +0 -97
- package/build/esm/behaviour/Repulsion.js +0 -81
- package/build/esm/behaviour/Rotate.js +0 -214
- package/build/esm/behaviour/Scale.js +0 -131
- package/build/esm/behaviour/Spring.js +0 -90
- package/build/esm/behaviour/constants.js +0 -11
- package/build/esm/behaviour/index.js +0 -14
- package/build/esm/behaviour/types.js +0 -14
- package/build/esm/constants/index.js +0 -42
- package/build/esm/core/Particle.js +0 -357
- package/build/esm/core/Pool.js +0 -161
- package/build/esm/core/System.js +0 -320
- package/build/esm/core/constants.js +0 -127
- package/build/esm/core/fromJSON.js +0 -107
- package/build/esm/core/fromJSONAsync.js +0 -210
- package/build/esm/core/index.js +0 -4
- package/build/esm/core/three/Euler.js +0 -224
- package/build/esm/core/three/Math.js +0 -84
- package/build/esm/core/three/Matrix4.js +0 -739
- package/build/esm/core/three/Quaternion.js +0 -445
- package/build/esm/core/three/Vector3.js +0 -457
- package/build/esm/core/three/constants.js +0 -148
- package/build/esm/core/three/index.js +0 -5
- package/build/esm/core/types.js +0 -4
- package/build/esm/debug/Debug.js +0 -195
- package/build/esm/debug/constants.js +0 -7
- package/build/esm/debug/index.js +0 -3
- package/build/esm/debug/log.js +0 -31
- package/build/esm/ease/index.js +0 -113
- package/build/esm/emitter/Emitter.js +0 -701
- package/build/esm/emitter/FollowEmitter.js +0 -97
- package/build/esm/emitter/constants.js +0 -7
- package/build/esm/emitter/index.js +0 -3
- package/build/esm/emitter/types.js +0 -3
- package/build/esm/events/EventDispatcher.js +0 -82
- package/build/esm/events/constants.js +0 -9
- package/build/esm/events/index.js +0 -4
- package/build/esm/index.js +0 -13
- package/build/esm/initializer/Body.js +0 -83
- package/build/esm/initializer/BodySprite.js +0 -108
- package/build/esm/initializer/Initializer.js +0 -84
- package/build/esm/initializer/InitializerUtil.js +0 -48
- package/build/esm/initializer/Life.js +0 -66
- package/build/esm/initializer/Mass.js +0 -60
- package/build/esm/initializer/Position.js +0 -98
- package/build/esm/initializer/Radius.js +0 -75
- package/build/esm/initializer/Rate.js +0 -107
- package/build/esm/initializer/Texture.js +0 -101
- package/build/esm/initializer/Velocity/PolarVelocity.js +0 -63
- package/build/esm/initializer/Velocity/RadialVelocity.js +0 -72
- package/build/esm/initializer/Velocity/VectorVelocity.js +0 -69
- package/build/esm/initializer/Velocity/Velocity.js +0 -52
- package/build/esm/initializer/Velocity/index.js +0 -4
- package/build/esm/initializer/constants.js +0 -26
- package/build/esm/initializer/index.js +0 -11
- package/build/esm/initializer/types.js +0 -15
- package/build/esm/math/ArraySpan.js +0 -62
- package/build/esm/math/Box.js +0 -25
- package/build/esm/math/ColorSpan.js +0 -71
- package/build/esm/math/MathUtils.js +0 -48
- package/build/esm/math/Polar3D.js +0 -81
- package/build/esm/math/Span.js +0 -63
- package/build/esm/math/Vector3D.js +0 -34
- package/build/esm/math/constants.js +0 -24
- package/build/esm/math/index.js +0 -10
- package/build/esm/math/integration.js +0 -47
- package/build/esm/math/types.js +0 -7
- package/build/esm/renderer/BaseRenderer.js +0 -78
- package/build/esm/renderer/CustomRenderer.js +0 -23
- package/build/esm/renderer/GPURenderer/Desktop/index.js +0 -331
- package/build/esm/renderer/GPURenderer/Desktop/shaders/fragmentShader.js +0 -23
- package/build/esm/renderer/GPURenderer/Desktop/shaders/index.js +0 -3
- package/build/esm/renderer/GPURenderer/Desktop/shaders/vertexShader.js +0 -33
- package/build/esm/renderer/GPURenderer/Mobile/index.js +0 -349
- package/build/esm/renderer/GPURenderer/Mobile/shaders/fragmentShader.js +0 -20
- package/build/esm/renderer/GPURenderer/Mobile/shaders/index.js +0 -3
- package/build/esm/renderer/GPURenderer/Mobile/shaders/vertexShader.js +0 -30
- package/build/esm/renderer/GPURenderer/common/ParticleBuffer/constants.js +0 -27
- package/build/esm/renderer/GPURenderer/common/ParticleBuffer/index.js +0 -70
- package/build/esm/renderer/GPURenderer/common/TextureAtlas/constants.js +0 -2
- package/build/esm/renderer/GPURenderer/common/TextureAtlas/index.js +0 -206
- package/build/esm/renderer/GPURenderer/common/constants.js +0 -13
- package/build/esm/renderer/GPURenderer/common/index.js +0 -4
- package/build/esm/renderer/GPURenderer/common/shaders/constants.js +0 -2
- package/build/esm/renderer/GPURenderer/common/stores/Target.js +0 -24
- package/build/esm/renderer/GPURenderer/common/stores/UniqueList.js +0 -29
- package/build/esm/renderer/GPURenderer/common/stores/index.js +0 -3
- package/build/esm/renderer/GPURenderer/index.js +0 -57
- package/build/esm/renderer/MeshRenderer.js +0 -94
- package/build/esm/renderer/SpriteRenderer.js +0 -26
- package/build/esm/renderer/index.js +0 -5
- package/build/esm/renderer/types.js +0 -8
- package/build/esm/utils/ColorUtil.js +0 -32
- package/build/esm/utils/PUID.js +0 -19
- package/build/esm/utils/THREEUtil.js +0 -31
- package/build/esm/utils/Util.js +0 -60
- package/build/esm/utils/index.js +0 -13
- package/build/esm/utils/uid.js +0 -3
- package/build/esm/zone/BoxZone.js +0 -133
- package/build/esm/zone/LineZone.js +0 -61
- package/build/esm/zone/MeshZone.js +0 -60
- package/build/esm/zone/PointZone.js +0 -59
- package/build/esm/zone/ScreenZone.js +0 -116
- package/build/esm/zone/SphereZone.js +0 -113
- package/build/esm/zone/Zone.js +0 -141
- package/build/esm/zone/index.js +0 -7
- package/build/esm/zone/types.js +0 -8
- package/build/three-nebula.js +0 -2
- 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
|
package/specs/README.md
ADDED
|
@@ -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**.
|