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.
- package/CHANGELOG.md +16 -0
- package/dist/three-nebula.cjs +109 -0
- package/dist/three-nebula.cjs.map +1 -0
- package/dist/three-nebula.mjs +2969 -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 +23 -47
- 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 -798
- 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/Rotation.js +0 -95
- 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 -108
- package/build/cjs/initializer/types.js +0 -37
- 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 -371
- 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 -385
- 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 -58
- 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 -45
- 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 -68
- 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 -12
- 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 -708
- 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/Rotation.js +0 -54
- 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 -12
- package/build/esm/initializer/types.js +0 -16
- 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 -357
- package/build/esm/renderer/GPURenderer/Desktop/shaders/fragmentShader.js +0 -30
- package/build/esm/renderer/GPURenderer/Desktop/shaders/index.js +0 -3
- package/build/esm/renderer/GPURenderer/Desktop/shaders/vertexShader.js +0 -36
- package/build/esm/renderer/GPURenderer/Mobile/index.js +0 -375
- package/build/esm/renderer/GPURenderer/Mobile/shaders/fragmentShader.js +0 -27
- package/build/esm/renderer/GPURenderer/Mobile/shaders/index.js +0 -3
- package/build/esm/renderer/GPURenderer/Mobile/shaders/vertexShader.js +0 -33
- package/build/esm/renderer/GPURenderer/common/ParticleBuffer/constants.js +0 -30
- 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 -26
- 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 -93
- package/build/esm/renderer/SpriteRenderer.js +0 -30
- 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
package/package.json
CHANGED
|
@@ -1,43 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "three-nebula",
|
|
3
|
-
"version": "11.
|
|
3
|
+
"version": "11.1.1",
|
|
4
4
|
"description": "WebGL based 3D particle engine",
|
|
5
|
-
"main": "./
|
|
6
|
-
"module": "./
|
|
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": "
|
|
13
|
-
"
|
|
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": "
|
|
24
|
-
"test:
|
|
25
|
-
"
|
|
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
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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.
|