fancy-ui-svelte 0.9.0 → 0.9.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/dist/fancy-ui/fireworks-hdr/FireworksHdr.svelte +629 -0
- package/dist/fancy-ui/fireworks-hdr/FireworksHdr.svelte.d.ts +56 -0
- package/dist/fancy-ui/fireworks-hdr/FireworksHdr.test.d.ts +1 -0
- package/dist/fancy-ui/fireworks-hdr/FireworksHdr.test.js +74 -0
- package/dist/fancy-ui/fireworks-hdr/README.md +492 -0
- package/dist/fancy-ui/fireworks-hdr/fireworks-shared.d.ts +444 -0
- package/dist/fancy-ui/fireworks-hdr/fireworks-shared.js +1520 -0
- package/dist/fancy-ui/fireworks-hdr/fireworks-shared.test.d.ts +1 -0
- package/dist/fancy-ui/fireworks-hdr/fireworks-shared.test.js +1054 -0
- package/dist/fancy-ui/fireworks-hdr/index.d.ts +5 -0
- package/dist/fancy-ui/fireworks-hdr/index.js +5 -0
- package/dist/fancy-ui/fireworks-hdr/webgl2-renderer.d.ts +20 -0
- package/dist/fancy-ui/fireworks-hdr/webgl2-renderer.js +634 -0
- package/dist/fancy-ui/fireworks-hdr/webgl2-renderer.test.d.ts +1 -0
- package/dist/fancy-ui/fireworks-hdr/webgl2-renderer.test.js +33 -0
- package/dist/fancy-ui/fireworks-hdr/webgpu-renderer.d.ts +51 -0
- package/dist/fancy-ui/fireworks-hdr/webgpu-renderer.js +538 -0
- package/dist/fancy-ui/index.d.ts +1 -0
- package/dist/fancy-ui/index.js +1 -0
- package/dist/fancy-ui/registry.js +79 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
// Pure physics/animation core for FireworksHdr — DOM-free and GPU-free.
|
|
2
|
+
//
|
|
3
|
+
// Transcribes the frozen physics spec (§1–§7). Positions are normalized
|
|
4
|
+
// [0,1] with a top-left, y-down origin. Velocities and accelerations are in
|
|
5
|
+
// VISUAL units (screen-heights/sec, isotropic); aspect A = canvasW/canvasH is
|
|
6
|
+
// applied only when integrating x (`pos.x += (vel.x / A) · dt`) and when
|
|
7
|
+
// converting a normalized position error to visual units (seeker springs).
|
|
8
|
+
//
|
|
9
|
+
// Determinism invariant: everything reachable from `step()` derives its
|
|
10
|
+
// randomness from stored per-particle seeds (hash-based value noise), never
|
|
11
|
+
// `Math.random`. `Math.random` only ever appears behind an injectable `rng()`
|
|
12
|
+
// used at spawn/schedule time (launch, ambient scheduling), so a fixed seed +
|
|
13
|
+
// fixed rng reproduce a frame exactly. This is what makes the lib testable.
|
|
14
|
+
// =============================================================================
|
|
15
|
+
// §1 — Pool layout, field map, enums, public types
|
|
16
|
+
// =============================================================================
|
|
17
|
+
/** Floats per particle in the interleaved pool. */
|
|
18
|
+
export const STRIDE = 17;
|
|
19
|
+
/** Field offsets inside one particle's STRIDE-float slot. */
|
|
20
|
+
export const F = {
|
|
21
|
+
posX: 0,
|
|
22
|
+
posY: 1,
|
|
23
|
+
velX: 2,
|
|
24
|
+
velY: 3,
|
|
25
|
+
age: 4,
|
|
26
|
+
ttl: 5,
|
|
27
|
+
size: 6,
|
|
28
|
+
r: 7,
|
|
29
|
+
g: 8,
|
|
30
|
+
b: 9,
|
|
31
|
+
brightness: 10,
|
|
32
|
+
type: 11,
|
|
33
|
+
seed: 12,
|
|
34
|
+
targetX: 13,
|
|
35
|
+
targetY: 14,
|
|
36
|
+
dragScale: 15,
|
|
37
|
+
/**
|
|
38
|
+
* Per-particle depth dim in (0,1] — the launch's `depth` folded into a
|
|
39
|
+
* single multiplier applied to the resolved brightness every frame (§3.6).
|
|
40
|
+
* Defaults to 1 for every spawn (see `appendParticle`), so only depth-aware
|
|
41
|
+
* spawns need to write it.
|
|
42
|
+
*/
|
|
43
|
+
depthDim: 16,
|
|
44
|
+
};
|
|
45
|
+
export const TYPE = {
|
|
46
|
+
ROCKET: 0,
|
|
47
|
+
SPARK: 1,
|
|
48
|
+
EMBER: 2,
|
|
49
|
+
SMOKE: 3,
|
|
50
|
+
FLASH: 4,
|
|
51
|
+
SEEKER: 5,
|
|
52
|
+
};
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// §2 — Constants
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// §2.1 World
|
|
57
|
+
export const G = 3.6; // visual gravity, +y down
|
|
58
|
+
export const DT_MAX = 0.033;
|
|
59
|
+
export const LAUNCH_Y = [1.02, 1.08];
|
|
60
|
+
export const LAUNCH_X = [0.08, 0.92];
|
|
61
|
+
// §2.4 Render/exposure
|
|
62
|
+
export const EXPOSURE_AMBIENT = 2.2;
|
|
63
|
+
export const EXPOSURE_INTRO = 2.6;
|
|
64
|
+
export const SDR_KNEE_START = 0.8;
|
|
65
|
+
export const SDR_CORE_SCALE = 1.4;
|
|
66
|
+
export const SDR_SAT_BOOST = 0.12;
|
|
67
|
+
// §7 instance velocity-stretch (seconds of motion baked into the quad's major
|
|
68
|
+
// axis). Small on purpose — the accumulation trail already conveys motion, and
|
|
69
|
+
// stacking a long stretch on top of it turns every spark into a needle.
|
|
70
|
+
export const STRETCH = 0.01;
|
|
71
|
+
// §2.3 Brightness ladder (pre-exposure; 1.0 = SDR white)
|
|
72
|
+
export const B_FLASH_INTRO = 3.4;
|
|
73
|
+
export const B_FLASH_AMBIENT = 2.1;
|
|
74
|
+
export const B_SPARK_FRESH = 2.2;
|
|
75
|
+
export const B_SPARK_STEADY = 0.9;
|
|
76
|
+
export const B_SPARK_TERMINAL = 0.15;
|
|
77
|
+
export const B_ASCENT_HEAD = 2.8;
|
|
78
|
+
export const B_ASCENT_TAIL = 0.45;
|
|
79
|
+
export const B_BURST_STREAK = 0.5;
|
|
80
|
+
export const B_EMBER_MEAN = 0.35;
|
|
81
|
+
export const B_SMOKE_MIN = 0.05;
|
|
82
|
+
export const B_SMOKE_MAX = 0.12;
|
|
83
|
+
export const B_GLYPH_HOLD = 1.3;
|
|
84
|
+
export const B_GLYPH_RELEASE = 2.6;
|
|
85
|
+
export const B_HAZE_CEIL = 0.18;
|
|
86
|
+
export const CRACKLE_HZ = 24;
|
|
87
|
+
// §2.5 Palette (ping-pong index order: cyan, blue, violet, magenta)
|
|
88
|
+
export const PALETTE_HEX = ["#42cfff", "#3d5bff", "#a142ff", "#ff2fd6"];
|
|
89
|
+
export const COOL_END_HEX = "#ffcf9c";
|
|
90
|
+
export const MAGNESIUM_HEX = "#f4f8ff";
|
|
91
|
+
export const COMET_HEAD_HEX = "#fff2e0";
|
|
92
|
+
export const SMOKE_HEX = "#0a0b10";
|
|
93
|
+
// Shell hue jitter (oklab degrees) and spark hue jitter — §2.5 / §1.6
|
|
94
|
+
export const SHELL_JITTER_DEG = 7;
|
|
95
|
+
export const SPARK_JITTER_DEG = 3;
|
|
96
|
+
// Ascent wobble (§4): lateral drift rate and its frequency. Applied to the
|
|
97
|
+
// rocket's position only — the velocity, and therefore the solved apex, is
|
|
98
|
+
// untouched, so the wobble cannot walk a shell off its target.
|
|
99
|
+
export const ROCKET_WOBBLE_AMP = 0.045;
|
|
100
|
+
export const ROCKET_WOBBLE_HZ = 11;
|
|
101
|
+
// Spark life-color schedule (§1.7): white → shell hue → cool end.
|
|
102
|
+
export const HUE_ARRIVES_AT = 0.34;
|
|
103
|
+
export const COOL_START = 0.72;
|
|
104
|
+
// Share of a shell's sparks that crackle — a fast on/off flicker (CRACKLE_HZ)
|
|
105
|
+
// on top of the normal decay. This is the "silver" chatter of a real break.
|
|
106
|
+
export const CRACKLE_FRAC = 0.06;
|
|
107
|
+
// Depth response for `depth ∈ [0,1]`: a far shell is dimmer, smaller, less
|
|
108
|
+
// saturated AND spatially tighter. Every term is applied at spawn (the dim as a
|
|
109
|
+
// per-particle multiplier on the brightness curve) so the same shell reads as
|
|
110
|
+
// "further back" on all of its debris — sparks, embers, smoke, flash, glyph.
|
|
111
|
+
export const DEPTH_DIM = 0.45; // brightness ×(1 − 0.45·d)
|
|
112
|
+
export const DEPTH_SIZE = 0.5; // particle size ×(1 − 0.5·d)
|
|
113
|
+
export const DEPTH_CHROMA = 0.35; // saturation ×(1 − 0.35·d)
|
|
114
|
+
export const DEPTH_RADIUS = 0.25; // burst radius ×(1 − 0.25·d)
|
|
115
|
+
// §2.6 Wind: session vector magnitudes (visual accel)
|
|
116
|
+
const WIND_X = [0.02, 0.05];
|
|
117
|
+
const WIND_Y = [0.01, 0.02];
|
|
118
|
+
// Per-type wind scale (§2.2 windScale column)
|
|
119
|
+
const WIND_SCALE = {
|
|
120
|
+
[TYPE.ROCKET]: 0.05,
|
|
121
|
+
[TYPE.SPARK]: 0.3,
|
|
122
|
+
[TYPE.EMBER]: 0.7,
|
|
123
|
+
[TYPE.SMOKE]: 1.0,
|
|
124
|
+
[TYPE.FLASH]: 0,
|
|
125
|
+
[TYPE.SEEKER]: 0.4,
|
|
126
|
+
};
|
|
127
|
+
// Per-type gravity scale (§2.2 gravScale column). Sparks and embers fall at a
|
|
128
|
+
// small fraction of the rocket's gravity: at full G their terminal velocity
|
|
129
|
+
// (g·G/drag) dwarfs the burst's own expansion speed, so a shell never opens
|
|
130
|
+
// into a sphere — it rains straight down as a fountain. Keeping ember gravity
|
|
131
|
+
// above spark gravity is what still separates a willow's droop from a peony.
|
|
132
|
+
const GRAV_SCALE = {
|
|
133
|
+
[TYPE.ROCKET]: 1.0,
|
|
134
|
+
[TYPE.SPARK]: 0.15,
|
|
135
|
+
[TYPE.EMBER]: 0.3,
|
|
136
|
+
[TYPE.SMOKE]: -0.05,
|
|
137
|
+
[TYPE.FLASH]: 0,
|
|
138
|
+
[TYPE.SEEKER]: 0, // gravity is phase-driven for seekers
|
|
139
|
+
};
|
|
140
|
+
// Per-type drag (1/s, §2.2 drag column)
|
|
141
|
+
const DRAG = {
|
|
142
|
+
[TYPE.ROCKET]: 0.6,
|
|
143
|
+
[TYPE.SPARK]: 2.2,
|
|
144
|
+
[TYPE.EMBER]: 2.4,
|
|
145
|
+
[TYPE.SMOKE]: 3.0,
|
|
146
|
+
[TYPE.FLASH]: 0,
|
|
147
|
+
[TYPE.SEEKER]: 1.2,
|
|
148
|
+
};
|
|
149
|
+
// Burst initial speed = radius · this (visual units). Under linear drag a spark
|
|
150
|
+
// coasts v0/drag, so k ≈ drag makes the shell open to about its nominal radius
|
|
151
|
+
// before the droop takes over. Not spec-pinned — a visual constant.
|
|
152
|
+
const BURST_SPEED_K = 3.0;
|
|
153
|
+
// §3 Quality tiers
|
|
154
|
+
export const QUALITY = {
|
|
155
|
+
high: { maxParticles: 4096, trailRate: 180 },
|
|
156
|
+
mid: { maxParticles: 2560, trailRate: 120 },
|
|
157
|
+
low: { maxParticles: 1280, trailRate: 60 },
|
|
158
|
+
};
|
|
159
|
+
// §3 Shell recipes
|
|
160
|
+
export const SHELL = {
|
|
161
|
+
peony: {
|
|
162
|
+
counts: { high: 320, mid: 200, low: 120 },
|
|
163
|
+
emberFrac: 0.15,
|
|
164
|
+
smoke: 1,
|
|
165
|
+
radius: [0.1, 0.2],
|
|
166
|
+
builder: "sphere",
|
|
167
|
+
},
|
|
168
|
+
willow: {
|
|
169
|
+
counts: { high: 200, mid: 130, low: 80 },
|
|
170
|
+
emberFrac: 0.8,
|
|
171
|
+
smoke: 1,
|
|
172
|
+
radius: [0.14, 0.22],
|
|
173
|
+
builder: "willow",
|
|
174
|
+
},
|
|
175
|
+
ring: {
|
|
176
|
+
counts: { high: 180, mid: 120, low: 70 },
|
|
177
|
+
emberFrac: 0.1,
|
|
178
|
+
smoke: 0,
|
|
179
|
+
radius: [0.12, 0.2],
|
|
180
|
+
builder: "ring",
|
|
181
|
+
},
|
|
182
|
+
glyph: {
|
|
183
|
+
counts: { high: 0, mid: 0, low: 0 }, // driven by glyphPoints + garnish
|
|
184
|
+
emberFrac: 0,
|
|
185
|
+
smoke: 1,
|
|
186
|
+
radius: [0.14, 0.14],
|
|
187
|
+
builder: "glyph",
|
|
188
|
+
},
|
|
189
|
+
// Pattern shells: the figure IS the shell, so they run denser than a peony of
|
|
190
|
+
// the same radius (an outline needs particles per unit of arc, not per unit of
|
|
191
|
+
// volume) and suppress everything that would blur it.
|
|
192
|
+
heart: {
|
|
193
|
+
counts: { high: 260, mid: 170, low: 100 },
|
|
194
|
+
emberFrac: 0.05,
|
|
195
|
+
smoke: 1,
|
|
196
|
+
radius: [0.13, 0.2],
|
|
197
|
+
builder: "outline",
|
|
198
|
+
outline: heartOutline,
|
|
199
|
+
crisp: true,
|
|
200
|
+
},
|
|
201
|
+
star: {
|
|
202
|
+
counts: { high: 240, mid: 160, low: 95 },
|
|
203
|
+
emberFrac: 0.05,
|
|
204
|
+
smoke: 1,
|
|
205
|
+
radius: [0.13, 0.2],
|
|
206
|
+
builder: "outline",
|
|
207
|
+
outline: starOutline(5, 0.45),
|
|
208
|
+
crisp: true,
|
|
209
|
+
},
|
|
210
|
+
// Caller-supplied figure — see LaunchOptions.shapePoints.
|
|
211
|
+
shape: {
|
|
212
|
+
counts: { high: 260, mid: 170, low: 100 },
|
|
213
|
+
emberFrac: 0.05,
|
|
214
|
+
smoke: 1,
|
|
215
|
+
radius: [0.13, 0.2],
|
|
216
|
+
builder: "outline",
|
|
217
|
+
crisp: true,
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
// §4.4 Ambient burst radius ceiling (fraction of min-dim). Distinct from the
|
|
221
|
+
// larger "feature" band (0.24) reserved for non-ambient bursts — the ambient
|
|
222
|
+
// scheduler derives its own scale per shell to respect this at any intensity.
|
|
223
|
+
export const AMBIENT_RADIUS_MAX = 0.2;
|
|
224
|
+
// §4.2 Ambient apex zones (weights sum to 1.0)
|
|
225
|
+
export const AMBIENT_ZONES = [
|
|
226
|
+
{ rect: { x0: 0.04, y0: 0.08, x1: 0.34, y1: 0.4 }, weight: 0.28 }, // UL
|
|
227
|
+
{ rect: { x0: 0.66, y0: 0.08, x1: 0.96, y1: 0.4 }, weight: 0.28 }, // UR
|
|
228
|
+
{ rect: { x0: 0.34, y0: 0.05, x1: 0.66, y1: 0.24 }, weight: 0.18 }, // TC
|
|
229
|
+
{ rect: { x0: 0.02, y0: 0.34, x1: 0.2, y1: 0.62 }, weight: 0.13 }, // LM
|
|
230
|
+
{ rect: { x0: 0.8, y0: 0.34, x1: 0.98, y1: 0.62 }, weight: 0.13 }, // RM
|
|
231
|
+
];
|
|
232
|
+
// §4.5 Keep-clear rects (desktop / mobile) — the card area apexes must avoid.
|
|
233
|
+
export const KEEP_CLEAR_DESKTOP = { x0: 0.28, y0: 0.3, x1: 0.72, y1: 0.82 };
|
|
234
|
+
export const KEEP_CLEAR_MOBILE = { x0: 0.1, y0: 0.22, x1: 0.9, y1: 0.9 };
|
|
235
|
+
// §4.5 soft dim — brightness multiplier for stray particles that drift behind
|
|
236
|
+
// the keep-clear card. Apexes are hard-rejected there, so this only ever bites
|
|
237
|
+
// drift-ins (embers, smoke, spent sparks, rocket trails), keeping the haze
|
|
238
|
+
// behind the card under the §4.6 ceiling.
|
|
239
|
+
export const KEEP_CLEAR_DIM = 0.35;
|
|
240
|
+
// =============================================================================
|
|
241
|
+
// §2.7 — Deterministic value noise
|
|
242
|
+
// =============================================================================
|
|
243
|
+
/** Sign-stable fract; matches the WGSL `fract`. */
|
|
244
|
+
function fract(x) {
|
|
245
|
+
return x - Math.floor(x);
|
|
246
|
+
}
|
|
247
|
+
/** hash11(x) = fract(sin(x·127.1)·43758.5453) — deterministic scalar hash. */
|
|
248
|
+
export function hash11(x) {
|
|
249
|
+
return fract(Math.sin(x * 127.1) * 43758.5453);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* 1-D value noise in [0,1). Smooth-interpolated hash lattice sampled at
|
|
253
|
+
* `age·freq`, offset by `seed`. Deterministic per (seed, age, freq).
|
|
254
|
+
*/
|
|
255
|
+
export function flickerNoise(seed, ageSec, freqHz) {
|
|
256
|
+
const t = ageSec * freqHz;
|
|
257
|
+
const i = Math.floor(t);
|
|
258
|
+
const f = t - i;
|
|
259
|
+
const a = hash11(seed + i);
|
|
260
|
+
const b = hash11(seed + i + 1);
|
|
261
|
+
const u = f * f * (3 - 2 * f);
|
|
262
|
+
return a + (b - a) * u;
|
|
263
|
+
}
|
|
264
|
+
/** smoothstep with edge0 possibly > edge1 (used by the death gate). */
|
|
265
|
+
export function smoothstep(edge0, edge1, x) {
|
|
266
|
+
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
|
|
267
|
+
return t * t * (3 - 2 * t);
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Last-10%-of-life brightness gate (§2.1 / §3.6). Equals 1 up to lifeT 0.9,
|
|
271
|
+
* eases to 0 at lifeT 1.0 — so a particle's brightness reaches 0 exactly when
|
|
272
|
+
* it is removed, honoring "nothing removed at nonzero brightness".
|
|
273
|
+
*/
|
|
274
|
+
export function deathGate(lifeT) {
|
|
275
|
+
return smoothstep(1.0, 0.9, lifeT);
|
|
276
|
+
}
|
|
277
|
+
// =============================================================================
|
|
278
|
+
// Color: sRGB ↔ linear ↔ oklab, hue rotation, life ramps
|
|
279
|
+
// =============================================================================
|
|
280
|
+
function srgbToLinear(c) {
|
|
281
|
+
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
|
282
|
+
}
|
|
283
|
+
function linearToSrgb(c) {
|
|
284
|
+
const x = Math.max(0, c);
|
|
285
|
+
return x <= 0.0031308 ? x * 12.92 : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
|
|
286
|
+
}
|
|
287
|
+
/** Parse `#rrggbb` to linear RGB. Falls back to white on malformed input. */
|
|
288
|
+
export function hexToLinearRgb(hex) {
|
|
289
|
+
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
290
|
+
if (!m)
|
|
291
|
+
return { r: 1, g: 1, b: 1 };
|
|
292
|
+
return {
|
|
293
|
+
r: srgbToLinear(parseInt(m[1], 16) / 255),
|
|
294
|
+
g: srgbToLinear(parseInt(m[2], 16) / 255),
|
|
295
|
+
b: srgbToLinear(parseInt(m[3], 16) / 255),
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
// Alloc-free scratch for the color math. The hot path (writeInstances →
|
|
299
|
+
// displayColor) reuses `*Into` variants so no oklab/RGB objects are minted per
|
|
300
|
+
// particle per frame; the exported pure functions below delegate to the same
|
|
301
|
+
// code so there is one arithmetic source of truth (determinism-preserving).
|
|
302
|
+
// Safe as module-level singletons: the sim is single-threaded and one
|
|
303
|
+
// writeInstances call fully unwinds before the next, so no two conversions are
|
|
304
|
+
// ever in flight at once.
|
|
305
|
+
const _okA = { L: 0, a: 0, b: 0 };
|
|
306
|
+
const _okB = { L: 0, a: 0, b: 0 };
|
|
307
|
+
const _okMix = { L: 0, a: 0, b: 0 };
|
|
308
|
+
const _sparkHue = { r: 0, g: 0, b: 0 };
|
|
309
|
+
/** Linear RGB → oklab, into `out` (no allocation). */
|
|
310
|
+
function linearToOklabInto(c, out) {
|
|
311
|
+
const l = 0.4122214708 * c.r + 0.5363325363 * c.g + 0.0514459929 * c.b;
|
|
312
|
+
const m = 0.2119034982 * c.r + 0.6806995451 * c.g + 0.1073969566 * c.b;
|
|
313
|
+
const s = 0.0883024619 * c.r + 0.2817188376 * c.g + 0.6299787005 * c.b;
|
|
314
|
+
const l_ = Math.cbrt(l);
|
|
315
|
+
const m_ = Math.cbrt(m);
|
|
316
|
+
const s_ = Math.cbrt(s);
|
|
317
|
+
out.L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
|
|
318
|
+
out.a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
|
|
319
|
+
out.b = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
|
|
320
|
+
}
|
|
321
|
+
/** oklab → linear RGB, into `out` (no allocation). */
|
|
322
|
+
function oklabToLinearInto(o, out) {
|
|
323
|
+
const l_ = o.L + 0.3963377774 * o.a + 0.2158037573 * o.b;
|
|
324
|
+
const m_ = o.L - 0.1055613458 * o.a - 0.0638541728 * o.b;
|
|
325
|
+
const s_ = o.L - 0.0894841775 * o.a - 1.291485548 * o.b;
|
|
326
|
+
const l = l_ * l_ * l_;
|
|
327
|
+
const m = m_ * m_ * m_;
|
|
328
|
+
const s = s_ * s_ * s_;
|
|
329
|
+
out.r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
330
|
+
out.g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
331
|
+
out.b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
|
332
|
+
}
|
|
333
|
+
/** hueRotate into `out` (no allocation). `out` may not alias `c`. */
|
|
334
|
+
function hueRotateInto(c, deg, out) {
|
|
335
|
+
linearToOklabInto(c, _okA);
|
|
336
|
+
const chroma = Math.hypot(_okA.a, _okA.b);
|
|
337
|
+
const h = Math.atan2(_okA.b, _okA.a) + (deg * Math.PI) / 180;
|
|
338
|
+
_okMix.L = _okA.L;
|
|
339
|
+
_okMix.a = chroma * Math.cos(h);
|
|
340
|
+
_okMix.b = chroma * Math.sin(h);
|
|
341
|
+
oklabToLinearInto(_okMix, out);
|
|
342
|
+
}
|
|
343
|
+
/** mixOklab into `out` (no allocation). `out` may not alias `a` or `b`. */
|
|
344
|
+
function mixOklabInto(a, b, t, out) {
|
|
345
|
+
linearToOklabInto(a, _okA);
|
|
346
|
+
linearToOklabInto(b, _okB);
|
|
347
|
+
_okMix.L = _okA.L + (_okB.L - _okA.L) * t;
|
|
348
|
+
_okMix.a = _okA.a + (_okB.a - _okA.a) * t;
|
|
349
|
+
_okMix.b = _okA.b + (_okB.b - _okA.b) * t;
|
|
350
|
+
oklabToLinearInto(_okMix, out);
|
|
351
|
+
}
|
|
352
|
+
/** Linear RGB → oklab. */
|
|
353
|
+
export function linearToOklab(c) {
|
|
354
|
+
const out = { L: 0, a: 0, b: 0 };
|
|
355
|
+
linearToOklabInto(c, out);
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
/** oklab → linear RGB (may return negative channels for out-of-gamut hues). */
|
|
359
|
+
export function oklabToLinear(o) {
|
|
360
|
+
const out = { r: 0, g: 0, b: 0 };
|
|
361
|
+
oklabToLinearInto(o, out);
|
|
362
|
+
return out;
|
|
363
|
+
}
|
|
364
|
+
/** Rotate a linear-RGB color's oklab hue by `deg` degrees (chroma preserved). */
|
|
365
|
+
export function hueRotate(c, deg) {
|
|
366
|
+
const out = { r: 0, g: 0, b: 0 };
|
|
367
|
+
hueRotateInto(c, deg, out);
|
|
368
|
+
return out;
|
|
369
|
+
}
|
|
370
|
+
/** Scale a color's oklab chroma by `k` (desaturate when k<1). */
|
|
371
|
+
function scaleChroma(c, k) {
|
|
372
|
+
const o = linearToOklab(c);
|
|
373
|
+
return oklabToLinear({ L: o.L, a: o.a * k, b: o.b * k });
|
|
374
|
+
}
|
|
375
|
+
/** Perceptual mix of two linear-RGB colors in oklab. */
|
|
376
|
+
export function mixOklab(a, b, t) {
|
|
377
|
+
const out = { r: 0, g: 0, b: 0 };
|
|
378
|
+
mixOklabInto(a, b, t, out);
|
|
379
|
+
return out;
|
|
380
|
+
}
|
|
381
|
+
/** sparkColorAtLife into `out` (no allocation). `out` may not alias `shellHue`. */
|
|
382
|
+
function sparkColorAtLifeInto(shellHue, lifeT, whiteHold, seed, out) {
|
|
383
|
+
hueRotateInto(shellHue, (hash11(seed) * 2 - 1) * SPARK_JITTER_DEG, _sparkHue);
|
|
384
|
+
if (lifeT <= whiteHold) {
|
|
385
|
+
out.r = MAGNESIUM.r;
|
|
386
|
+
out.g = MAGNESIUM.g;
|
|
387
|
+
out.b = MAGNESIUM.b;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
// The magnesium flash reads as the detonation, not as the shell: it has to
|
|
391
|
+
// hand over to the shell hue early (by ~a third of the life) or a burst is
|
|
392
|
+
// a white ball for most of the time anyone is looking at it.
|
|
393
|
+
if (lifeT < HUE_ARRIVES_AT) {
|
|
394
|
+
const t = (lifeT - whiteHold) / (HUE_ARRIVES_AT - whiteHold);
|
|
395
|
+
mixOklabInto(MAGNESIUM, _sparkHue, Math.min(1, t), out);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (lifeT < COOL_START) {
|
|
399
|
+
out.r = _sparkHue.r;
|
|
400
|
+
out.g = _sparkHue.g;
|
|
401
|
+
out.b = _sparkHue.b;
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
const t = (lifeT - COOL_START) / (1 - COOL_START);
|
|
405
|
+
mixOklabInto(_sparkHue, COOL_END, Math.min(1, t), out);
|
|
406
|
+
}
|
|
407
|
+
// Resolved palette constants (linear).
|
|
408
|
+
export const PALETTE = PALETTE_HEX.map(hexToLinearRgb);
|
|
409
|
+
const COOL_END = hexToLinearRgb(COOL_END_HEX);
|
|
410
|
+
const MAGNESIUM = hexToLinearRgb(MAGNESIUM_HEX);
|
|
411
|
+
const COMET_HEAD = hexToLinearRgb(COMET_HEAD_HEX);
|
|
412
|
+
const SMOKE_COLOR = hexToLinearRgb(SMOKE_HEX);
|
|
413
|
+
// How far the ascent trail is pulled from the comet head toward the shell hue.
|
|
414
|
+
// The blend is done once per launch (stored on the spec) so emitTrail never
|
|
415
|
+
// re-mixes (3 oklab allocations) per emitted spark, and a branded palette gets
|
|
416
|
+
// its own trail instead of the built-in cyan.
|
|
417
|
+
export const COMET_TRAIL_MIX = 0.25;
|
|
418
|
+
// Fallback tint for a trail whose launch spec is already gone (the rocket
|
|
419
|
+
// outlived its detonation record) — the built-in palette's cool end.
|
|
420
|
+
const COMET_TRAIL_HUE = mixOklab(COMET_HEAD, PALETTE[0], COMET_TRAIL_MIX);
|
|
421
|
+
/** Rise height reached by an up-velocity `vu0` under gravity `g` and drag `k`. */
|
|
422
|
+
function riseUnderDrag(vu0, g, k) {
|
|
423
|
+
return vu0 / k - (g / (k * k)) * Math.log(1 + (k * vu0) / g);
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Solve a rocket's launch velocity (§4). With `flightMs` the timing wins
|
|
427
|
+
* (vy = 0 exactly at flight, apex.y only targets x); without it the flight is
|
|
428
|
+
* derived so the trajectory peaks at apex.y.
|
|
429
|
+
*
|
|
430
|
+
* `medium` makes the solve match the integrator: with drag `k` the vertical
|
|
431
|
+
* motion is `vu(t) = (vu0 + g/k)·e^(−kt) − g/k` (apex where that hits 0) and
|
|
432
|
+
* the horizontal one integrates to `(vx0 − ax/k)(1 − e^(−kt))/k + (ax/k)·t`,
|
|
433
|
+
* divided by the aspect. Without it both collapse to the undamped forms.
|
|
434
|
+
*/
|
|
435
|
+
export function solveLaunch(from, apex, g, flightMs, medium) {
|
|
436
|
+
const k = medium?.drag ?? 0;
|
|
437
|
+
const aspect = medium?.aspect ?? 1;
|
|
438
|
+
const ax = medium?.windX ?? 0;
|
|
439
|
+
// Wind on y is a constant accel, so it just shifts effective gravity.
|
|
440
|
+
const gEff = Math.max(1e-4, g + (medium?.windY ?? 0));
|
|
441
|
+
const dy = Math.max(0, from.y - apex.y);
|
|
442
|
+
const dx = (apex.x - from.x) * aspect;
|
|
443
|
+
if (k <= 0) {
|
|
444
|
+
const fs = flightMs !== undefined ? flightMs / 1000 : Math.sqrt((2 * dy) / gEff);
|
|
445
|
+
const safeFs = fs > 1e-4 ? fs : 1e-4;
|
|
446
|
+
// Δx = vx0·t + ½·ax·t² → vx0 = Δx/t − ½·ax·t.
|
|
447
|
+
return {
|
|
448
|
+
v0: { x: dx / safeFs - 0.5 * ax * safeFs, y: -gEff * fs },
|
|
449
|
+
flightMs: fs * 1000,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
let vu0;
|
|
453
|
+
let fs;
|
|
454
|
+
if (flightMs !== undefined) {
|
|
455
|
+
fs = flightMs / 1000;
|
|
456
|
+
// vu(t) = 0 at t = fs ⇒ vu0 = (g/k)·(e^(k·fs) − 1).
|
|
457
|
+
vu0 = (gEff / k) * (Math.exp(k * Math.max(0, fs)) - 1);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
// Invert rise(vu0) = dy by Newton (monotonic; rise' = vu0/(g + k·vu0)),
|
|
461
|
+
// seeded with the undamped answer — converges in a handful of steps.
|
|
462
|
+
vu0 = Math.sqrt(2 * gEff * dy);
|
|
463
|
+
for (let i = 0; i < 12; i++) {
|
|
464
|
+
const err = riseUnderDrag(vu0, gEff, k) - dy;
|
|
465
|
+
const slope = vu0 / (gEff + k * vu0);
|
|
466
|
+
if (Math.abs(err) < 1e-9 || slope <= 0)
|
|
467
|
+
break;
|
|
468
|
+
vu0 = Math.max(0, vu0 - err / slope);
|
|
469
|
+
}
|
|
470
|
+
fs = (1 / k) * Math.log(1 + (k * vu0) / gEff);
|
|
471
|
+
}
|
|
472
|
+
const safeFs = fs > 1e-4 ? fs : 1e-4;
|
|
473
|
+
// Δx = (ax/k)·t + (vx0 − ax/k)·(1 − e^(−k·t))/k → solve for vx0.
|
|
474
|
+
const decay = 1 - Math.exp(-k * safeFs);
|
|
475
|
+
const drift = ax / k;
|
|
476
|
+
const vx0 = drift + ((dx - drift * safeFs) * k) / decay;
|
|
477
|
+
return { v0: { x: vx0, y: -vu0 }, flightMs: fs * 1000 };
|
|
478
|
+
}
|
|
479
|
+
/** Gravity that makes a launch from `from` peak at `apex.y` in `flightSec`. */
|
|
480
|
+
export function solveGravity(from, apex, flightSec) {
|
|
481
|
+
return (2 * (from.y - apex.y)) / (flightSec * flightSec);
|
|
482
|
+
}
|
|
483
|
+
/** Fibonacci-sphere burst (peony). `dir` is the in-plane projection, `z` depth. */
|
|
484
|
+
export function sphereBurst(n, seed) {
|
|
485
|
+
const out = [];
|
|
486
|
+
const golden = Math.PI * (3 - Math.sqrt(5));
|
|
487
|
+
const phi0 = hash11(seed) * Math.PI * 2;
|
|
488
|
+
for (let i = 0; i < n; i++) {
|
|
489
|
+
const z = 1 - (2 * (i + 0.5)) / n;
|
|
490
|
+
const r = Math.sqrt(Math.max(0, 1 - z * z));
|
|
491
|
+
const phi = phi0 + i * golden;
|
|
492
|
+
out.push({ dir: { x: r * Math.cos(phi), y: r * Math.sin(phi) }, z });
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
/** Even ring of unit in-plane directions with a slight per-seed tilt (ring shell). */
|
|
497
|
+
export function ringBurst(n, seed) {
|
|
498
|
+
const out = [];
|
|
499
|
+
const a0 = hash11(seed) * Math.PI * 2;
|
|
500
|
+
const tilt = 0.03 * (hash11(seed + 1) * 2 - 1);
|
|
501
|
+
for (let i = 0; i < n; i++) {
|
|
502
|
+
const a = a0 + (i / n) * Math.PI * 2;
|
|
503
|
+
out.push({ dir: { x: Math.cos(a), y: Math.sin(a) }, z: tilt * Math.cos(a) });
|
|
504
|
+
}
|
|
505
|
+
return out;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Classic heart curve, normalized into the unit circle and y-up.
|
|
509
|
+
* `x = 16sin³t`, `y = 13cos t − 5cos 2t − 2cos 3t − cos 4t` — the 17 divisor is
|
|
510
|
+
* the curve's own extent, so the figure fills the shell radius without clipping.
|
|
511
|
+
*/
|
|
512
|
+
export function heartOutline(t) {
|
|
513
|
+
const a = t * Math.PI * 2;
|
|
514
|
+
const s = Math.sin(a);
|
|
515
|
+
return {
|
|
516
|
+
x: (16 * s * s * s) / 17,
|
|
517
|
+
y: (13 * Math.cos(a) - 5 * Math.cos(2 * a) - 2 * Math.cos(3 * a) - Math.cos(4 * a)) / 17,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* A `points`-pointed star walked along its edges (not just its vertices), so a
|
|
522
|
+
* sparse particle count still draws the figure rather than a scatter of tips.
|
|
523
|
+
* `inner` is the valley radius as a fraction of the tip radius.
|
|
524
|
+
*/
|
|
525
|
+
export function starOutline(points = 5, inner = 0.45) {
|
|
526
|
+
const verts = points * 2;
|
|
527
|
+
return (t) => {
|
|
528
|
+
const u = ((t % 1) + 1) % 1;
|
|
529
|
+
const scaled = u * verts;
|
|
530
|
+
const i = Math.floor(scaled);
|
|
531
|
+
const f = scaled - i;
|
|
532
|
+
const radiusAt = (k) => (k % 2 === 0 ? 1 : inner);
|
|
533
|
+
// Vertices start at the top (−π/2 in y-up math) so a star sits point-up.
|
|
534
|
+
const angleAt = (k) => (k / verts) * Math.PI * 2 + Math.PI / 2;
|
|
535
|
+
const a0 = angleAt(i);
|
|
536
|
+
const a1 = angleAt(i + 1);
|
|
537
|
+
const r0 = radiusAt(i);
|
|
538
|
+
const r1 = radiusAt(i + 1);
|
|
539
|
+
const x0 = Math.cos(a0) * r0;
|
|
540
|
+
const y0 = Math.sin(a0) * r0;
|
|
541
|
+
const x1 = Math.cos(a1) * r1;
|
|
542
|
+
const y1 = Math.sin(a1) * r1;
|
|
543
|
+
return { x: lerp(x0, x1, f), y: lerp(y0, y1, f) };
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Cut a burst from a closed unit figure (§3.7). Each direction keeps the
|
|
548
|
+
* outline point's own magnitude, so the spawn speed is proportional to its
|
|
549
|
+
* distance from the centre — under linear drag every spark coasts `v0/drag`,
|
|
550
|
+
* which is what redraws the figure in the sky at `radius` scale.
|
|
551
|
+
*
|
|
552
|
+
* Points are y-up going in and y-down coming out, matching the sim.
|
|
553
|
+
*/
|
|
554
|
+
export function outlineBurst(n, seed, outline, jitter = 0.06) {
|
|
555
|
+
const out = [];
|
|
556
|
+
const phase = hash11(seed) * 0.5;
|
|
557
|
+
for (let i = 0; i < n; i++) {
|
|
558
|
+
// Half-step stagger keeps consecutive shells from sampling identically.
|
|
559
|
+
const t = (i + phase) / n;
|
|
560
|
+
const p = outline(t);
|
|
561
|
+
// Radial jitter only — lateral jitter would fray the outline.
|
|
562
|
+
const j = 1 + (hash11(seed + i * 1.7) * 2 - 1) * jitter;
|
|
563
|
+
out.push({
|
|
564
|
+
dir: { x: p.x * j, y: -p.y * j },
|
|
565
|
+
// A figure is a plane facing the viewer: keep z shallow so the burst
|
|
566
|
+
// reads flat rather than as a sphere wearing the outline.
|
|
567
|
+
z: (hash11(seed + i + 401) * 2 - 1) * 0.12,
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
return out;
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Turn caller points into an {@link Outline}: normalized to the unit circle
|
|
574
|
+
* about their own centroid and walked as a closed polygon, so both a sampled
|
|
575
|
+
* curve and a hand-written polygon draw correctly.
|
|
576
|
+
*/
|
|
577
|
+
export function polygonOutline(points) {
|
|
578
|
+
if (!points.length)
|
|
579
|
+
return () => ({ x: 0, y: 0 });
|
|
580
|
+
// Centre on the bounding box, not the vertex mean: a mean moves when the
|
|
581
|
+
// caller inserts a vertex mid-edge, which would silently re-aim the figure
|
|
582
|
+
// around the break point even though the drawn shape is identical.
|
|
583
|
+
let minX = Infinity;
|
|
584
|
+
let maxX = -Infinity;
|
|
585
|
+
let minY = Infinity;
|
|
586
|
+
let maxY = -Infinity;
|
|
587
|
+
for (const p of points) {
|
|
588
|
+
if (p.x < minX)
|
|
589
|
+
minX = p.x;
|
|
590
|
+
if (p.x > maxX)
|
|
591
|
+
maxX = p.x;
|
|
592
|
+
if (p.y < minY)
|
|
593
|
+
minY = p.y;
|
|
594
|
+
if (p.y > maxY)
|
|
595
|
+
maxY = p.y;
|
|
596
|
+
}
|
|
597
|
+
const cx = (minX + maxX) / 2;
|
|
598
|
+
const cy = (minY + maxY) / 2;
|
|
599
|
+
let max = 0;
|
|
600
|
+
const centered = points.map((p) => {
|
|
601
|
+
const v = { x: p.x - cx, y: p.y - cy };
|
|
602
|
+
max = Math.max(max, Math.hypot(v.x, v.y));
|
|
603
|
+
return v;
|
|
604
|
+
});
|
|
605
|
+
const scale = max > 1e-6 ? 1 / max : 1;
|
|
606
|
+
const n = centered.length;
|
|
607
|
+
// Walk by PERIMETER, not by vertex index: `t → segment t·n` hands every edge
|
|
608
|
+
// the same share of the sparks, so a short edge comes out as dense as a long
|
|
609
|
+
// one and inserting a vertex mid-edge changes the figure's density. Cumulative
|
|
610
|
+
// edge lengths spread the particles evenly around the outline instead.
|
|
611
|
+
const cumulative = new Array(n + 1);
|
|
612
|
+
cumulative[0] = 0;
|
|
613
|
+
for (let i = 0; i < n; i++) {
|
|
614
|
+
const a = centered[i];
|
|
615
|
+
const b = centered[(i + 1) % n];
|
|
616
|
+
cumulative[i + 1] = cumulative[i] + Math.hypot(b.x - a.x, b.y - a.y);
|
|
617
|
+
}
|
|
618
|
+
const perimeter = cumulative[n];
|
|
619
|
+
return (t) => {
|
|
620
|
+
const u = ((t % 1) + 1) % 1;
|
|
621
|
+
// Degenerate outline (all points equal): every sample is the same point.
|
|
622
|
+
if (perimeter <= 1e-9)
|
|
623
|
+
return { x: centered[0].x * scale, y: centered[0].y * scale };
|
|
624
|
+
const target = u * perimeter;
|
|
625
|
+
// Linear scan: outlines are a handful of points and this runs once per
|
|
626
|
+
// spawned spark, not per frame.
|
|
627
|
+
let i = 0;
|
|
628
|
+
while (i < n - 1 && cumulative[i + 1] <= target)
|
|
629
|
+
i++;
|
|
630
|
+
const segLen = cumulative[i + 1] - cumulative[i];
|
|
631
|
+
const f = segLen > 1e-9 ? (target - cumulative[i]) / segLen : 0;
|
|
632
|
+
const a = centered[i];
|
|
633
|
+
const b = centered[(i + 1) % n];
|
|
634
|
+
return { x: lerp(a.x, b.x, f) * scale, y: lerp(a.y, b.y, f) * scale };
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
/** Upward-biased, vertically compressed sphere (willow droop before gravity). */
|
|
638
|
+
export function willowBurst(n, seed) {
|
|
639
|
+
return sphereBurst(n, seed).map((d) => ({
|
|
640
|
+
dir: { x: d.dir.x, y: d.dir.y * 0.55 - 0.15 },
|
|
641
|
+
z: d.z * 0.7,
|
|
642
|
+
}));
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* One semi-implicit Euler step of a damped spring toward `target`.
|
|
646
|
+
* a = −ω²(p−target) − 2ζω·v (§5.2). Stable for the seeker's ω=6, ζ=0.9 at
|
|
647
|
+
* dt up to 33 ms.
|
|
648
|
+
*/
|
|
649
|
+
export function springStep(pos, vel, target, dt, omega, zeta) {
|
|
650
|
+
const a = -omega * omega * (pos - target) - 2 * zeta * omega * vel;
|
|
651
|
+
const v = vel + a * dt;
|
|
652
|
+
return { pos: pos + v * dt, vel: v };
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Ping-pong index over 0..3 (§1). Returns the current index, then advances,
|
|
656
|
+
* reflecting at the ends: 0,1,2,3,2,1,0,1,…
|
|
657
|
+
*/
|
|
658
|
+
export function nextShellHue(state) {
|
|
659
|
+
const cur = state.i;
|
|
660
|
+
let ni = state.i + state.dir;
|
|
661
|
+
if (ni > 3) {
|
|
662
|
+
state.dir = -1;
|
|
663
|
+
ni = state.i - 1;
|
|
664
|
+
}
|
|
665
|
+
else if (ni < 0) {
|
|
666
|
+
state.dir = 1;
|
|
667
|
+
ni = state.i + 1;
|
|
668
|
+
}
|
|
669
|
+
state.i = ni;
|
|
670
|
+
return cur;
|
|
671
|
+
}
|
|
672
|
+
/** Palette color at a ping-pong index, jittered ±`jitterDeg` in oklab hue. */
|
|
673
|
+
export function shellHueColor(hueIndex, palette, jitterDeg, rng) {
|
|
674
|
+
const base = palette[((hueIndex % palette.length) + palette.length) % palette.length];
|
|
675
|
+
const deg = (rng() * 2 - 1) * jitterDeg;
|
|
676
|
+
return hueRotate(base, deg);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Spark color at life fraction (§1.3 / §3.1): pure white → hue crossfade →
|
|
680
|
+
* full hue → cool warm-white. `whiteHold` is the life fraction the white core
|
|
681
|
+
* holds before crossfading; `seed` adds a small deterministic hue jitter.
|
|
682
|
+
*/
|
|
683
|
+
export function sparkColorAtLife(shellHue, lifeT, whiteHold, seed) {
|
|
684
|
+
const out = { r: 0, g: 0, b: 0 };
|
|
685
|
+
sparkColorAtLifeInto(shellHue, lifeT, whiteHold, seed, out);
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Poisson (exponential) inter-arrival time in ms, clamped to [min,max]. The
|
|
690
|
+
* clamp (rather than truncation) keeps the sampled mean near `meanMs`.
|
|
691
|
+
*/
|
|
692
|
+
export function poissonIntervalMs(meanMs, rng, clampMin, clampMax) {
|
|
693
|
+
const u = rng();
|
|
694
|
+
const x = -meanMs * Math.log(1 - Math.min(0.999999, u));
|
|
695
|
+
return Math.min(clampMax, Math.max(clampMin, x));
|
|
696
|
+
}
|
|
697
|
+
/** Margin the keep-clear rect is grown by before rejecting an apex (§6). */
|
|
698
|
+
export const KEEP_CLEAR_MARGIN = 0.02;
|
|
699
|
+
/**
|
|
700
|
+
* Whether `(x, y)` falls inside `rect` grown by `margin`. Exported so callers
|
|
701
|
+
* that derive an apex from a sampled one (e.g. a mirrored double) can re-run
|
|
702
|
+
* the same rejection test rather than assume the transform preserved it.
|
|
703
|
+
*/
|
|
704
|
+
export function rectExpandedContains(rect, x, y, margin = KEEP_CLEAR_MARGIN) {
|
|
705
|
+
return (x >= rect.x0 - margin && x <= rect.x1 + margin && y >= rect.y0 - margin && y <= rect.y1 + margin);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Sample an apex from weighted zones (§6). Rejects points inside the expanded
|
|
709
|
+
* keep-clear rect (resampling up to `maxTries`), edge-biases x by depth, and
|
|
710
|
+
* falls back to the zone farthest from the keep-clear center.
|
|
711
|
+
*/
|
|
712
|
+
export function sampleZone(zones, rng, keepClear, maxTries = 8) {
|
|
713
|
+
const total = zones.reduce((s, z) => s + z.weight, 0);
|
|
714
|
+
const pickZone = () => {
|
|
715
|
+
let t = rng() * total;
|
|
716
|
+
for (const z of zones) {
|
|
717
|
+
t -= z.weight;
|
|
718
|
+
if (t <= 0)
|
|
719
|
+
return z;
|
|
720
|
+
}
|
|
721
|
+
return zones[zones.length - 1];
|
|
722
|
+
};
|
|
723
|
+
const KEEP_MARGIN = KEEP_CLEAR_MARGIN;
|
|
724
|
+
for (let attempt = 0; attempt < maxTries; attempt++) {
|
|
725
|
+
const z = pickZone();
|
|
726
|
+
let x = z.rect.x0 + rng() * (z.rect.x1 - z.rect.x0);
|
|
727
|
+
const y = z.rect.y0 + rng() * (z.rect.y1 - z.rect.y0);
|
|
728
|
+
const depth = rng();
|
|
729
|
+
// Edge-bias: nudge x toward the zone's nearer outer edge as depth rises,
|
|
730
|
+
// staying within the zone rect so zone weights are preserved.
|
|
731
|
+
const mid = (z.rect.x0 + z.rect.x1) / 2;
|
|
732
|
+
const edge = x < mid ? z.rect.x0 : z.rect.x1;
|
|
733
|
+
x = x + (edge - x) * depth * 0.4;
|
|
734
|
+
if (keepClear && rectExpandedContains(keepClear, x, y, KEEP_MARGIN))
|
|
735
|
+
continue;
|
|
736
|
+
return { apex: { x, y }, depth };
|
|
737
|
+
}
|
|
738
|
+
// Fallback: the zone whose center is farthest from the keep-clear center.
|
|
739
|
+
const cc = keepClear
|
|
740
|
+
? { x: (keepClear.x0 + keepClear.x1) / 2, y: (keepClear.y0 + keepClear.y1) / 2 }
|
|
741
|
+
: { x: 0.5, y: 0.5 };
|
|
742
|
+
let best = zones[0];
|
|
743
|
+
let bestD = -1;
|
|
744
|
+
for (const z of zones) {
|
|
745
|
+
const zx = (z.rect.x0 + z.rect.x1) / 2;
|
|
746
|
+
const zy = (z.rect.y0 + z.rect.y1) / 2;
|
|
747
|
+
const d = Math.hypot(zx - cc.x, zy - cc.y);
|
|
748
|
+
if (d > bestD) {
|
|
749
|
+
bestD = d;
|
|
750
|
+
best = z;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
apex: { x: (best.rect.x0 + best.rect.x1) / 2, y: (best.rect.y0 + best.rect.y1) / 2 },
|
|
755
|
+
depth: rng(),
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* SDR soft-knee tone map (§7.2): identity below `knee`, then an asymptotic
|
|
760
|
+
* approach to 1.0 so over-white energy compresses instead of hard-clipping.
|
|
761
|
+
* Shared by the renderers (WGSL / GLSL inline the same formula) and unit-tested
|
|
762
|
+
* here so the ladder has one canonical definition.
|
|
763
|
+
*/
|
|
764
|
+
export function softKnee(x, knee) {
|
|
765
|
+
if (x <= knee)
|
|
766
|
+
return x;
|
|
767
|
+
const over = x - knee;
|
|
768
|
+
const span = Math.max(1 - knee, 1e-3);
|
|
769
|
+
return knee + span * (1 - Math.exp(-over / span));
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Resolve the effective quality tier for `quality="auto"` (or pass a fixed tier
|
|
773
|
+
* through unchanged). Extracted from the component so the ladder is testable:
|
|
774
|
+
*
|
|
775
|
+
* - an explicit tier ("high"/"mid"/"low") always wins;
|
|
776
|
+
* - a small viewport (min-dim < 480 px) → "low" (covers phones);
|
|
777
|
+
* - a low-power device on the WebGL2 fallback → "low" (weak GPU + modest
|
|
778
|
+
* CPU/RAM; only ever downgrades the fallback path, never WebGPU);
|
|
779
|
+
* - "webgpu-hdr" → "high"; "webgpu-sdr" → "high" on a retina DPR, else "mid";
|
|
780
|
+
* - the WebGL2 levels ("webgl-p3"/"webgl-sdr") → "mid" (the fallback renderer is
|
|
781
|
+
* fill-rate bound; "none" is unreachable here — no sim is built without an
|
|
782
|
+
* engine — but maps to "mid" for completeness).
|
|
783
|
+
*/
|
|
784
|
+
export function resolveQualityTier(requested, level, dpr, minDim, lowPower = false) {
|
|
785
|
+
if (requested !== "auto")
|
|
786
|
+
return requested;
|
|
787
|
+
if (minDim > 0 && minDim < 480)
|
|
788
|
+
return "low";
|
|
789
|
+
const isWebGl = level === "webgl-p3" || level === "webgl-sdr";
|
|
790
|
+
if (lowPower && isWebGl)
|
|
791
|
+
return "low";
|
|
792
|
+
if (level === "webgpu-hdr")
|
|
793
|
+
return "high";
|
|
794
|
+
if (level === "webgpu-sdr")
|
|
795
|
+
return dpr >= 2 ? "high" : "mid";
|
|
796
|
+
return "mid";
|
|
797
|
+
}
|
|
798
|
+
/** The fixed ladder. Index 0 is full quality; each step sheds more. */
|
|
799
|
+
export const ADAPTIVE_LADDER = [
|
|
800
|
+
{ renderScale: 1, spawnScale: 1 }, // 0 — full
|
|
801
|
+
{ renderScale: 0.75, spawnScale: 1 }, // 1 — GPU: 0.75× accum
|
|
802
|
+
{ renderScale: 0.5, spawnScale: 1 }, // 2 — GPU: 0.5× accum
|
|
803
|
+
{ renderScale: 0.5, spawnScale: 0.66 }, // 3 — CPU: 0.66× spawn
|
|
804
|
+
{ renderScale: 0.5, spawnScale: 0.4 }, // 4 — CPU: 0.4× spawn (floor)
|
|
805
|
+
];
|
|
806
|
+
/** Defaults: EMA α=0.1, downgrade when the average frame holds > 24 ms (~<42 fps) for ~60 frames (~1 s). */
|
|
807
|
+
export const ADAPTIVE_DEFAULTS = {
|
|
808
|
+
alpha: 0.1,
|
|
809
|
+
thresholdMs: 24,
|
|
810
|
+
sustainFrames: 60,
|
|
811
|
+
};
|
|
812
|
+
export function createAdaptiveState() {
|
|
813
|
+
return { emaMs: 0, overCount: 0, step: 0, started: false };
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Fold one ACTIVE frame's wall time into the adaptive controller. The caller
|
|
817
|
+
* must invoke this only for frames that actually did work (particles present,
|
|
818
|
+
* not reduced-motion-idle, not `document.hidden`) so paused/quiet frames don't
|
|
819
|
+
* skew the average. Mutates `state` (EMA, counter, step) and returns `true` on
|
|
820
|
+
* the frame a downgrade fires — the caller then applies `adaptiveLevel(state)`.
|
|
821
|
+
* Session-sticky: never upgrades; a no-op once at the ladder floor.
|
|
822
|
+
*/
|
|
823
|
+
export function adaptiveDowngradeStep(state, frameMs, tuning = ADAPTIVE_DEFAULTS) {
|
|
824
|
+
// Seed on the first active frame so the EMA starts at the real cost.
|
|
825
|
+
if (!state.started) {
|
|
826
|
+
state.emaMs = frameMs;
|
|
827
|
+
state.started = true;
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
state.emaMs += tuning.alpha * (frameMs - state.emaMs);
|
|
831
|
+
}
|
|
832
|
+
// At the floor there is nothing left to shed.
|
|
833
|
+
if (state.step >= ADAPTIVE_LADDER.length - 1) {
|
|
834
|
+
state.overCount = 0;
|
|
835
|
+
return false;
|
|
836
|
+
}
|
|
837
|
+
if (state.emaMs > tuning.thresholdMs) {
|
|
838
|
+
state.overCount++;
|
|
839
|
+
if (state.overCount >= tuning.sustainFrames) {
|
|
840
|
+
state.step++;
|
|
841
|
+
state.overCount = 0; // give the new level time to take effect
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
state.overCount = 0;
|
|
847
|
+
}
|
|
848
|
+
return false;
|
|
849
|
+
}
|
|
850
|
+
/** The current ladder rung for `state.step`. */
|
|
851
|
+
export function adaptiveLevel(state) {
|
|
852
|
+
return ADAPTIVE_LADDER[Math.min(state.step, ADAPTIVE_LADDER.length - 1)];
|
|
853
|
+
}
|
|
854
|
+
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
|
855
|
+
const lerp = (a, b, t) => a + (b - a) * t;
|
|
856
|
+
const rand = (rng, lo, hi) => lo + rng() * (hi - lo);
|
|
857
|
+
export function createSim(opts) {
|
|
858
|
+
const tier = QUALITY[opts.quality];
|
|
859
|
+
const capacity = tier.maxParticles;
|
|
860
|
+
const palette = opts.palette ?? PALETTE;
|
|
861
|
+
const rng = opts.rng ?? Math.random;
|
|
862
|
+
const pool = new Float32Array(capacity * STRIDE);
|
|
863
|
+
let count = 0;
|
|
864
|
+
let droppedSpawns = 0;
|
|
865
|
+
let aspect = opts.aspect;
|
|
866
|
+
// §4.5 soft-dim rect. Null until the caller sets it (the app only does so
|
|
867
|
+
// once the card is on screen), so the intro glyph is never dimmed.
|
|
868
|
+
let keepClear = null;
|
|
869
|
+
// Adaptive spawn-density lever (1 = full). Scales new burst counts only.
|
|
870
|
+
let spawnScale = 1;
|
|
871
|
+
// Session wind (seeded once). `a += w · windScale[type]` each step.
|
|
872
|
+
const sessionWind = opts.wind ?? {
|
|
873
|
+
x: (rng() < 0.5 ? -1 : 1) * rand(rng, WIND_X[0], WIND_X[1]),
|
|
874
|
+
y: (rng() < 0.5 ? -1 : 1) * rand(rng, WIND_Y[0], WIND_Y[1]),
|
|
875
|
+
};
|
|
876
|
+
// Deferred spawns: enqueued during step (detonations, flashes, trail sparks),
|
|
877
|
+
// drained at the top of the NEXT step so the pool is never grown mid-iterate.
|
|
878
|
+
let burstQueue = [];
|
|
879
|
+
let flashQueue = [];
|
|
880
|
+
let sparkQueue = [];
|
|
881
|
+
// Monotonic seed for step-time randomness that has no stored particle seed
|
|
882
|
+
// (keeps determinism: a fixed call order reproduces the same sequence).
|
|
883
|
+
let seq = 1;
|
|
884
|
+
const stepSeed = () => hash11(seq++ * 0.61803398875);
|
|
885
|
+
function appendParticle(write) {
|
|
886
|
+
if (count >= capacity) {
|
|
887
|
+
droppedSpawns++;
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
const base = count * STRIDE;
|
|
891
|
+
// Zero the slot so unset fields are deterministic.
|
|
892
|
+
for (let k = 0; k < STRIDE; k++)
|
|
893
|
+
pool[base + k] = 0;
|
|
894
|
+
// Depth dim is a multiplier, so its neutral value is 1, not 0. Set before
|
|
895
|
+
// the writer runs so depth-aware spawns can still override it.
|
|
896
|
+
pool[base + F.depthDim] = 1;
|
|
897
|
+
write(base);
|
|
898
|
+
count++;
|
|
899
|
+
return true;
|
|
900
|
+
}
|
|
901
|
+
function setAspect(a) {
|
|
902
|
+
aspect = a;
|
|
903
|
+
}
|
|
904
|
+
function setKeepClear(rect) {
|
|
905
|
+
keepClear = rect;
|
|
906
|
+
}
|
|
907
|
+
function setSpawnScale(scale) {
|
|
908
|
+
spawnScale = Number.isFinite(scale) ? Math.max(0, Math.min(1, scale)) : 1;
|
|
909
|
+
}
|
|
910
|
+
function reset() {
|
|
911
|
+
count = 0;
|
|
912
|
+
droppedSpawns = 0;
|
|
913
|
+
burstQueue = [];
|
|
914
|
+
flashQueue = [];
|
|
915
|
+
sparkQueue = [];
|
|
916
|
+
seq = 1;
|
|
917
|
+
}
|
|
918
|
+
// ---- launch (§4) --------------------------------------------------------
|
|
919
|
+
function launch(o) {
|
|
920
|
+
const shell = o.shell ?? "peony";
|
|
921
|
+
const seed = o.seed ?? Math.floor(rng() * 1e6);
|
|
922
|
+
const from = o.from ?? {
|
|
923
|
+
x: clamp(o.apex.x + (rng() * 2 - 1) * 0.04, LAUNCH_X[0], LAUNCH_X[1]),
|
|
924
|
+
y: rand(rng, LAUNCH_Y[0], LAUNCH_Y[1]),
|
|
925
|
+
};
|
|
926
|
+
// Solve against the medium the rocket is actually integrated through
|
|
927
|
+
// (drag, session wind, aspect) — a ballistic solve undershoots the apex by
|
|
928
|
+
// ~25% of the rise and misses horizontally on non-square canvases.
|
|
929
|
+
const { v0, flightMs } = solveLaunch(from, o.apex, G * GRAV_SCALE[TYPE.ROCKET], o.flightMs, {
|
|
930
|
+
drag: DRAG[TYPE.ROCKET],
|
|
931
|
+
aspect,
|
|
932
|
+
windX: sessionWind.x * WIND_SCALE[TYPE.ROCKET],
|
|
933
|
+
windY: sessionWind.y * WIND_SCALE[TYPE.ROCKET],
|
|
934
|
+
});
|
|
935
|
+
// Fuse hang 80–140 ms (intro glyph "?" uses 140 via explicit releaseAtMs path).
|
|
936
|
+
const hangMs = o.intensity === "intro" ? 140 : rand(rng, 80, 140);
|
|
937
|
+
const breakMs = flightMs + hangMs;
|
|
938
|
+
const colors = resolveColors(o.color, seed);
|
|
939
|
+
const spec = {
|
|
940
|
+
shell,
|
|
941
|
+
colors,
|
|
942
|
+
// The ascent trail is tinted toward THIS shell's hue (§4) — mixed once
|
|
943
|
+
// per launch, not per emitted spark, and never from the default palette.
|
|
944
|
+
trailHue: mixOklab(COMET_HEAD, colors[0], COMET_TRAIL_MIX),
|
|
945
|
+
scale: o.scale ?? 1,
|
|
946
|
+
depth: clamp(o.depth ?? 0, 0, 1),
|
|
947
|
+
seed,
|
|
948
|
+
intensity: o.intensity ?? "ambient",
|
|
949
|
+
glyphPoints: o.glyphPoints,
|
|
950
|
+
// Resolved here, not per particle: normalizing the caller's points is
|
|
951
|
+
// O(n) and the burst is expanded a frame later, once.
|
|
952
|
+
outline: o.shapePoints?.length ? polygonOutline(o.shapePoints) : undefined,
|
|
953
|
+
releaseAtMs: o.releaseAtMs,
|
|
954
|
+
breakMs,
|
|
955
|
+
};
|
|
956
|
+
const launchId = registerSpec(spec);
|
|
957
|
+
appendParticle((base) => {
|
|
958
|
+
pool[base + F.posX] = from.x;
|
|
959
|
+
pool[base + F.posY] = from.y;
|
|
960
|
+
pool[base + F.velX] = v0.x;
|
|
961
|
+
pool[base + F.velY] = v0.y;
|
|
962
|
+
pool[base + F.ttl] = breakMs / 1000;
|
|
963
|
+
pool[base + F.size] = 0.004;
|
|
964
|
+
pool[base + F.r] = COMET_HEAD.r;
|
|
965
|
+
pool[base + F.g] = COMET_HEAD.g;
|
|
966
|
+
pool[base + F.b] = COMET_HEAD.b;
|
|
967
|
+
pool[base + F.brightness] = B_ASCENT_HEAD;
|
|
968
|
+
pool[base + F.type] = TYPE.ROCKET;
|
|
969
|
+
pool[base + F.seed] = seed;
|
|
970
|
+
// Field reuse for rockets: targetX = launchId, targetY = trail accumulator.
|
|
971
|
+
pool[base + F.targetX] = launchId;
|
|
972
|
+
pool[base + F.targetY] = 0;
|
|
973
|
+
pool[base + F.dragScale] = 1;
|
|
974
|
+
});
|
|
975
|
+
return { flightMs, breakMs };
|
|
976
|
+
}
|
|
977
|
+
function resolveColors(color, seed) {
|
|
978
|
+
if (Array.isArray(color))
|
|
979
|
+
return color.length ? color : [PALETTE[0]];
|
|
980
|
+
if (color)
|
|
981
|
+
return [color];
|
|
982
|
+
// Omitted → ping-pong sweep seeded off the launch seed (deterministic).
|
|
983
|
+
const idx = Math.floor(hash11(seed) * palette.length) % palette.length;
|
|
984
|
+
return [hueRotate(palette[idx], (hash11(seed + 7) * 2 - 1) * SHELL_JITTER_DEG)];
|
|
985
|
+
}
|
|
986
|
+
// Active launch specs by id (freed at detonation). Tiny (≤ a few at once).
|
|
987
|
+
const specs = new Map();
|
|
988
|
+
let specId = 1;
|
|
989
|
+
function registerSpec(spec) {
|
|
990
|
+
const id = specId++;
|
|
991
|
+
specs.set(id, spec);
|
|
992
|
+
return id;
|
|
993
|
+
}
|
|
994
|
+
function spawnFlash(pos, brightness, sizeNorm, hue) {
|
|
995
|
+
flashQueue.push({ pos: { ...pos }, brightness, size: sizeNorm, hue });
|
|
996
|
+
}
|
|
997
|
+
// ---- detonation → burst expansion --------------------------------------
|
|
998
|
+
function expandBurst(req) {
|
|
999
|
+
const { origin, spec } = req;
|
|
1000
|
+
const recipe = SHELL[spec.shell];
|
|
1001
|
+
if (spec.shell === "glyph") {
|
|
1002
|
+
expandGlyph(origin, spec);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
// Spawn-density lever (adaptive downgrade): scale burst counts, floor at 1.
|
|
1006
|
+
const n = Math.max(1, Math.round(recipe.counts[opts.quality] * spawnScale));
|
|
1007
|
+
// Depth also pulls the burst in spatially, so a far shell is a smaller
|
|
1008
|
+
// bloom and not just a desaturated one at full size.
|
|
1009
|
+
const radius = rand(seededRng(spec.seed + 3), recipe.radius[0], recipe.radius[1]) *
|
|
1010
|
+
spec.scale *
|
|
1011
|
+
(1 - DEPTH_RADIUS * spec.depth);
|
|
1012
|
+
const dim = depthDim(spec.depth);
|
|
1013
|
+
const baseSpeed = radius * BURST_SPEED_K;
|
|
1014
|
+
const outline = spec.outline ?? recipe.outline;
|
|
1015
|
+
let dirs;
|
|
1016
|
+
if (recipe.builder === "outline") {
|
|
1017
|
+
// A "shape" shell launched without points has no figure to draw; fall
|
|
1018
|
+
// back to a plain sphere so the shell still breaks instead of vanishing.
|
|
1019
|
+
dirs = outline ? outlineBurst(n, spec.seed, outline) : sphereBurst(n, spec.seed);
|
|
1020
|
+
}
|
|
1021
|
+
else if (recipe.builder === "ring") {
|
|
1022
|
+
dirs = ringBurst(n, spec.seed);
|
|
1023
|
+
}
|
|
1024
|
+
else if (recipe.builder === "willow") {
|
|
1025
|
+
dirs = willowBurst(n, spec.seed);
|
|
1026
|
+
}
|
|
1027
|
+
else {
|
|
1028
|
+
dirs = sphereBurst(n, spec.seed);
|
|
1029
|
+
}
|
|
1030
|
+
for (let i = 0; i < dirs.length; i++) {
|
|
1031
|
+
const { dir, z } = dirs[i];
|
|
1032
|
+
// Break asymmetry ±12% and 5% stragglers ×1.4 speed / dragScale×0.7.
|
|
1033
|
+
// A pattern shell keeps neither: both are read as a broken figure.
|
|
1034
|
+
const asymAmount = recipe.crisp ? 0.03 : 0.12;
|
|
1035
|
+
const asym = 1 + (hash11(spec.seed + i) * 2 - 1) * asymAmount;
|
|
1036
|
+
const straggler = !recipe.crisp && hash11(spec.seed + i + 991) < 0.05;
|
|
1037
|
+
const speed = baseSpeed * asym * (straggler ? 1.4 : 1);
|
|
1038
|
+
const isEmber = hash11(spec.seed + i + 313) < recipe.emberFrac;
|
|
1039
|
+
const type = isEmber ? TYPE.EMBER : TYPE.SPARK;
|
|
1040
|
+
const crackles = !isEmber && hash11(spec.seed + i + 577) < CRACKLE_FRAC;
|
|
1041
|
+
const hue = depthAdjust(pickShellColor(spec, i), spec.depth);
|
|
1042
|
+
const ttl = isEmber
|
|
1043
|
+
? rand(seededRng(spec.seed + i + 17), 1.4, 2.4)
|
|
1044
|
+
: rand(seededRng(spec.seed + i + 17), 0.75, 1.3);
|
|
1045
|
+
const size = (isEmber
|
|
1046
|
+
? rand(seededRng(spec.seed + i + 29), 0.002, 0.003)
|
|
1047
|
+
: rand(seededRng(spec.seed + i + 29), 0.0025, 0.004)) *
|
|
1048
|
+
(1 - DEPTH_SIZE * spec.depth);
|
|
1049
|
+
appendParticle((base) => {
|
|
1050
|
+
pool[base + F.posX] = origin.x;
|
|
1051
|
+
pool[base + F.posY] = origin.y;
|
|
1052
|
+
pool[base + F.velX] = dir.x * speed;
|
|
1053
|
+
pool[base + F.velY] = dir.y * speed;
|
|
1054
|
+
pool[base + F.ttl] = ttl;
|
|
1055
|
+
pool[base + F.size] = size;
|
|
1056
|
+
pool[base + F.r] = hue.r;
|
|
1057
|
+
pool[base + F.g] = hue.g;
|
|
1058
|
+
pool[base + F.b] = hue.b;
|
|
1059
|
+
pool[base + F.brightness] = B_SPARK_FRESH;
|
|
1060
|
+
pool[base + F.type] = type;
|
|
1061
|
+
pool[base + F.seed] = spec.seed + i;
|
|
1062
|
+
// z-parallax folded into dragScale as a mild size/brightness proxy is
|
|
1063
|
+
// avoided; dragScale carries the straggler multiplier and a per-spark
|
|
1064
|
+
// spread — identical drag makes every spark fall at one speed, which
|
|
1065
|
+
// combs the debris into a parallel curtain instead of a shell.
|
|
1066
|
+
//
|
|
1067
|
+
// A pattern shell gets NO spread: a spark coasts `v0 / (drag·dragScale)`,
|
|
1068
|
+
// so varying the scale scales each spark's reach independently and the
|
|
1069
|
+
// figure never expands as one shape. The ±6% radial jitter in
|
|
1070
|
+
// `outlineBurst` is the deliberate, bounded version of that.
|
|
1071
|
+
pool[base + F.dragScale] = recipe.crisp
|
|
1072
|
+
? 1
|
|
1073
|
+
: straggler
|
|
1074
|
+
? 0.7
|
|
1075
|
+
: 0.85 + 0.45 * hash11(spec.seed + i + 233);
|
|
1076
|
+
pool[base + F.depthDim] = dim;
|
|
1077
|
+
// Field reuse for sparks: targetX flags the cracklers.
|
|
1078
|
+
pool[base + F.targetX] = crackles ? 1 : 0;
|
|
1079
|
+
// Stash burst z in targetY (unused for sparks/embers) for future
|
|
1080
|
+
// parallax; harmless if ignored by the renderer.
|
|
1081
|
+
pool[base + F.targetY] = z;
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
// Apex smoke puff(s) + one detonation flash.
|
|
1085
|
+
for (let s = 0; s < recipe.smoke; s++) {
|
|
1086
|
+
spawnSmoke(origin, spec, s);
|
|
1087
|
+
}
|
|
1088
|
+
const flashB = spec.intensity === "intro" ? B_FLASH_INTRO : B_FLASH_AMBIENT;
|
|
1089
|
+
flashQueue.push({
|
|
1090
|
+
pos: { ...origin },
|
|
1091
|
+
brightness: flashB,
|
|
1092
|
+
size: rand(seededRng(spec.seed + 5), 0.025, 0.05) * (1 - DEPTH_SIZE * spec.depth),
|
|
1093
|
+
hue: depthAdjust(spec.colors[0], spec.depth),
|
|
1094
|
+
dim,
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
function expandGlyph(origin, spec) {
|
|
1098
|
+
const points = spec.glyphPoints ?? [];
|
|
1099
|
+
const releaseDelaySec = Math.max(0.3, ((spec.releaseAtMs ?? spec.breakMs + 1500) - spec.breakMs) / 1000);
|
|
1100
|
+
const hue = depthAdjust(spec.colors[0], spec.depth);
|
|
1101
|
+
const dim = depthDim(spec.depth);
|
|
1102
|
+
for (let i = 0; i < points.length; i++) {
|
|
1103
|
+
const p = points[i];
|
|
1104
|
+
appendParticle((base) => {
|
|
1105
|
+
pool[base + F.posX] = origin.x;
|
|
1106
|
+
pool[base + F.posY] = origin.y;
|
|
1107
|
+
// Pop velocity toward target, magnitude dist/0.08 (§5.1).
|
|
1108
|
+
const dx = (p.x - origin.x) * aspect;
|
|
1109
|
+
const dy = p.y - origin.y;
|
|
1110
|
+
const d = Math.hypot(dx, dy) || 1e-4;
|
|
1111
|
+
const mag = d / 0.08;
|
|
1112
|
+
pool[base + F.velX] = (dx / d) * mag;
|
|
1113
|
+
pool[base + F.velY] = (dy / d) * mag;
|
|
1114
|
+
pool[base + F.ttl] = releaseDelaySec + 0.9; // hold window + release fade
|
|
1115
|
+
pool[base + F.size] = 0.003 * (1 - DEPTH_SIZE * spec.depth);
|
|
1116
|
+
pool[base + F.depthDim] = dim;
|
|
1117
|
+
pool[base + F.r] = hue.r;
|
|
1118
|
+
pool[base + F.g] = hue.g;
|
|
1119
|
+
pool[base + F.b] = hue.b;
|
|
1120
|
+
pool[base + F.brightness] = B_GLYPH_HOLD;
|
|
1121
|
+
pool[base + F.type] = TYPE.SEEKER;
|
|
1122
|
+
pool[base + F.seed] = spec.seed + i;
|
|
1123
|
+
pool[base + F.targetX] = p.x;
|
|
1124
|
+
pool[base + F.targetY] = p.y;
|
|
1125
|
+
// Field reuse for seekers: dragScale carries the release delay (s).
|
|
1126
|
+
pool[base + F.dragScale] = releaseDelaySec;
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
function spawnSmoke(origin, spec, s) {
|
|
1131
|
+
appendParticle((base) => {
|
|
1132
|
+
pool[base + F.posX] = origin.x + (hash11(spec.seed + s + 41) * 2 - 1) * 0.01;
|
|
1133
|
+
pool[base + F.posY] = origin.y + (hash11(spec.seed + s + 43) * 2 - 1) * 0.01;
|
|
1134
|
+
pool[base + F.velX] = (hash11(spec.seed + s + 47) * 2 - 1) * 0.02;
|
|
1135
|
+
pool[base + F.velY] = -0.02;
|
|
1136
|
+
pool[base + F.ttl] = rand(seededRng(spec.seed + s + 51), 1.5, 3.0);
|
|
1137
|
+
pool[base + F.size] = 0.01;
|
|
1138
|
+
pool[base + F.r] = SMOKE_COLOR.r;
|
|
1139
|
+
pool[base + F.g] = SMOKE_COLOR.g;
|
|
1140
|
+
pool[base + F.b] = SMOKE_COLOR.b;
|
|
1141
|
+
pool[base + F.brightness] = B_SMOKE_MIN;
|
|
1142
|
+
pool[base + F.type] = TYPE.SMOKE;
|
|
1143
|
+
pool[base + F.seed] = spec.seed + s + 61;
|
|
1144
|
+
pool[base + F.dragScale] = 1;
|
|
1145
|
+
pool[base + F.depthDim] = depthDim(spec.depth);
|
|
1146
|
+
});
|
|
1147
|
+
}
|
|
1148
|
+
function pickShellColor(spec, i) {
|
|
1149
|
+
if (spec.colors.length === 1)
|
|
1150
|
+
return spec.colors[0];
|
|
1151
|
+
// Adjacent duo: split the shell between the two hues.
|
|
1152
|
+
return hash11(spec.seed + i + 137) < 0.5 ? spec.colors[0] : spec.colors[1];
|
|
1153
|
+
}
|
|
1154
|
+
function depthAdjust(hue, depth) {
|
|
1155
|
+
if (depth <= 0)
|
|
1156
|
+
return hue;
|
|
1157
|
+
return scaleChroma(hue, 1 - DEPTH_CHROMA * depth);
|
|
1158
|
+
}
|
|
1159
|
+
/** Brightness multiplier a launch's `depth` imposes on all of its debris. */
|
|
1160
|
+
function depthDim(depth) {
|
|
1161
|
+
return depth <= 0 ? 1 : 1 - DEPTH_DIM * depth;
|
|
1162
|
+
}
|
|
1163
|
+
// A small deterministic rng seeded off an integer, for spawn-time jitter
|
|
1164
|
+
// during a detonation (which happens inside step and must stay reproducible).
|
|
1165
|
+
function seededRng(seed) {
|
|
1166
|
+
let s = seed;
|
|
1167
|
+
return () => {
|
|
1168
|
+
s += 1;
|
|
1169
|
+
return hash11(s);
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
// ---- step (§7) ----------------------------------------------------------
|
|
1173
|
+
function step(dtSeconds, wind) {
|
|
1174
|
+
const dt = Math.min(dtSeconds, DT_MAX);
|
|
1175
|
+
const w = wind ?? sessionWind;
|
|
1176
|
+
// 2. Drain the previous frame's deferred spawns.
|
|
1177
|
+
if (burstQueue.length) {
|
|
1178
|
+
const q = burstQueue;
|
|
1179
|
+
burstQueue = [];
|
|
1180
|
+
for (let bi = 0; bi < q.length; bi++)
|
|
1181
|
+
expandBurst(q[bi]);
|
|
1182
|
+
}
|
|
1183
|
+
if (flashQueue.length) {
|
|
1184
|
+
const q = flashQueue;
|
|
1185
|
+
flashQueue = [];
|
|
1186
|
+
for (let fi = 0; fi < q.length; fi++) {
|
|
1187
|
+
const f = q[fi];
|
|
1188
|
+
appendParticle((base) => {
|
|
1189
|
+
pool[base + F.posX] = f.pos.x;
|
|
1190
|
+
pool[base + F.posY] = f.pos.y;
|
|
1191
|
+
pool[base + F.ttl] = rand(seededRng(seq++ | 0), 0.06, 0.12);
|
|
1192
|
+
pool[base + F.size] = f.size;
|
|
1193
|
+
pool[base + F.r] = f.hue.r;
|
|
1194
|
+
pool[base + F.g] = f.hue.g;
|
|
1195
|
+
pool[base + F.b] = f.hue.b;
|
|
1196
|
+
pool[base + F.brightness] = f.brightness;
|
|
1197
|
+
pool[base + F.type] = TYPE.FLASH;
|
|
1198
|
+
pool[base + F.seed] = stepSeed() * 1000;
|
|
1199
|
+
pool[base + F.depthDim] = f.dim ?? 1;
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
if (sparkQueue.length) {
|
|
1204
|
+
const q = sparkQueue;
|
|
1205
|
+
sparkQueue = [];
|
|
1206
|
+
for (let si = 0; si < q.length; si++) {
|
|
1207
|
+
const sp = q[si];
|
|
1208
|
+
appendParticle((base) => {
|
|
1209
|
+
pool[base + F.posX] = sp.pos.x;
|
|
1210
|
+
pool[base + F.posY] = sp.pos.y;
|
|
1211
|
+
pool[base + F.velX] = sp.vel.x;
|
|
1212
|
+
pool[base + F.velY] = sp.vel.y;
|
|
1213
|
+
pool[base + F.ttl] = sp.ttl;
|
|
1214
|
+
pool[base + F.size] = sp.size;
|
|
1215
|
+
pool[base + F.r] = sp.hue.r;
|
|
1216
|
+
pool[base + F.g] = sp.hue.g;
|
|
1217
|
+
pool[base + F.b] = sp.hue.b;
|
|
1218
|
+
pool[base + F.brightness] = B_ASCENT_TAIL;
|
|
1219
|
+
pool[base + F.type] = TYPE.SPARK;
|
|
1220
|
+
pool[base + F.seed] = sp.seed;
|
|
1221
|
+
pool[base + F.dragScale] = 1;
|
|
1222
|
+
});
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
// 3. Per-live update with swap-remove (re-examine i after a removal). Aging
|
|
1226
|
+
// happens exactly once per particle here; type routines only integrate.
|
|
1227
|
+
let i = 0;
|
|
1228
|
+
while (i < count) {
|
|
1229
|
+
const base = i * STRIDE;
|
|
1230
|
+
const type = pool[base + F.type];
|
|
1231
|
+
if (type === TYPE.ROCKET) {
|
|
1232
|
+
emitTrail(base, dt);
|
|
1233
|
+
integrate(base, type, dt, w);
|
|
1234
|
+
// Ascent wobble: a real shell corkscrews slightly on its way up, and
|
|
1235
|
+
// the perfectly straight column the integrator draws reads as a wire.
|
|
1236
|
+
// Positional only (velocity untouched), and small enough that the
|
|
1237
|
+
// apex the launch solved for still holds.
|
|
1238
|
+
const wob = pool[base + F.seed];
|
|
1239
|
+
pool[base + F.posX] +=
|
|
1240
|
+
Math.sin(pool[base + F.age] * ROCKET_WOBBLE_HZ + hash11(wob) * 6.283) *
|
|
1241
|
+
ROCKET_WOBBLE_AMP *
|
|
1242
|
+
dt;
|
|
1243
|
+
}
|
|
1244
|
+
else if (type === TYPE.SEEKER) {
|
|
1245
|
+
stepSeeker(base, dt);
|
|
1246
|
+
}
|
|
1247
|
+
else {
|
|
1248
|
+
integrate(base, type, dt, w);
|
|
1249
|
+
if (type === TYPE.SMOKE)
|
|
1250
|
+
stepSmoke(base, dt);
|
|
1251
|
+
}
|
|
1252
|
+
pool[base + F.age] += dt;
|
|
1253
|
+
const lifeT = pool[base + F.ttl] > 0 ? pool[base + F.age] / pool[base + F.ttl] : 1;
|
|
1254
|
+
// The launch's depth rides along as a per-particle multiplier: the type
|
|
1255
|
+
// curves stay the single source of the shape, depth only scales it.
|
|
1256
|
+
pool[base + F.brightness] =
|
|
1257
|
+
brightnessCurve(type, base, lifeT) * deathGate(lifeT) * pool[base + F.depthDim];
|
|
1258
|
+
// §4.5 soft-dim: drift-ins behind the card fade to KEEP_CLEAR_DIM so the
|
|
1259
|
+
// haze there stays under the §4.6 ceiling (this brightness feeds the
|
|
1260
|
+
// additive accumulation write in writeInstances). Rockets transit and
|
|
1261
|
+
// glyph seekers are the show — both exempt — and the branch only runs
|
|
1262
|
+
// once a keep-clear rect is set.
|
|
1263
|
+
if (keepClear !== null && type !== TYPE.ROCKET && type !== TYPE.SEEKER) {
|
|
1264
|
+
const px = pool[base + F.posX];
|
|
1265
|
+
const py = pool[base + F.posY];
|
|
1266
|
+
if (px >= keepClear.x0 && px <= keepClear.x1 && py >= keepClear.y0 && py <= keepClear.y1) {
|
|
1267
|
+
pool[base + F.brightness] *= KEEP_CLEAR_DIM;
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
let removed = false;
|
|
1271
|
+
if (lifeT >= 1) {
|
|
1272
|
+
// A rocket reaching its ttl detonates: enqueue the shell at the head's
|
|
1273
|
+
// current position (drained next frame), then die like any particle.
|
|
1274
|
+
if (type === TYPE.ROCKET) {
|
|
1275
|
+
const spec = specs.get(pool[base + F.targetX]);
|
|
1276
|
+
if (spec) {
|
|
1277
|
+
burstQueue.push({
|
|
1278
|
+
origin: { x: pool[base + F.posX], y: pool[base + F.posY] },
|
|
1279
|
+
spec,
|
|
1280
|
+
});
|
|
1281
|
+
specs.delete(pool[base + F.targetX]);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
removed = true;
|
|
1285
|
+
}
|
|
1286
|
+
if (removed) {
|
|
1287
|
+
// Swap-remove: copy the last live particle into slot i, shrink, and
|
|
1288
|
+
// re-examine i (do not advance).
|
|
1289
|
+
const last = (count - 1) * STRIDE;
|
|
1290
|
+
if (base !== last)
|
|
1291
|
+
pool.copyWithin(base, last, last + STRIDE);
|
|
1292
|
+
count--;
|
|
1293
|
+
}
|
|
1294
|
+
else {
|
|
1295
|
+
i++;
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
// Generic semi-implicit Euler with per-type gravity, wind, and drag (§2.1).
|
|
1300
|
+
function integrate(base, type, dt, w) {
|
|
1301
|
+
const ax = w.x * WIND_SCALE[type];
|
|
1302
|
+
const ay = G * GRAV_SCALE[type] + w.y * WIND_SCALE[type];
|
|
1303
|
+
let vx = pool[base + F.velX] + ax * dt;
|
|
1304
|
+
let vy = pool[base + F.velY] + ay * dt;
|
|
1305
|
+
const damp = 1 + DRAG[type] * pool[base + F.dragScale] * dt;
|
|
1306
|
+
vx /= damp;
|
|
1307
|
+
vy /= damp;
|
|
1308
|
+
pool[base + F.velX] = vx;
|
|
1309
|
+
pool[base + F.velY] = vy;
|
|
1310
|
+
pool[base + F.posX] += (vx / aspect) * dt;
|
|
1311
|
+
pool[base + F.posY] += vy * dt;
|
|
1312
|
+
}
|
|
1313
|
+
// Rocket ascent trail (§4): fractional accumulator in targetY, deferred
|
|
1314
|
+
// spawns so the pool is never grown while the per-live loop iterates.
|
|
1315
|
+
function emitTrail(base, dt) {
|
|
1316
|
+
let emit = pool[base + F.targetY] + tier.trailRate * dt;
|
|
1317
|
+
const seed = pool[base + F.seed];
|
|
1318
|
+
const px = pool[base + F.posX];
|
|
1319
|
+
const py = pool[base + F.posY];
|
|
1320
|
+
// targetX carries the launch id: the trail wears the launched shell's hue.
|
|
1321
|
+
const hue = specs.get(pool[base + F.targetX])?.trailHue ?? COMET_TRAIL_HUE;
|
|
1322
|
+
let k = 0;
|
|
1323
|
+
while (emit >= 1) {
|
|
1324
|
+
emit -= 1;
|
|
1325
|
+
const j = hash11(seed + pool[base + F.age] * 1000 + k);
|
|
1326
|
+
sparkQueue.push({
|
|
1327
|
+
pos: { x: px, y: py },
|
|
1328
|
+
// Wider lateral scatter: a rigidly co-linear trail reads as a drawn
|
|
1329
|
+
// beam rather than a comet shedding sparks.
|
|
1330
|
+
vel: { x: (j * 2 - 1) * 0.035, y: (hash11(j) * 2 - 1) * 0.02 },
|
|
1331
|
+
ttl: rand(seededRng(seed + k + 71), 0.22, 0.45),
|
|
1332
|
+
size: 0.0018,
|
|
1333
|
+
hue,
|
|
1334
|
+
seed: seed + k + 500,
|
|
1335
|
+
});
|
|
1336
|
+
k++;
|
|
1337
|
+
}
|
|
1338
|
+
pool[base + F.targetY] = emit;
|
|
1339
|
+
}
|
|
1340
|
+
function stepSeeker(base, dt) {
|
|
1341
|
+
const age = pool[base + F.age];
|
|
1342
|
+
const releaseDelay = pool[base + F.dragScale];
|
|
1343
|
+
const tX = pool[base + F.targetX];
|
|
1344
|
+
const tY = pool[base + F.targetY];
|
|
1345
|
+
const seed = pool[base + F.seed];
|
|
1346
|
+
if (age < 0.08) {
|
|
1347
|
+
// Pop phase: ballistic toward target, mild drag, no gravity (§5.1).
|
|
1348
|
+
let vx = pool[base + F.velX];
|
|
1349
|
+
let vy = pool[base + F.velY];
|
|
1350
|
+
const damp = 1 + DRAG[TYPE.SEEKER] * dt;
|
|
1351
|
+
vx /= damp;
|
|
1352
|
+
vy /= damp;
|
|
1353
|
+
pool[base + F.velX] = vx;
|
|
1354
|
+
pool[base + F.velY] = vy;
|
|
1355
|
+
pool[base + F.posX] += (vx / aspect) * dt;
|
|
1356
|
+
pool[base + F.posY] += vy * dt;
|
|
1357
|
+
}
|
|
1358
|
+
else if (age < releaseDelay) {
|
|
1359
|
+
// Spring phase, per axis in VISUAL units (errX = (targetX−posX)·A).
|
|
1360
|
+
// Inlined from springStep (a = −ω²(p−target) − 2ζω·v; v += a·dt;
|
|
1361
|
+
// p += v·dt) with ω=6, ζ=0.9 so the held-glyph hot loop mints no
|
|
1362
|
+
// per-seeker objects. Kept byte-identical to springStep.
|
|
1363
|
+
const omega = 6;
|
|
1364
|
+
const zeta = 0.9;
|
|
1365
|
+
const px = pool[base + F.posX] * aspect;
|
|
1366
|
+
const ax = -omega * omega * (px - tX * aspect) - 2 * zeta * omega * pool[base + F.velX];
|
|
1367
|
+
const nvx = pool[base + F.velX] + ax * dt;
|
|
1368
|
+
pool[base + F.velX] = nvx;
|
|
1369
|
+
pool[base + F.posX] = (px + nvx * dt) / aspect;
|
|
1370
|
+
const py = pool[base + F.posY];
|
|
1371
|
+
const ay = -omega * omega * (py - tY) - 2 * zeta * omega * pool[base + F.velY];
|
|
1372
|
+
const nvy = pool[base + F.velY] + ay * dt;
|
|
1373
|
+
pool[base + F.velY] = nvy;
|
|
1374
|
+
pool[base + F.posY] = py + nvy * dt;
|
|
1375
|
+
}
|
|
1376
|
+
else {
|
|
1377
|
+
// Release phase: spring off, gravity on, ballistic fall.
|
|
1378
|
+
const vy = pool[base + F.velY] + G * 1.0 * dt;
|
|
1379
|
+
pool[base + F.velY] = vy;
|
|
1380
|
+
pool[base + F.posX] += (pool[base + F.velX] / aspect) * dt;
|
|
1381
|
+
pool[base + F.posY] += vy * dt;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
function stepSmoke(base, _dt) {
|
|
1385
|
+
// Two-stage expand: grow size toward 0.05 over life (§2.2 / §3.5).
|
|
1386
|
+
const lifeT = pool[base + F.ttl] > 0 ? pool[base + F.age] / pool[base + F.ttl] : 1;
|
|
1387
|
+
pool[base + F.size] = lerp(0.01, 0.05, Math.min(1, lifeT));
|
|
1388
|
+
}
|
|
1389
|
+
function brightnessCurve(type, base, lifeT) {
|
|
1390
|
+
const seed = pool[base + F.seed];
|
|
1391
|
+
const age = pool[base + F.age];
|
|
1392
|
+
switch (type) {
|
|
1393
|
+
case TYPE.FLASH:
|
|
1394
|
+
// Steep decay: the flash marks the break, it must not sit on top of
|
|
1395
|
+
// the shell as a white disc while the sparks are opening.
|
|
1396
|
+
return pool[base + F.brightness] > 0
|
|
1397
|
+
? Math.max(pool[base + F.brightness], B_FLASH_AMBIENT) * Math.exp(-5.5 * lifeT)
|
|
1398
|
+
: B_FLASH_AMBIENT * Math.exp(-5.5 * lifeT);
|
|
1399
|
+
case TYPE.SPARK: {
|
|
1400
|
+
// Two-stage: fast drop 2.2→~0.9, then cubic ease-in to 0 past 0.7.
|
|
1401
|
+
let b = B_SPARK_STEADY + (B_SPARK_FRESH - B_SPARK_STEADY) * Math.exp(-lifeT * 8);
|
|
1402
|
+
if (lifeT > 0.7) {
|
|
1403
|
+
const u = (lifeT - 0.7) / 0.3;
|
|
1404
|
+
b = Math.max(B_SPARK_TERMINAL, b * (1 - u) * (1 - u) * (1 - u));
|
|
1405
|
+
}
|
|
1406
|
+
// Crackle: flagged sparks chatter on/off at CRACKLE_HZ instead of
|
|
1407
|
+
// fading smoothly (field reuse — targetX is free for sparks).
|
|
1408
|
+
if (pool[base + F.targetX] > 0.5) {
|
|
1409
|
+
b *= flickerNoise(seed, age, CRACKLE_HZ) > 0.5 ? 1.9 : 0.25;
|
|
1410
|
+
}
|
|
1411
|
+
return b;
|
|
1412
|
+
}
|
|
1413
|
+
case TYPE.EMBER: {
|
|
1414
|
+
// Irregular flicker 8–14 Hz, ×0.6–2.0, over a slow near-linear fade.
|
|
1415
|
+
const freq = 8 + 6 * hash11(seed);
|
|
1416
|
+
const mul = 0.6 + 1.4 * flickerNoise(seed, age, freq);
|
|
1417
|
+
return B_EMBER_MEAN * mul * (1 - 0.6 * lifeT);
|
|
1418
|
+
}
|
|
1419
|
+
case TYPE.SMOKE:
|
|
1420
|
+
// Rise then fall within the haze ceiling.
|
|
1421
|
+
return lerp(B_SMOKE_MIN, B_SMOKE_MAX, Math.sin(Math.min(1, lifeT) * Math.PI));
|
|
1422
|
+
case TYPE.SEEKER: {
|
|
1423
|
+
const releaseDelay = pool[base + F.dragScale];
|
|
1424
|
+
if (age < releaseDelay) {
|
|
1425
|
+
return B_GLYPH_HOLD * (0.85 + 0.3 * flickerNoise(seed, age, 10));
|
|
1426
|
+
}
|
|
1427
|
+
// Release pop then decay.
|
|
1428
|
+
const rt = (age - releaseDelay) / 0.9;
|
|
1429
|
+
return B_GLYPH_RELEASE * Math.exp(-3 * Math.max(0, rt));
|
|
1430
|
+
}
|
|
1431
|
+
case TYPE.ROCKET:
|
|
1432
|
+
default:
|
|
1433
|
+
return B_ASCENT_HEAD;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
// ---- writeInstances (§7) -----------------------------------------------
|
|
1437
|
+
// Per-sim display scratch: `_dispHue` holds the raw pool hue, `_dispColor`
|
|
1438
|
+
// the resolved display color. Reused every particle every frame so the hot
|
|
1439
|
+
// loop mints zero objects.
|
|
1440
|
+
const _dispHue = { r: 0, g: 0, b: 0 };
|
|
1441
|
+
const _dispColor = { r: 0, g: 0, b: 0 };
|
|
1442
|
+
function writeInstances(out) {
|
|
1443
|
+
let n = 0;
|
|
1444
|
+
for (let idx = 0; idx < count; idx++) {
|
|
1445
|
+
const base = idx * STRIDE;
|
|
1446
|
+
const type = pool[base + F.type];
|
|
1447
|
+
const bright = pool[base + F.brightness];
|
|
1448
|
+
const lifeT = pool[base + F.ttl] > 0 ? pool[base + F.age] / pool[base + F.ttl] : 1;
|
|
1449
|
+
displayColorInto(type, base, lifeT, _dispColor);
|
|
1450
|
+
const o = n * 8;
|
|
1451
|
+
out[o + 0] = pool[base + F.posX];
|
|
1452
|
+
out[o + 1] = pool[base + F.posY];
|
|
1453
|
+
out[o + 2] = pool[base + F.size];
|
|
1454
|
+
out[o + 3] = _dispColor.r * bright;
|
|
1455
|
+
out[o + 4] = _dispColor.g * bright;
|
|
1456
|
+
out[o + 5] = _dispColor.b * bright;
|
|
1457
|
+
out[o + 6] = pool[base + F.velX] * STRETCH;
|
|
1458
|
+
out[o + 7] = pool[base + F.velY] * STRETCH;
|
|
1459
|
+
n++;
|
|
1460
|
+
}
|
|
1461
|
+
return n;
|
|
1462
|
+
}
|
|
1463
|
+
// Resolve a particle's display color into `out` (no allocation). `out` must
|
|
1464
|
+
// not alias `_dispHue`.
|
|
1465
|
+
function displayColorInto(type, base, lifeT, out) {
|
|
1466
|
+
_dispHue.r = pool[base + F.r];
|
|
1467
|
+
_dispHue.g = pool[base + F.g];
|
|
1468
|
+
_dispHue.b = pool[base + F.b];
|
|
1469
|
+
const seed = pool[base + F.seed];
|
|
1470
|
+
switch (type) {
|
|
1471
|
+
case TYPE.SPARK: {
|
|
1472
|
+
const whiteHold = 0.04 + 0.07 * hash11(seed);
|
|
1473
|
+
sparkColorAtLifeInto(_dispHue, lifeT, whiteHold, seed, out);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
case TYPE.EMBER:
|
|
1477
|
+
// Cool toward warm-white only as it dims (§1.7).
|
|
1478
|
+
mixOklabInto(_dispHue, COOL_END, Math.min(1, lifeT) * 0.5, out);
|
|
1479
|
+
return;
|
|
1480
|
+
case TYPE.FLASH:
|
|
1481
|
+
// White core crossfading to hue as it decays (§3.2).
|
|
1482
|
+
mixOklabInto(MAGNESIUM, _dispHue, Math.min(1, lifeT), out);
|
|
1483
|
+
return;
|
|
1484
|
+
case TYPE.SMOKE:
|
|
1485
|
+
out.r = SMOKE_COLOR.r;
|
|
1486
|
+
out.g = SMOKE_COLOR.g;
|
|
1487
|
+
out.b = SMOKE_COLOR.b;
|
|
1488
|
+
return;
|
|
1489
|
+
case TYPE.SEEKER:
|
|
1490
|
+
case TYPE.ROCKET:
|
|
1491
|
+
default:
|
|
1492
|
+
out.r = _dispHue.r;
|
|
1493
|
+
out.g = _dispHue.g;
|
|
1494
|
+
out.b = _dispHue.b;
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
return {
|
|
1499
|
+
launch,
|
|
1500
|
+
spawnFlash,
|
|
1501
|
+
step,
|
|
1502
|
+
writeInstances,
|
|
1503
|
+
setAspect,
|
|
1504
|
+
setKeepClear,
|
|
1505
|
+
setSpawnScale,
|
|
1506
|
+
reset,
|
|
1507
|
+
get count() {
|
|
1508
|
+
return count;
|
|
1509
|
+
},
|
|
1510
|
+
get capacity() {
|
|
1511
|
+
return capacity;
|
|
1512
|
+
},
|
|
1513
|
+
get droppedSpawns() {
|
|
1514
|
+
return droppedSpawns;
|
|
1515
|
+
},
|
|
1516
|
+
get data() {
|
|
1517
|
+
return pool;
|
|
1518
|
+
},
|
|
1519
|
+
};
|
|
1520
|
+
}
|