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,629 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { FireworksHandle } from "./fireworks-shared.js";
|
|
3
|
+
|
|
4
|
+
/** Props for {@link FireworksHdr}. */
|
|
5
|
+
export interface FireworksHdrProps {
|
|
6
|
+
/**
|
|
7
|
+
* Brand hues (hex). Order is irrelevant — they are parsed and sorted into
|
|
8
|
+
* the canonical cool→warm ping-pong order (cyan, blue, violet, magenta)
|
|
9
|
+
* so the shell sweep is stable however they are listed.
|
|
10
|
+
*/
|
|
11
|
+
palette?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Opt into the GPU engine: WebGPU HDR first, then a WebGL2 fallback
|
|
14
|
+
* (display-p3 SDR where supported, else plain sRGB). When false, no engine
|
|
15
|
+
* boots at all.
|
|
16
|
+
*/
|
|
17
|
+
hdr?: boolean;
|
|
18
|
+
/** Display exposure multiplier, clamped [1,4]. */
|
|
19
|
+
exposure?: number;
|
|
20
|
+
/** Run the ambient auto-scheduler. */
|
|
21
|
+
ambient?: boolean;
|
|
22
|
+
/** Ambient intensity [0,1] — scales shell size/energy. */
|
|
23
|
+
ambientIntensity?: number;
|
|
24
|
+
/** Launch a shell toward the pointer on window pointerdown. */
|
|
25
|
+
interactive?: boolean;
|
|
26
|
+
/** Quality tier, or "auto" to pick from the render level + DPR. */
|
|
27
|
+
quality?: "auto" | "high" | "mid" | "low";
|
|
28
|
+
/**
|
|
29
|
+
* Which shells the ambient scheduler is allowed to fire, picked uniformly.
|
|
30
|
+
* Defaults to the weighted peony/willow/ring mix. Pattern shells ("heart",
|
|
31
|
+
* "star") are valid here; "glyph" and "shape" are not — they need points
|
|
32
|
+
* the scheduler has no way to invent, so they are ignored if listed.
|
|
33
|
+
*/
|
|
34
|
+
ambientShells?: ShellKind[];
|
|
35
|
+
/** Force ambient off under prefers-reduced-motion (launches still work). */
|
|
36
|
+
respectReducedMotion?: boolean;
|
|
37
|
+
/** Extra classes on the canvas wrapper. */
|
|
38
|
+
class?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Called when the engine is live, with an imperative handle. Never called
|
|
41
|
+
* when no GPU renderer comes up (the caller uses its own timeout). Called
|
|
42
|
+
* again after a GPU context loss is recovered, with a fresh handle whose
|
|
43
|
+
* `renderLevel` reflects the engine that came back (a lost WebGPU device
|
|
44
|
+
* can return as the WebGL2 fallback).
|
|
45
|
+
*/
|
|
46
|
+
onReady?: (handle: FireworksHandle) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Called once when the GPU context is lost and cannot be brought back.
|
|
49
|
+
* The component has torn itself down by then: the loop is stopped, the
|
|
50
|
+
* listeners are gone, and any handle it handed out is inert. Use it to
|
|
51
|
+
* swap in a static fallback.
|
|
52
|
+
*/
|
|
53
|
+
onLost?: () => void;
|
|
54
|
+
}
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<script lang="ts">
|
|
58
|
+
import { cn } from "../../utils.js";
|
|
59
|
+
import { onMount } from "svelte";
|
|
60
|
+
import {
|
|
61
|
+
createSim,
|
|
62
|
+
hexToLinearRgb,
|
|
63
|
+
linearToOklab,
|
|
64
|
+
nextShellHue,
|
|
65
|
+
shellHueColor,
|
|
66
|
+
poissonIntervalMs,
|
|
67
|
+
sampleZone,
|
|
68
|
+
rectExpandedContains,
|
|
69
|
+
resolveQualityTier,
|
|
70
|
+
createAdaptiveState,
|
|
71
|
+
adaptiveDowngradeStep,
|
|
72
|
+
adaptiveLevel,
|
|
73
|
+
AMBIENT_ZONES,
|
|
74
|
+
AMBIENT_RADIUS_MAX,
|
|
75
|
+
KEEP_CLEAR_DESKTOP,
|
|
76
|
+
QUALITY,
|
|
77
|
+
SHELL,
|
|
78
|
+
SHELL_JITTER_DEG,
|
|
79
|
+
EXPOSURE_AMBIENT,
|
|
80
|
+
type Sim,
|
|
81
|
+
type Rgb,
|
|
82
|
+
type Rect,
|
|
83
|
+
type ShellKind,
|
|
84
|
+
type FireworksRenderLevel,
|
|
85
|
+
type LaunchOptions,
|
|
86
|
+
type LaunchResult,
|
|
87
|
+
} from "./fireworks-shared.js";
|
|
88
|
+
import { startWebGpuFireworks, type FireworksEngineHandle } from "./webgpu-renderer.js";
|
|
89
|
+
import { startWebGl2Fireworks } from "./webgl2-renderer.js";
|
|
90
|
+
|
|
91
|
+
let {
|
|
92
|
+
palette = ["#ff2fd6", "#a142ff", "#3d5bff", "#42cfff"],
|
|
93
|
+
hdr = true,
|
|
94
|
+
exposure = EXPOSURE_AMBIENT,
|
|
95
|
+
ambient = true,
|
|
96
|
+
ambientIntensity = 0.35,
|
|
97
|
+
interactive = true,
|
|
98
|
+
quality = "auto",
|
|
99
|
+
ambientShells,
|
|
100
|
+
respectReducedMotion = true,
|
|
101
|
+
class: className = "",
|
|
102
|
+
onReady,
|
|
103
|
+
onLost,
|
|
104
|
+
}: FireworksHdrProps = $props();
|
|
105
|
+
|
|
106
|
+
let canvasRef: HTMLCanvasElement;
|
|
107
|
+
|
|
108
|
+
// Largest instance buffer we might need — the engine is sized for the high
|
|
109
|
+
// tier up front so the resolved (auto) quality can be any tier ≤ high.
|
|
110
|
+
const MAX_INSTANCES = QUALITY.high.maxParticles;
|
|
111
|
+
const DT_CLAMP = 0.05; // guards against huge dt after a tab switch
|
|
112
|
+
// AD §4.3 / physics §6: "ambient 2–3 (rare 4)" — the scheduler's own cadence
|
|
113
|
+
// stays gated at 2; a mirrored double (§5.2) may push a third shell in
|
|
114
|
+
// flight, but never a fourth.
|
|
115
|
+
const MAX_AMBIENT_INFLIGHT_SOFT = 2;
|
|
116
|
+
const MAX_AMBIENT_INFLIGHT_HARD = 3;
|
|
117
|
+
// How long a lost WebGL context is given to announce its restoration before
|
|
118
|
+
// the component gives up and reports the failure through `onLost`.
|
|
119
|
+
const RESTORE_TIMEOUT_MS = 4000;
|
|
120
|
+
|
|
121
|
+
function clampExposure(v: number): number {
|
|
122
|
+
return Number.isFinite(v) ? Math.max(1, Math.min(4, v)) : 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Parse palette hexes to linear RGB, sorted cool→warm by oklab hue angle. */
|
|
126
|
+
function resolvePalette(hexes: string[]): Rgb[] {
|
|
127
|
+
const parsed = (hexes.length ? hexes : ["#42cfff", "#3d5bff", "#a142ff", "#ff2fd6"]).map(
|
|
128
|
+
hexToLinearRgb
|
|
129
|
+
);
|
|
130
|
+
return parsed
|
|
131
|
+
.map((c) => {
|
|
132
|
+
const o = linearToOklab(c);
|
|
133
|
+
// Angle folded to [0,2π); cyan≈196°→…→magenta≈312° sort ascending.
|
|
134
|
+
let h = Math.atan2(o.b, o.a);
|
|
135
|
+
if (h < 0) h += Math.PI * 2;
|
|
136
|
+
return { c, h };
|
|
137
|
+
})
|
|
138
|
+
.sort((a, b) => a.h - b.h)
|
|
139
|
+
.map((x) => x.c);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Clamped device-pixel ratio, matching the renderers' MAX_DPR. */
|
|
143
|
+
function currentDpr(): number {
|
|
144
|
+
return Math.min(typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1, 2);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A conservative low-power signal (modest RAM or few logical cores). Only ever
|
|
149
|
+
* consulted on the WebGL2 fallback path (a weak-GPU signal already), where it
|
|
150
|
+
* drops the auto tier from "mid" to "low". WebGPU machines are never affected.
|
|
151
|
+
*/
|
|
152
|
+
function detectLowPower(): boolean {
|
|
153
|
+
if (typeof navigator === "undefined") return false;
|
|
154
|
+
const mem = (navigator as Navigator & { deviceMemory?: number }).deviceMemory;
|
|
155
|
+
const cores = navigator.hardwareConcurrency;
|
|
156
|
+
return (typeof mem === "number" && mem <= 4) || (typeof cores === "number" && cores <= 4);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
onMount(() => {
|
|
160
|
+
const canvas = canvasRef;
|
|
161
|
+
if (!canvas) return;
|
|
162
|
+
|
|
163
|
+
let disposed = false;
|
|
164
|
+
let engine: FireworksEngineHandle | null = null;
|
|
165
|
+
let sim: Sim | null = null;
|
|
166
|
+
let instances: Float32Array<ArrayBuffer> | null = null;
|
|
167
|
+
let rafId = 0;
|
|
168
|
+
let running = false;
|
|
169
|
+
let lastTime = 0;
|
|
170
|
+
|
|
171
|
+
// Adaptive downgrade: session-sticky quality ladder driven by an
|
|
172
|
+
// EMA of frame time. Applied only on active frames (see `loop`).
|
|
173
|
+
const adaptive = createAdaptiveState();
|
|
174
|
+
let currentRenderScale = 1;
|
|
175
|
+
let currentSpawnScale = 1;
|
|
176
|
+
|
|
177
|
+
let currentExposure = clampExposure(exposure);
|
|
178
|
+
// Reused per-frame uniforms object (no per-frame allocation in the loop).
|
|
179
|
+
const frameUniforms = { exposure: currentExposure };
|
|
180
|
+
let keepClear: Rect | null = KEEP_CLEAR_DESKTOP;
|
|
181
|
+
let paletteRgb = resolvePalette(palette);
|
|
182
|
+
|
|
183
|
+
const prefersReduced =
|
|
184
|
+
respectReducedMotion &&
|
|
185
|
+
typeof window !== "undefined" &&
|
|
186
|
+
typeof window.matchMedia === "function" &&
|
|
187
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
188
|
+
|
|
189
|
+
// Sampled once; only consulted on the WebGL2 fallback tier resolution.
|
|
190
|
+
const lowPower = detectLowPower();
|
|
191
|
+
|
|
192
|
+
let ambientOn = ambient && !prefersReduced;
|
|
193
|
+
let ambientLevel = Math.max(0, Math.min(1, ambientIntensity));
|
|
194
|
+
|
|
195
|
+
// Ambient scheduling state.
|
|
196
|
+
const hueState = { i: 0, dir: 1 as 1 | -1 };
|
|
197
|
+
let ambientElapsed = 0;
|
|
198
|
+
let nextInterval = poissonIntervalMs(2200, Math.random, 900, 5200);
|
|
199
|
+
let ambientInFlight = 0;
|
|
200
|
+
const pendingDoubles: { at: number; mirror: boolean }[] = [];
|
|
201
|
+
|
|
202
|
+
let isVisible = true;
|
|
203
|
+
let observer: IntersectionObserver | null = null;
|
|
204
|
+
let resizeObs: ResizeObserver | null = null;
|
|
205
|
+
|
|
206
|
+
// GPU context loss: one recovery attempt (WebGPU asks for a new device,
|
|
207
|
+
// WebGL waits for `webglcontextrestored`), then a full teardown so the
|
|
208
|
+
// caller can fall back instead of holding a dead handle.
|
|
209
|
+
let wired = false; // observers/listeners are registered exactly once
|
|
210
|
+
let recovering = false;
|
|
211
|
+
let recoveryUsed = false;
|
|
212
|
+
let restoreTimer = 0;
|
|
213
|
+
|
|
214
|
+
function updateAspect() {
|
|
215
|
+
if (!sim || !canvas) return;
|
|
216
|
+
const w = canvas.clientWidth || 1;
|
|
217
|
+
const h = canvas.clientHeight || 1;
|
|
218
|
+
sim.setAspect(w / h);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function currentAspect(): number {
|
|
222
|
+
return (canvas.clientWidth || 1) / (canvas.clientHeight || 1);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Shells the scheduler can fire unaided — "glyph" and "shape" need caller
|
|
226
|
+
// points, so they are dropped from any list rather than breaking blank.
|
|
227
|
+
const SELF_DRAWING: ShellKind[] = ["peony", "willow", "ring", "heart", "star"];
|
|
228
|
+
const ambientMix = (ambientShells ?? []).filter((k) => SELF_DRAWING.includes(k));
|
|
229
|
+
|
|
230
|
+
/** The stock weighted mix — what a launch uses when nothing overrides it. */
|
|
231
|
+
function pickShell(): ShellKind {
|
|
232
|
+
const r = Math.random();
|
|
233
|
+
if (r < 0.62) return "peony";
|
|
234
|
+
if (r < 0.82) return "willow";
|
|
235
|
+
return "ring";
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The scheduler's own pick. `ambientShells` is scoped to the scheduler, so
|
|
240
|
+
* pointer launches keep the stock mix — a prop named for the ambient loop
|
|
241
|
+
* must not quietly rewrite what a click does.
|
|
242
|
+
*/
|
|
243
|
+
function pickAmbientShell(): ShellKind {
|
|
244
|
+
if (ambientMix.length) return ambientMix[Math.floor(Math.random() * ambientMix.length)];
|
|
245
|
+
return pickShell();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function ambientColor(): Rgb {
|
|
249
|
+
return shellHueColor(nextShellHue(hueState), paletteRgb, SHELL_JITTER_DEG, Math.random);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* AD §4.4: ambient bursts must stay within 0.10–0.20 (fraction of
|
|
254
|
+
* min-dim) — distinct from the larger "feature" band reserved for
|
|
255
|
+
* intro/feature shells. `SHELL[shell].radius[1]` is each shell's own
|
|
256
|
+
* frozen max (willow runs a touch larger than peony/ring), so the scale
|
|
257
|
+
* ceiling is derived per shell rather than shared, guaranteeing the
|
|
258
|
+
* 0.20 cap holds at ambientLevel 1 for every shell kind.
|
|
259
|
+
*/
|
|
260
|
+
function ambientScale(shell: ShellKind, level: number): number {
|
|
261
|
+
const raw = 0.7 + 0.4 * level;
|
|
262
|
+
const ceiling = AMBIENT_RADIUS_MAX / SHELL[shell].radius[1];
|
|
263
|
+
return Math.min(raw, ceiling);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function doAmbientLaunch(mirror: boolean) {
|
|
267
|
+
if (!sim || ambientInFlight >= MAX_AMBIENT_INFLIGHT_HARD) return;
|
|
268
|
+
let picked = sampleZone(AMBIENT_ZONES, Math.random, keepClear);
|
|
269
|
+
let apex = picked.apex;
|
|
270
|
+
if (mirror) {
|
|
271
|
+
const mirrored = { x: 1 - picked.apex.x, y: picked.apex.y };
|
|
272
|
+
// sampleZone validated the ORIGINAL point; reflecting it can drop the
|
|
273
|
+
// double straight into an asymmetric keep-clear rect. Re-test, and
|
|
274
|
+
// take a freshly sampled (validated) apex when the mirror lands in it.
|
|
275
|
+
if (keepClear && rectExpandedContains(keepClear, mirrored.x, mirrored.y)) {
|
|
276
|
+
picked = sampleZone(AMBIENT_ZONES, Math.random, keepClear);
|
|
277
|
+
apex = picked.apex;
|
|
278
|
+
} else {
|
|
279
|
+
apex = mirrored;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const shell = pickAmbientShell();
|
|
283
|
+
const res = sim.launch({
|
|
284
|
+
apex,
|
|
285
|
+
shell,
|
|
286
|
+
color: ambientColor(),
|
|
287
|
+
depth: picked.depth,
|
|
288
|
+
scale: ambientScale(shell, ambientLevel),
|
|
289
|
+
intensity: "ambient",
|
|
290
|
+
});
|
|
291
|
+
ambientInFlight++;
|
|
292
|
+
// Release the in-flight slot shortly after the shell detonates.
|
|
293
|
+
window.setTimeout(() => {
|
|
294
|
+
ambientInFlight = Math.max(0, ambientInFlight - 1);
|
|
295
|
+
}, res.breakMs + 200);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function ambientTick(dtMs: number) {
|
|
299
|
+
if (!ambientOn) return;
|
|
300
|
+
// Fire any scheduled double-launches whose delay has elapsed.
|
|
301
|
+
for (let i = pendingDoubles.length - 1; i >= 0; i--) {
|
|
302
|
+
pendingDoubles[i].at -= dtMs;
|
|
303
|
+
if (pendingDoubles[i].at <= 0) {
|
|
304
|
+
doAmbientLaunch(pendingDoubles[i].mirror);
|
|
305
|
+
pendingDoubles.splice(i, 1);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
ambientElapsed += dtMs;
|
|
309
|
+
if (ambientElapsed >= nextInterval && ambientInFlight < MAX_AMBIENT_INFLIGHT_SOFT) {
|
|
310
|
+
doAmbientLaunch(false);
|
|
311
|
+
ambientElapsed = 0;
|
|
312
|
+
nextInterval = poissonIntervalMs(2200, Math.random, 900, 5200);
|
|
313
|
+
// 18% chance of a mirrored double, +120–320 ms later.
|
|
314
|
+
if (Math.random() < 0.18) {
|
|
315
|
+
pendingDoubles.push({ at: 120 + Math.random() * 200, mirror: true });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function applyAdaptiveLevel() {
|
|
321
|
+
const level = adaptiveLevel(adaptive);
|
|
322
|
+
if (level.renderScale !== currentRenderScale) {
|
|
323
|
+
currentRenderScale = level.renderScale;
|
|
324
|
+
engine?.setRenderScale(currentRenderScale);
|
|
325
|
+
}
|
|
326
|
+
if (level.spawnScale !== currentSpawnScale) {
|
|
327
|
+
currentSpawnScale = level.spawnScale;
|
|
328
|
+
sim?.setSpawnScale(currentSpawnScale);
|
|
329
|
+
}
|
|
330
|
+
if (import.meta.env.DEV) {
|
|
331
|
+
console.info(
|
|
332
|
+
`[FireworksHdr] adaptive downgrade → step ${adaptive.step} ` +
|
|
333
|
+
`(renderScale ${currentRenderScale}, spawnScale ${currentSpawnScale})`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function loop(now: number) {
|
|
339
|
+
if (!running || disposed || !sim || !engine || !instances) return;
|
|
340
|
+
// A lost device/context never recovers on its own: reboot or tear down
|
|
341
|
+
// rather than leave a live component driving a dead engine.
|
|
342
|
+
if (engine.lost) {
|
|
343
|
+
handleEngineLoss();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const rawFrameMs = now - lastTime;
|
|
347
|
+
const dt = Math.min((now - lastTime) / 1000, DT_CLAMP);
|
|
348
|
+
lastTime = now;
|
|
349
|
+
|
|
350
|
+
ambientTick(dt * 1000);
|
|
351
|
+
engine.resizeIfNeeded();
|
|
352
|
+
sim.step(dt);
|
|
353
|
+
const n = sim.writeInstances(instances);
|
|
354
|
+
frameUniforms.exposure = currentExposure;
|
|
355
|
+
engine.frame(dt, instances, n, frameUniforms);
|
|
356
|
+
|
|
357
|
+
// Fold only ACTIVE, foreground frames into the adaptive controller so
|
|
358
|
+
// paused / reduced-motion-idle frames (n === 0) never skew the average.
|
|
359
|
+
if (n > 0 && !document.hidden && adaptiveDowngradeStep(adaptive, rawFrameMs)) {
|
|
360
|
+
applyAdaptiveLevel();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
rafId = requestAnimationFrame(loop);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function startLoop() {
|
|
367
|
+
if (running) return;
|
|
368
|
+
running = true;
|
|
369
|
+
lastTime = performance.now();
|
|
370
|
+
rafId = requestAnimationFrame(loop);
|
|
371
|
+
}
|
|
372
|
+
function stopLoop() {
|
|
373
|
+
running = false;
|
|
374
|
+
if (rafId) cancelAnimationFrame(rafId);
|
|
375
|
+
rafId = 0;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// --- pointer-driven launches -----------------------------------------
|
|
379
|
+
function handlePointerDown(e: PointerEvent) {
|
|
380
|
+
if (!sim) return;
|
|
381
|
+
const rect = canvas.getBoundingClientRect();
|
|
382
|
+
if (rect.width <= 0 || rect.height <= 0) return;
|
|
383
|
+
const x = (e.clientX - rect.left) / rect.width;
|
|
384
|
+
const y = (e.clientY - rect.top) / rect.height;
|
|
385
|
+
if (x < 0 || x > 1 || y < 0 || y > 1) return;
|
|
386
|
+
sim.launch({
|
|
387
|
+
apex: { x, y },
|
|
388
|
+
shell: pickShell(),
|
|
389
|
+
color: ambientColor(),
|
|
390
|
+
intensity: "ambient",
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function handleVisibility() {
|
|
395
|
+
if (document.hidden) stopLoop();
|
|
396
|
+
else if (isVisible && !disposed && sim) startLoop();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- imperative handle (§1) ------------------------------------------
|
|
400
|
+
function buildHandle(level: FireworksRenderLevel): FireworksHandle {
|
|
401
|
+
return {
|
|
402
|
+
launch(o: LaunchOptions): LaunchResult {
|
|
403
|
+
if (!sim) return { flightMs: 0, breakMs: 0 };
|
|
404
|
+
return sim.launch(o);
|
|
405
|
+
},
|
|
406
|
+
setAmbient(on: boolean, intensity?: number) {
|
|
407
|
+
ambientOn = on && !prefersReduced;
|
|
408
|
+
if (intensity !== undefined) ambientLevel = Math.max(0, Math.min(1, intensity));
|
|
409
|
+
},
|
|
410
|
+
setKeepClear(rect: Rect | null) {
|
|
411
|
+
keepClear = rect;
|
|
412
|
+
// Also drives the per-particle soft dim (AD §4.5) — apex sampling
|
|
413
|
+
// alone only keeps burst origins clear, not drift-in trails/embers.
|
|
414
|
+
sim?.setKeepClear(rect);
|
|
415
|
+
},
|
|
416
|
+
setExposure(v: number) {
|
|
417
|
+
currentExposure = clampExposure(v);
|
|
418
|
+
},
|
|
419
|
+
renderLevel: level,
|
|
420
|
+
cleanup: teardown,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function teardown() {
|
|
425
|
+
disposed = true;
|
|
426
|
+
stopLoop();
|
|
427
|
+
observer?.disconnect();
|
|
428
|
+
resizeObs?.disconnect();
|
|
429
|
+
if (interactive) window.removeEventListener("pointerdown", handlePointerDown);
|
|
430
|
+
document.removeEventListener("visibilitychange", handleVisibility);
|
|
431
|
+
canvas.removeEventListener("webglcontextrestored", onContextRestored);
|
|
432
|
+
if (restoreTimer) window.clearTimeout(restoreTimer);
|
|
433
|
+
restoreTimer = 0;
|
|
434
|
+
wired = false;
|
|
435
|
+
engine?.destroy();
|
|
436
|
+
engine = null;
|
|
437
|
+
sim = null;
|
|
438
|
+
instances = null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Drop the dead engine and its sim, keeping observers/listeners alive. */
|
|
442
|
+
function releaseEngine() {
|
|
443
|
+
stopLoop();
|
|
444
|
+
engine?.destroy();
|
|
445
|
+
engine = null;
|
|
446
|
+
sim = null;
|
|
447
|
+
instances = null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function giveUp() {
|
|
451
|
+
if (disposed) return;
|
|
452
|
+
teardown();
|
|
453
|
+
onLost?.();
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function reboot() {
|
|
457
|
+
recovering = true;
|
|
458
|
+
let revived = false;
|
|
459
|
+
try {
|
|
460
|
+
revived = await boot();
|
|
461
|
+
} catch (error) {
|
|
462
|
+
if (import.meta.env.DEV) {
|
|
463
|
+
console.warn("[FireworksHdr] engine reboot failed:", error);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
recovering = false;
|
|
467
|
+
if (!revived && !disposed) giveUp();
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function onContextRestored() {
|
|
471
|
+
if (restoreTimer) window.clearTimeout(restoreTimer);
|
|
472
|
+
restoreTimer = 0;
|
|
473
|
+
if (disposed) return;
|
|
474
|
+
void reboot();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* A WebGPU device loss or a `webglcontextlost` leaves the engine dead: no
|
|
479
|
+
* frame it draws lands. Reboot once (which also re-runs the WebGPU →
|
|
480
|
+
* WebGL2 fallback), and if that fails tear the component down and report
|
|
481
|
+
* it instead of leaving observers, listeners, and a handle wired to
|
|
482
|
+
* nothing.
|
|
483
|
+
*/
|
|
484
|
+
function handleEngineLoss() {
|
|
485
|
+
if (disposed || recovering) return;
|
|
486
|
+
const wasWebgl = engine ? engine.renderLevel.startsWith("webgl") : false;
|
|
487
|
+
releaseEngine();
|
|
488
|
+
if (recoveryUsed) {
|
|
489
|
+
giveUp();
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
recoveryUsed = true;
|
|
493
|
+
if (wasWebgl) {
|
|
494
|
+
// A lost WebGL context only vends a usable one again once the browser
|
|
495
|
+
// fires webglcontextrestored — which it may never do.
|
|
496
|
+
recovering = true;
|
|
497
|
+
canvas.addEventListener("webglcontextrestored", onContextRestored, { once: true });
|
|
498
|
+
restoreTimer = window.setTimeout(() => {
|
|
499
|
+
restoreTimer = 0;
|
|
500
|
+
canvas.removeEventListener("webglcontextrestored", onContextRestored);
|
|
501
|
+
recovering = false;
|
|
502
|
+
giveUp();
|
|
503
|
+
}, RESTORE_TIMEOUT_MS);
|
|
504
|
+
} else {
|
|
505
|
+
// WebGPU: a fresh device can be requested straight away.
|
|
506
|
+
void reboot();
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Wire the sim, observers, and listeners around a live engine and hand out
|
|
512
|
+
* a handle. Shared by both the WebGPU and WebGL2 paths so the render level
|
|
513
|
+
* flows through from whichever engine actually came up — and re-entrant,
|
|
514
|
+
* so a post-loss reboot re-seats the sim without double-wiring the
|
|
515
|
+
* observers and listeners it already owns.
|
|
516
|
+
*/
|
|
517
|
+
function activate(eng: FireworksEngineHandle) {
|
|
518
|
+
engine = eng;
|
|
519
|
+
const level = eng.renderLevel;
|
|
520
|
+
const minDim = Math.min(canvas.clientWidth, canvas.clientHeight);
|
|
521
|
+
const q = resolveQualityTier(quality, level, currentDpr(), minDim, lowPower);
|
|
522
|
+
|
|
523
|
+
sim = createSim({
|
|
524
|
+
quality: q,
|
|
525
|
+
aspect: currentAspect(),
|
|
526
|
+
hdr: true,
|
|
527
|
+
palette: paletteRgb,
|
|
528
|
+
});
|
|
529
|
+
sim.setKeepClear(keepClear);
|
|
530
|
+
instances = new Float32Array(sim.capacity * 8);
|
|
531
|
+
|
|
532
|
+
if (!wired) {
|
|
533
|
+
// Observers: pause off-screen / when hidden; track aspect + resize.
|
|
534
|
+
observer = new IntersectionObserver(
|
|
535
|
+
([entry]) => {
|
|
536
|
+
isVisible = entry.isIntersecting;
|
|
537
|
+
if (isVisible && !document.hidden && !disposed) startLoop();
|
|
538
|
+
else stopLoop();
|
|
539
|
+
},
|
|
540
|
+
{ threshold: 0 }
|
|
541
|
+
);
|
|
542
|
+
observer.observe(canvas);
|
|
543
|
+
|
|
544
|
+
resizeObs = new ResizeObserver(() => {
|
|
545
|
+
updateAspect();
|
|
546
|
+
engine?.resizeIfNeeded();
|
|
547
|
+
});
|
|
548
|
+
resizeObs.observe(canvas);
|
|
549
|
+
|
|
550
|
+
if (interactive) window.addEventListener("pointerdown", handlePointerDown);
|
|
551
|
+
document.addEventListener("visibilitychange", handleVisibility);
|
|
552
|
+
wired = true;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
updateAspect();
|
|
556
|
+
startLoop();
|
|
557
|
+
// Re-fired after a recovery: the level may have dropped to the WebGL2
|
|
558
|
+
// fallback, and the caller needs a handle that says so.
|
|
559
|
+
onReady?.(buildHandle(level));
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// --- boot the engine, then wire everything ---------------------------
|
|
563
|
+
// WebGPU first (if present), then the WebGL2 fallback. Only if BOTH fail do
|
|
564
|
+
// we stay silent — the app's own timeout then shows the static fallback
|
|
565
|
+
// (renderLevel "none"). Resolves to whether an engine actually activated,
|
|
566
|
+
// which is what the post-loss reboot decides on.
|
|
567
|
+
async function boot(): Promise<boolean> {
|
|
568
|
+
let eng: FireworksEngineHandle | null = null;
|
|
569
|
+
|
|
570
|
+
if (typeof navigator !== "undefined" && "gpu" in navigator) {
|
|
571
|
+
eng = await startWebGpuFireworks(canvas, {
|
|
572
|
+
maxInstances: MAX_INSTANCES,
|
|
573
|
+
renderScale: 1,
|
|
574
|
+
exposure: currentExposure,
|
|
575
|
+
}).catch((error) => {
|
|
576
|
+
if (import.meta.env.DEV) {
|
|
577
|
+
console.warn("[FireworksHdr] WebGPU start failed:", error);
|
|
578
|
+
}
|
|
579
|
+
return null;
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (disposed) {
|
|
584
|
+
eng?.destroy();
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (!eng) {
|
|
589
|
+
// WebGL2 fallback. It only ever reports webgl-p3 / webgl-sdr, which
|
|
590
|
+
// resolve to the same tier, so the tier is resolved up front to size
|
|
591
|
+
// the instance buffer to the resolved tier (not high's 4096).
|
|
592
|
+
const minDim = Math.min(canvas.clientWidth, canvas.clientHeight);
|
|
593
|
+
const webglTier = resolveQualityTier(quality, "webgl-p3", currentDpr(), minDim, lowPower);
|
|
594
|
+
eng = startWebGl2Fireworks(canvas, {
|
|
595
|
+
maxInstances: QUALITY[webglTier].maxParticles,
|
|
596
|
+
renderScale: 1,
|
|
597
|
+
exposure: currentExposure,
|
|
598
|
+
hdr: true,
|
|
599
|
+
});
|
|
600
|
+
if (disposed) {
|
|
601
|
+
eng?.destroy();
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Both paths exhausted: no GPU renderer. Stay silent (no onReady); the
|
|
607
|
+
// app's timeout owns the static fallback.
|
|
608
|
+
if (!eng) return false;
|
|
609
|
+
|
|
610
|
+
activate(eng);
|
|
611
|
+
return true;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (hdr) {
|
|
615
|
+
boot().catch((error) => {
|
|
616
|
+
if (import.meta.env.DEV) {
|
|
617
|
+
console.warn("[FireworksHdr] engine boot failed:", error);
|
|
618
|
+
}
|
|
619
|
+
return false;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return teardown;
|
|
624
|
+
});
|
|
625
|
+
</script>
|
|
626
|
+
|
|
627
|
+
<div class={cn("pointer-events-none absolute inset-0 h-full w-full", className)} aria-hidden="true">
|
|
628
|
+
<canvas bind:this={canvasRef} class="block h-full w-full"></canvas>
|
|
629
|
+
</div>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { FireworksHandle } from "./fireworks-shared.js";
|
|
2
|
+
/** Props for {@link FireworksHdr}. */
|
|
3
|
+
export interface FireworksHdrProps {
|
|
4
|
+
/**
|
|
5
|
+
* Brand hues (hex). Order is irrelevant — they are parsed and sorted into
|
|
6
|
+
* the canonical cool→warm ping-pong order (cyan, blue, violet, magenta)
|
|
7
|
+
* so the shell sweep is stable however they are listed.
|
|
8
|
+
*/
|
|
9
|
+
palette?: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Opt into the GPU engine: WebGPU HDR first, then a WebGL2 fallback
|
|
12
|
+
* (display-p3 SDR where supported, else plain sRGB). When false, no engine
|
|
13
|
+
* boots at all.
|
|
14
|
+
*/
|
|
15
|
+
hdr?: boolean;
|
|
16
|
+
/** Display exposure multiplier, clamped [1,4]. */
|
|
17
|
+
exposure?: number;
|
|
18
|
+
/** Run the ambient auto-scheduler. */
|
|
19
|
+
ambient?: boolean;
|
|
20
|
+
/** Ambient intensity [0,1] — scales shell size/energy. */
|
|
21
|
+
ambientIntensity?: number;
|
|
22
|
+
/** Launch a shell toward the pointer on window pointerdown. */
|
|
23
|
+
interactive?: boolean;
|
|
24
|
+
/** Quality tier, or "auto" to pick from the render level + DPR. */
|
|
25
|
+
quality?: "auto" | "high" | "mid" | "low";
|
|
26
|
+
/**
|
|
27
|
+
* Which shells the ambient scheduler is allowed to fire, picked uniformly.
|
|
28
|
+
* Defaults to the weighted peony/willow/ring mix. Pattern shells ("heart",
|
|
29
|
+
* "star") are valid here; "glyph" and "shape" are not — they need points
|
|
30
|
+
* the scheduler has no way to invent, so they are ignored if listed.
|
|
31
|
+
*/
|
|
32
|
+
ambientShells?: ShellKind[];
|
|
33
|
+
/** Force ambient off under prefers-reduced-motion (launches still work). */
|
|
34
|
+
respectReducedMotion?: boolean;
|
|
35
|
+
/** Extra classes on the canvas wrapper. */
|
|
36
|
+
class?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Called when the engine is live, with an imperative handle. Never called
|
|
39
|
+
* when no GPU renderer comes up (the caller uses its own timeout). Called
|
|
40
|
+
* again after a GPU context loss is recovered, with a fresh handle whose
|
|
41
|
+
* `renderLevel` reflects the engine that came back (a lost WebGPU device
|
|
42
|
+
* can return as the WebGL2 fallback).
|
|
43
|
+
*/
|
|
44
|
+
onReady?: (handle: FireworksHandle) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Called once when the GPU context is lost and cannot be brought back.
|
|
47
|
+
* The component has torn itself down by then: the loop is stopped, the
|
|
48
|
+
* listeners are gone, and any handle it handed out is inert. Use it to
|
|
49
|
+
* swap in a static fallback.
|
|
50
|
+
*/
|
|
51
|
+
onLost?: () => void;
|
|
52
|
+
}
|
|
53
|
+
import { type ShellKind } from "./fireworks-shared.js";
|
|
54
|
+
declare const FireworksHdr: import("svelte").Component<FireworksHdrProps, {}, "">;
|
|
55
|
+
type FireworksHdr = ReturnType<typeof FireworksHdr>;
|
|
56
|
+
export default FireworksHdr;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|