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,74 @@
|
|
|
1
|
+
import { render, cleanup } from "@testing-library/svelte";
|
|
2
|
+
import { afterEach, describe, it, expect, vi } from "vitest";
|
|
3
|
+
import FireworksHdr from "./FireworksHdr.svelte";
|
|
4
|
+
// jsdom exposes no `navigator.gpu`, so these exercise the no-renderer path:
|
|
5
|
+
// the component mounts inertly and never fires onReady (the app's own timeout
|
|
6
|
+
// owns the fallback decision).
|
|
7
|
+
describe("FireworksHdr", () => {
|
|
8
|
+
afterEach(cleanup);
|
|
9
|
+
it("renders a wrapper div", () => {
|
|
10
|
+
const { container } = render(FireworksHdr);
|
|
11
|
+
const div = container.firstElementChild;
|
|
12
|
+
expect(div).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
it("renders a canvas element", () => {
|
|
15
|
+
const { container } = render(FireworksHdr);
|
|
16
|
+
expect(container.querySelector("canvas")).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
it("wrapper is pointer-events-none and absolutely fills its parent", () => {
|
|
19
|
+
const { container } = render(FireworksHdr);
|
|
20
|
+
const div = container.firstElementChild;
|
|
21
|
+
expect(div.className).toContain("pointer-events-none");
|
|
22
|
+
expect(div.className).toContain("absolute");
|
|
23
|
+
expect(div.className).toContain("inset-0");
|
|
24
|
+
});
|
|
25
|
+
it("wrapper is aria-hidden (decorative)", () => {
|
|
26
|
+
const { container } = render(FireworksHdr);
|
|
27
|
+
const div = container.firstElementChild;
|
|
28
|
+
expect(div.getAttribute("aria-hidden")).toBe("true");
|
|
29
|
+
});
|
|
30
|
+
it("applies a custom class name", () => {
|
|
31
|
+
const { container } = render(FireworksHdr, { props: { class: "my-fireworks" } });
|
|
32
|
+
const div = container.firstElementChild;
|
|
33
|
+
expect(div.className).toContain("my-fireworks");
|
|
34
|
+
});
|
|
35
|
+
it("does not fire onReady when no GPU renderer is available (jsdom)", async () => {
|
|
36
|
+
const onReady = vi.fn();
|
|
37
|
+
render(FireworksHdr, { props: { onReady } });
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
39
|
+
expect(onReady).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
it("does not fire onLost when no engine ever booted (that is not a loss)", async () => {
|
|
42
|
+
const onLost = vi.fn();
|
|
43
|
+
render(FireworksHdr, { props: { onLost } });
|
|
44
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
45
|
+
expect(onLost).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
it("re-exports the figure helpers consumers are told to import", async () => {
|
|
48
|
+
// The package barrel is `export * from "./fireworks-hdr/index.js"`, so what
|
|
49
|
+
// this module exposes is exactly what `import { … } from "fancy-ui-svelte"`
|
|
50
|
+
// can reach.
|
|
51
|
+
const barrel = await import("./index.js");
|
|
52
|
+
expect(typeof barrel.heartOutline).toBe("function");
|
|
53
|
+
expect(typeof barrel.starOutline).toBe("function");
|
|
54
|
+
expect(typeof barrel.polygonOutline).toBe("function");
|
|
55
|
+
expect(typeof barrel.outlineBurst).toBe("function");
|
|
56
|
+
});
|
|
57
|
+
it("does not attempt WebGPU when hdr={false}", () => {
|
|
58
|
+
const { container } = render(FireworksHdr, { props: { hdr: false } });
|
|
59
|
+
expect(container.querySelector("canvas")).toBeInTheDocument();
|
|
60
|
+
});
|
|
61
|
+
it("unmounts without throwing", () => {
|
|
62
|
+
const { unmount } = render(FireworksHdr, { props: { interactive: true, ambient: true } });
|
|
63
|
+
expect(() => unmount()).not.toThrow();
|
|
64
|
+
});
|
|
65
|
+
it("mounts without error across quality tiers and reduced-motion", () => {
|
|
66
|
+
for (const quality of ["auto", "high", "mid", "low"]) {
|
|
67
|
+
const { container, unmount } = render(FireworksHdr, {
|
|
68
|
+
props: { quality, respectReducedMotion: true },
|
|
69
|
+
});
|
|
70
|
+
expect(container.querySelector("canvas")).toBeInTheDocument();
|
|
71
|
+
unmount();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
# FireworksHdr
|
|
2
|
+
|
|
3
|
+
A GPU fireworks background that burns shells into the night sky. A DOM/GPU-free
|
|
4
|
+
physics core drives a WebGPU renderer into an `rgba16float`, wide-gamut (P3),
|
|
5
|
+
extended-tone-mapping canvas, so bursts, flashes, and comet trails render
|
|
6
|
+
_brighter than SDR white_ on an HDR display. It degrades silently — a clamped
|
|
7
|
+
WebGPU-SDR path, then a WebGL2 fallback (display-p3 or plain sRGB), then nothing
|
|
8
|
+
at all — so the effect stays safe on everything else.
|
|
9
|
+
|
|
10
|
+
The simulation (rockets, peony/willow/ring shells, heart/star/custom pattern
|
|
11
|
+
shells, glyph shells, sparks, embers, smoke, seekers) is fully deterministic
|
|
12
|
+
given a seed, and pointer- or code-driven. The
|
|
13
|
+
intro "word" burned by the host app (glyph shells spelling out letters) is just
|
|
14
|
+
the generic `glyph` shell fed explicit target points — the component itself
|
|
15
|
+
ships no copy.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```svelte
|
|
20
|
+
<script>
|
|
21
|
+
import { FireworksHdr } from "fancy-ui-svelte";
|
|
22
|
+
|
|
23
|
+
let handle;
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<div class="relative h-screen w-screen bg-black">
|
|
27
|
+
<FireworksHdr
|
|
28
|
+
palette={["#ff2fd6", "#a142ff", "#3d5bff", "#42cfff"]}
|
|
29
|
+
exposure={2.2}
|
|
30
|
+
ambient
|
|
31
|
+
interactive
|
|
32
|
+
onReady={(h) => (handle = h)}
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Drive it programmatically (e.g. pair with `interactive={false}`):
|
|
38
|
+
|
|
39
|
+
```js
|
|
40
|
+
handle.launch({ apex: { x: 0.5, y: 0.3 }, shell: "peony" });
|
|
41
|
+
handle.setAmbient(true, 0.5);
|
|
42
|
+
handle.setKeepClear({ x0: 0.28, y0: 0.3, x1: 0.72, y1: 0.82 });
|
|
43
|
+
handle.setExposure(2.6);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Props
|
|
47
|
+
|
|
48
|
+
| Prop | Type | Default | Description |
|
|
49
|
+
| ---------------------- | ------------------------------------ | ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
50
|
+
| `palette` | `string[]` (hex) | `["#ff2fd6","#a142ff","#3d5bff","#42cfff"]` | Brand hues. Order is irrelevant — parsed and sorted cool→warm (by oklab hue angle) for the shell sweep. |
|
|
51
|
+
| `hdr` | `boolean` | `true` | Opt into the GPU engine (WebGPU HDR first, then a WebGL2 fallback). When `false`, no engine boots at all. |
|
|
52
|
+
| `exposure` | `number` | `2.2` | Display exposure multiplier, clamped to `[1,4]`. |
|
|
53
|
+
| `ambient` | `boolean` | `true` | Run the ambient auto-scheduler (Poisson-timed background shells). |
|
|
54
|
+
| `ambientIntensity` | `number` | `0.35` | Ambient energy `[0,1]` — scales shell size. |
|
|
55
|
+
| `interactive` | `boolean` | `true` | Launch a shell toward the pointer on window `pointerdown`. |
|
|
56
|
+
| `quality` | `"auto" \| "high" \| "mid" \| "low"` | `"auto"` | Particle budget; `auto` picks from the render level + DPR. |
|
|
57
|
+
| `ambientShells` | `ShellKind[]` | — | Restrict the ambient scheduler to these shells, picked uniformly (default: weighted peony/willow/ring). `"glyph"` and `"shape"` are ignored — they need caller points. |
|
|
58
|
+
| `respectReducedMotion` | `boolean` | `true` | Force ambient off under `prefers-reduced-motion` (explicit launches still work). |
|
|
59
|
+
| `class` | `string` | `""` | Extra classes on the canvas wrapper. |
|
|
60
|
+
| `onReady` | `(handle: FireworksHandle) => void` | — | Fired when the engine is live, and again with a fresh handle after a recovered GPU context loss. Never fired when no GPU renderer comes up. |
|
|
61
|
+
| `onLost` | `() => void` | — | Fired once when a GPU context loss could not be recovered; the component has torn itself down and any handle it gave out is inert. |
|
|
62
|
+
|
|
63
|
+
The canvas wrapper is `pointer-events-none` and `aria-hidden` — it is a
|
|
64
|
+
background. `interactive` listens at the window level, so clicks pass through to
|
|
65
|
+
your UI while still launching a shell.
|
|
66
|
+
|
|
67
|
+
## Handle (`FireworksHandle`)
|
|
68
|
+
|
|
69
|
+
The imperative control surface passed to `onReady`. Coordinates are normalized
|
|
70
|
+
`[0,1]`, top-left origin.
|
|
71
|
+
|
|
72
|
+
| Member | Signature | Description |
|
|
73
|
+
| -------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
74
|
+
| `launch` | `(opts: LaunchOptions) => LaunchResult` | Fire a shell; returns the resolved `{ flightMs, breakMs }`. |
|
|
75
|
+
| `setAmbient` | `(on: boolean, intensity?: number) => void` | Toggle the ambient scheduler / set its intensity `[0,1]`. |
|
|
76
|
+
| `setKeepClear` | `(rect: Rect \| null) => void` | Set (or clear) the rect ambient apexes avoid; also soft-dims stray particles that drift in. |
|
|
77
|
+
| `setExposure` | `(v: number) => void` | Update exposure (clamped `[1,4]`). |
|
|
78
|
+
| `renderLevel` | `FireworksRenderLevel` | Which path actually engaged (see below). |
|
|
79
|
+
| `cleanup` | `() => void` | Stop the loop, remove listeners, release GPU memory. |
|
|
80
|
+
|
|
81
|
+
`setKeepClear` drives two things at once: the ambient scheduler steers new apexes
|
|
82
|
+
out of the rect, and the sim soft-dims any spark, ember, or trail that drifts
|
|
83
|
+
inside it — so the area behind a foreground card stays calm without the scheduler
|
|
84
|
+
alone being enough.
|
|
85
|
+
|
|
86
|
+
## Types
|
|
87
|
+
|
|
88
|
+
- `LaunchOptions` — `{ apex, from?, shell?, color?, glyphPoints?, shapePoints?, scale?, flightMs?, intensity?, depth?, seed?, releaseAtMs? }`. `glyphPoints` is **required** when `shell === "glyph"`, `shapePoints` when `shell === "shape"`. `flightMs` (when given) wins over the apex height for timing — apex only sets the horizontal target; omit it and the flight time is solved from gravity. `depth ∈ [0,1]` pushes a shell "back" (dimmer, smaller, desaturated).
|
|
89
|
+
- `LaunchResult` — `{ flightMs, breakMs }` where `breakMs = flightMs + fuse-hang` (when the shell detonates).
|
|
90
|
+
- `ShellKind` — `"peony" | "willow" | "ring" | "heart" | "star" | "shape" | "glyph"`.
|
|
91
|
+
- `QualityTier` — `"high" | "mid" | "low"`.
|
|
92
|
+
- `FireworksRenderLevel` — `"webgpu-hdr" | "webgpu-sdr" | "webgl-p3" | "webgl-sdr" | "none"`.
|
|
93
|
+
|
|
94
|
+
## Pattern shells
|
|
95
|
+
|
|
96
|
+
`heart` and `star` break into a figure instead of a sphere, and `shape` draws
|
|
97
|
+
any closed outline you hand it:
|
|
98
|
+
|
|
99
|
+
```js
|
|
100
|
+
handle.launch({ apex: { x: 0.5, y: 0.3 }, shell: "heart" });
|
|
101
|
+
handle.launch({ apex: { x: 0.5, y: 0.3 }, shell: "star" });
|
|
102
|
+
|
|
103
|
+
// Any closed figure: y-UP points around the origin, scale is free — the outline
|
|
104
|
+
// is normalized to the unit circle and then drawn at the shell's radius.
|
|
105
|
+
handle.launch({
|
|
106
|
+
apex: { x: 0.5, y: 0.3 },
|
|
107
|
+
shell: "shape",
|
|
108
|
+
shapePoints: [
|
|
109
|
+
{ x: 0.1, y: 1 },
|
|
110
|
+
{ x: -0.55, y: 0.05 },
|
|
111
|
+
{ x: -0.1, y: 0.05 },
|
|
112
|
+
{ x: -0.3, y: -1 },
|
|
113
|
+
{ x: 0.5, y: 0.1 },
|
|
114
|
+
{ x: 0.05, y: 0.1 },
|
|
115
|
+
],
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
How it works: the burst is **cut from the outline** rather than from a sphere.
|
|
120
|
+
Each spark leaves the break point in the direction of its outline sample, with a
|
|
121
|
+
speed proportional to that sample's distance from the centre — under linear drag
|
|
122
|
+
every spark coasts `v0 / drag`, so the figure redraws itself in the sky at the
|
|
123
|
+
shell's radius and then droops. Points are walked along the outline's edges, so
|
|
124
|
+
a six-point polygon draws as well as a sampled curve.
|
|
125
|
+
|
|
126
|
+
Pattern shells are marked `crisp`: they drop the ±12% break asymmetry to ±3%,
|
|
127
|
+
carry no stragglers, keep embers at 5%, and give every spark the **same**
|
|
128
|
+
`dragScale` — all four exist to make a peony look natural, and all four read as
|
|
129
|
+
a _broken figure_ on a heart. The drag one matters most: a spark coasts
|
|
130
|
+
`v0 / (drag · dragScale)`, so a per-spark drag spread scales each spark's reach
|
|
131
|
+
independently and the outline never expands as one shape. Radial jitter (±6%)
|
|
132
|
+
stays, because a mathematically perfect outline looks printed.
|
|
133
|
+
|
|
134
|
+
`shapePoints` are walked by **perimeter**, not per vertex, and centred on their
|
|
135
|
+
bounding box: inserting a vertex in the middle of an edge leaves both the figure
|
|
136
|
+
and its particle density unchanged, and a short edge does not collect as many
|
|
137
|
+
sparks as a long one.
|
|
138
|
+
|
|
139
|
+
A `shape` shell launched without `shapePoints` falls back to a plain sphere
|
|
140
|
+
burst rather than vanishing.
|
|
141
|
+
|
|
142
|
+
The ambient scheduler can fire them too:
|
|
143
|
+
|
|
144
|
+
```svelte
|
|
145
|
+
<FireworksHdr ambientShells={["peony", "heart", "star"]} />
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`glyph` and `shape` are ignored in that list — they need points the scheduler
|
|
149
|
+
has no way to invent. The prop is scoped to the scheduler: pointer launches keep
|
|
150
|
+
the stock peony/willow/ring mix.
|
|
151
|
+
|
|
152
|
+
The figure helpers are exported from the package root, so an outline can be
|
|
153
|
+
built without reimplementing them:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
import { heartOutline, starOutline, polygonOutline, outlineBurst } from "fancy-ui-svelte";
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Render levels
|
|
160
|
+
|
|
161
|
+
`renderLevel` reports the path that _actually_ engaged. It is sampled once at
|
|
162
|
+
startup and does **not** follow the window to another display. The value is
|
|
163
|
+
honest — the component never claims HDR it is not delivering.
|
|
164
|
+
|
|
165
|
+
| Level | Meaning |
|
|
166
|
+
| ------------ | --------------------------------------------------------------------------------------------------------------------- |
|
|
167
|
+
| `webgpu-hdr` | WebGPU with extended tone mapping active **and** a `(dynamic-range: high)` display — true brighter-than-white output. |
|
|
168
|
+
| `webgpu-sdr` | WebGPU float16 + P3, but the browser clamped tone mapping or the display is SDR. |
|
|
169
|
+
| `webgl-p3` | WebGL2 fallback with a `display-p3` drawing buffer (wider gamut, no over-white headroom). |
|
|
170
|
+
| `webgl-sdr` | WebGL2 fallback, plain sRGB. |
|
|
171
|
+
| `none` | No GPU rendering available; `onReady` never fires. |
|
|
172
|
+
|
|
173
|
+
Honesty invariants:
|
|
174
|
+
|
|
175
|
+
- `webgpu-hdr` is reported **only** when extended tone mapping reads back as
|
|
176
|
+
active from `getConfiguration()` **and** `matchMedia("(dynamic-range: high)")`
|
|
177
|
+
matches. A WebGPU context that renders float16 + P3 but cannot prove extended
|
|
178
|
+
tone mapping downgrades to `webgpu-sdr`.
|
|
179
|
+
- `webgl-p3` is reported **only** when the `drawingBufferColorSpace = "display-p3"`
|
|
180
|
+
assignment reads back as `display-p3`; otherwise the buffer stays sRGB and the
|
|
181
|
+
level is `webgl-sdr`.
|
|
182
|
+
- `onReady` fires once per live engine, and never when both engines fail. The
|
|
183
|
+
consumer owns the fallback decision (e.g. a short timeout that swaps in a
|
|
184
|
+
static poster) — the component stays silent rather than showing a broken
|
|
185
|
+
canvas.
|
|
186
|
+
- The `sdr` uniform the shaders branch on uses the **same** predicate as
|
|
187
|
+
`webgpu-hdr`. An SDR display therefore always gets the exposure-`1.0`
|
|
188
|
+
soft-knee path, even when the browser did keep extended tone mapping — the
|
|
189
|
+
render level and the pixels can never disagree.
|
|
190
|
+
|
|
191
|
+
### GPU context loss
|
|
192
|
+
|
|
193
|
+
A lost WebGPU device or a `webglcontextlost` event kills the engine: nothing it
|
|
194
|
+
draws lands again. The component detects it on the next frame and recovers
|
|
195
|
+
**once** — WebGPU asks for a fresh device (re-running the WebGPU → WebGL2
|
|
196
|
+
fallback), WebGL waits up to 4 s for `webglcontextrestored`. A successful
|
|
197
|
+
recovery re-seats the sim and fires `onReady` again with a handle whose
|
|
198
|
+
`renderLevel` reflects the engine that came back. If recovery fails, or a second
|
|
199
|
+
loss follows, the component tears itself down — loop stopped, observers and
|
|
200
|
+
listeners removed, GPU memory released — and calls `onLost` so the consumer can
|
|
201
|
+
swap in its static fallback. It never sits there driving a dead engine.
|
|
202
|
+
|
|
203
|
+
On an SDR display the effect is still safe: exposure is pinned to `1.0`, a
|
|
204
|
+
soft-knee compression keeps highlights from clipping, and a small saturation lift
|
|
205
|
+
compensates for the lost range (see _SDR degradation_ below). Everything
|
|
206
|
+
_sub-white_ renders identically on HDR and SDR.
|
|
207
|
+
|
|
208
|
+
## Quality & adaptive downgrade
|
|
209
|
+
|
|
210
|
+
`quality="auto"` resolves a tier from the render level, device-pixel-ratio, and a
|
|
211
|
+
conservative low-power signal:
|
|
212
|
+
|
|
213
|
+
| Condition | Tier |
|
|
214
|
+
| ---------------------------------------- | ------ |
|
|
215
|
+
| container min-dimension `< 480px` | `low` |
|
|
216
|
+
| WebGL2 fallback **and** low-power device | `low` |
|
|
217
|
+
| `webgpu-hdr` | `high` |
|
|
218
|
+
| `webgpu-sdr`, DPR ≥ 2 | `high` |
|
|
219
|
+
| `webgpu-sdr`, DPR < 2 | `mid` |
|
|
220
|
+
| WebGL2 (`webgl-p3` / `webgl-sdr`) | `mid` |
|
|
221
|
+
|
|
222
|
+
Tiers set the particle budget and trail emission rate:
|
|
223
|
+
|
|
224
|
+
| Tier | maxParticles | trailRate (/s) |
|
|
225
|
+
| ------ | ------------ | -------------- |
|
|
226
|
+
| `high` | 4096 | 180 |
|
|
227
|
+
| `mid` | 2560 | 120 |
|
|
228
|
+
| `low` | 1280 | 60 |
|
|
229
|
+
|
|
230
|
+
On top of the tier, a **session-sticky adaptive ladder** watches an EMA of frame
|
|
231
|
+
time (α = 0.1) and drops a notch when the average holds above 24 ms (~<42 fps)
|
|
232
|
+
for ~60 consecutive active frames. It only ever downgrades — never hunts back up
|
|
233
|
+
— and sheds GPU cost before CPU cost:
|
|
234
|
+
|
|
235
|
+
| Step | renderScale | spawnScale | Sheds |
|
|
236
|
+
| ---- | ----------- | ---------- | ------------------------ |
|
|
237
|
+
| 0 | 1.0 | 1.0 | — (full) |
|
|
238
|
+
| 1 | 0.75 | 1.0 | GPU: accumulation buffer |
|
|
239
|
+
| 2 | 0.5 | 1.0 | GPU: accumulation buffer |
|
|
240
|
+
| 3 | 0.5 | 0.66 | CPU: burst spawn density |
|
|
241
|
+
| 4 | 0.5 | 0.4 | CPU: burst spawn density |
|
|
242
|
+
|
|
243
|
+
The pool is fixed-size (frozen at `createSim`), so the ladder never rebuilds it
|
|
244
|
+
mid-show — it shrinks _new burst_ counts instead, which is pop-free. Glyph
|
|
245
|
+
seekers are driven by explicit points and are never scaled, so the intro word
|
|
246
|
+
stays intact under downgrade. Only active foreground frames (particles present,
|
|
247
|
+
not `document.hidden`, not reduced-motion-idle) fold into the average, so
|
|
248
|
+
paused/quiet frames never trigger a spurious drop.
|
|
249
|
+
|
|
250
|
+
## Performance
|
|
251
|
+
|
|
252
|
+
The CPU sim has large headroom; the real budget risk is GPU fill-rate on the HDR
|
|
253
|
+
float path, which is what the adaptive ladder targets first. Measured on Apple
|
|
254
|
+
Silicon at the `high` tier:
|
|
255
|
+
|
|
256
|
+
- **Intro peak** (four glyph shells + garnish): `step()` + `writeInstances()`
|
|
257
|
+
≈ **0.10 ms** mean per frame.
|
|
258
|
+
- **Saturation** (4096 live particles): ≈ **1.03 ms** mean per frame — still well
|
|
259
|
+
under a 60 fps CPU budget, leaving the frame to the GPU.
|
|
260
|
+
|
|
261
|
+
---
|
|
262
|
+
|
|
263
|
+
## Art direction
|
|
264
|
+
|
|
265
|
+
Design rationale for the frame. Brightness values are _encoded linear,
|
|
266
|
+
pre-exposure_; `1.0` = SDR white. Shipped exposure is 2.2 ambient / 2.6 intro on
|
|
267
|
+
HDR; SDR pins exposure to 1.0 with a soft knee.
|
|
268
|
+
|
|
269
|
+
### Palette & color
|
|
270
|
+
|
|
271
|
+
- Family (ping-pong index order): cyan `#42cfff` (196°), blue `#3d5bff` (231°),
|
|
272
|
+
violet `#a142ff` (271°), magenta `#ff2fd6` (312°). Electric blue `#3d5bff` is
|
|
273
|
+
the signature hue.
|
|
274
|
+
- **White-hot life ramp** (life fraction `L`): white hold to `L 0.04–0.11`
|
|
275
|
+
(magnesium bias `#f4f8ff`, per-spark), crossfade white→hue in oklab (never
|
|
276
|
+
grey) completed by `L 0.34`, full hue to `0.72`, then cool and dim to
|
|
277
|
+
warm-white `#ffcf9c`. The magnesium reads as the _detonation_, so handing over
|
|
278
|
+
late leaves the shell white for most of the time anyone is looking at it.
|
|
279
|
+
- **Shell assignment**: 85% single-hue via a ping-pong sweep
|
|
280
|
+
cyan→blue→violet→magenta→violet→blue→cyan; 12% adjacent-duo only
|
|
281
|
+
(`cyan+blue`, `blue+violet`, `violet+magenta`). Never rainbow, never
|
|
282
|
+
cyan+magenta. 6% of a shell's sparks crackle — an on/off chatter at 24 Hz
|
|
283
|
+
(`flickerNoise > 0.5 ? ×1.9 : ×0.25`) instead of a smooth fade.
|
|
284
|
+
- Intro glyph hues: **H = cyan, D = blue, R = violet, ? = magenta**.
|
|
285
|
+
- Hue jitter ±7° per shell, ±3° per spark.
|
|
286
|
+
- Embers are palette-tinted (dimmed, desaturated parent hue), cooling to
|
|
287
|
+
`#ffcf9c` only at low luminance — **not** orange. Smoke is a near-black cool
|
|
288
|
+
grey `#0a0b10` that picks up 8–12% of the nearest shell hue when re-lit.
|
|
289
|
+
- Ascent comet head is white-hot `#fff2e0` with a short tail tinted toward the
|
|
290
|
+
incoming hue.
|
|
291
|
+
|
|
292
|
+
### Brightness ladder (pre-exposure)
|
|
293
|
+
|
|
294
|
+
Intro flash **3.4** · ambient flash **2.1** · fresh spark **2.2** · steady
|
|
295
|
+
**0.9** · terminal **0.15** · hue-hot **2.5 → 1.2** · ascent head **2.8** / tail
|
|
296
|
+
**0.45** · burst streak **0.5** · ember mean **0.35** (flicker ×0.6–2.0 @
|
|
297
|
+
8–14 Hz irregular) · smoke **0.05–0.12** · glyph hold **1.3** · glyph release
|
|
298
|
+
**2.6** · haze ceiling behind the card **≤ 0.18** (hard).
|
|
299
|
+
|
|
300
|
+
- **Must exceed white** (HDR, transient, small area): flashes, fresh sparks,
|
|
301
|
+
comet, glyph release, hue-hot.
|
|
302
|
+
- **Must not bloom**: steady sparks, embers, smoke, haze, streaks. The frame
|
|
303
|
+
reads as mostly deep black with surgical over-white points.
|
|
304
|
+
|
|
305
|
+
### Fades
|
|
306
|
+
|
|
307
|
+
- Spark, two-stage: `L 0→0.7` flash 2.2→0.9 (first ~150 ms drop, then slow burn);
|
|
308
|
+
`L 0.7→1.0` cubic ease-in to 0, shrink ~15%, desaturate warm.
|
|
309
|
+
- Core flash, exponential: 50% @ 60 ms, 10% @ 180 ms, gone by 350 ms; crossfades
|
|
310
|
+
to hue while decaying.
|
|
311
|
+
- Accumulation decay: ascent comet persists 350–500 ms (×0.90–0.93/frame @
|
|
312
|
+
60 fps); burst streaks 150–220 ms (×0.82–0.86). Smoke expands + fades over
|
|
313
|
+
1.5–3 s.
|
|
314
|
+
- Universal: the last 10% of life is multiplied by `smoothstep(1.0, 0.9, L)`;
|
|
315
|
+
alpha and size ease together; nothing is ever removed at non-zero brightness.
|
|
316
|
+
|
|
317
|
+
### Composition & negative space
|
|
318
|
+
|
|
319
|
+
- Launch origin off-screen: `y ∈ [1.02, 1.08]`, `x ∈ [0.08, 0.92]` (ambient x
|
|
320
|
+
weighted to the sides).
|
|
321
|
+
- Ambient apex zones (`x0,y0,x1,y1 · weight`): UL `.04,.08,.34,.40 · .28` · UR
|
|
322
|
+
`.66,.08,.96,.40 · .28` · top-centre `.34,.05,.66,.24 · .18` · left-mid
|
|
323
|
+
`.02,.34,.20,.62 · .13` · right-mid `.80,.34,.98,.62 · .13`.
|
|
324
|
+
- Max simultaneous: ambient 2–3 (rare 4); intro exactly 4, staggered.
|
|
325
|
+
- Burst radius (fraction of min-dimension): ambient 0.10–0.20, feature 0.24,
|
|
326
|
+
glyph 0.14, hard max 0.28.
|
|
327
|
+
- **Keep-clear** rect: desktop `(0.28, 0.30, 0.72, 0.82)`, mobile
|
|
328
|
+
`(0.10, 0.22, 0.90, 0.90)`. Apexes are hard-rejected inside it; drift-in
|
|
329
|
+
particles are soft-dimmed. Rockets may transit thin/fast; their trails dim
|
|
330
|
+
inside. Haze behind the card is capped at 0.18 encoded.
|
|
331
|
+
|
|
332
|
+
### Rhythm
|
|
333
|
+
|
|
334
|
+
- Ambient: Poisson, mean 2200 ms, clamped `[900, 5200]`.
|
|
335
|
+
- Double-launch 18% of the time, +120–320 ms later into a mirrored zone. No
|
|
336
|
+
finales.
|
|
337
|
+
- **Intro (≈2.7 s)**: launches at t = 0 / 180 / 360 / 540 ms; detonations ≈ 700 /
|
|
338
|
+
880 / 1060 / 1300 ms; the "?" dot accent (magenta) ~1450 ms; hold until ~2.0 s
|
|
339
|
+
(word readable 500–600 ms); release cascade 500 ms; card panel-in ~2.3–2.4 s;
|
|
340
|
+
settled ≈ 3.1 s. Post-intro pause 800–1000 ms, then a first small single low
|
|
341
|
+
shell.
|
|
342
|
+
|
|
343
|
+
### Realism anchors
|
|
344
|
+
|
|
345
|
+
Session wind ±0.02–0.05 on ascents and drift · detonation at `vy ≈ 0` plus an
|
|
346
|
+
80–140 ms fuse hang · all sparks ballistic (willow = slower, longer-lived) ·
|
|
347
|
+
smallest embers hit terminal velocity and outlive everything · apex smoke puffs
|
|
348
|
+
drift and are re-lit by later bursts · per-shell depth `∈ [0,1]` dims
|
|
349
|
+
size/brightness/saturation, far shells edge-biased · break asymmetry ±12% plus 5%
|
|
350
|
+
stragglers at 1.4× · color-cooling white→hue→warm-white · crackle strobe ~24 Hz
|
|
351
|
+
(1-in-6 sparks, or the 3% silver shells), 0.2 ↔ 2.4.
|
|
352
|
+
|
|
353
|
+
### SDR degradation
|
|
354
|
+
|
|
355
|
+
Exposure pinned to 1.0 · soft-knee start 0.8, asymptotic to 1.0 · ladder
|
|
356
|
+
unchanged (the over-white separation is simply lost) · bigger-not-brighter: core
|
|
357
|
+
radius ×1.3–1.5 with a longer falloff · crossfade to hue earlier + global
|
|
358
|
+
saturation +10–15% · everything sub-white is identical to HDR.
|
|
359
|
+
|
|
360
|
+
## Physics model
|
|
361
|
+
|
|
362
|
+
A DOM/GPU-free simulation core (`fireworks-shared.ts`). Positions are normalized
|
|
363
|
+
`[0,1]`, top-left, y-down. Velocities and accelerations are in **visual units** —
|
|
364
|
+
screen-heights per second, isotropic — with aspect `A = canvasW / canvasH`
|
|
365
|
+
applied only at horizontal integration (`pos.x += (vel.x / A)·dt`) and when
|
|
366
|
+
converting a normalized position error to visual units. All `step()` randomness
|
|
367
|
+
is seed-derived (deterministic); `Math.random` only ever sits behind an
|
|
368
|
+
injectable `rng()` at spawn/schedule time.
|
|
369
|
+
|
|
370
|
+
### World
|
|
371
|
+
|
|
372
|
+
`G = 3.6` (visual, +y down) · `DT_MAX = 0.033` · launch `y ∈ [1.02, 1.08]`,
|
|
373
|
+
`x ∈ [0.08, 0.92]`. Semi-implicit Euler: `vel += a·dt; pos.x += (vel.x/A)·dt;
|
|
374
|
+
pos.y += vel.y·dt`. Drag: `vel /= (1 + drag·dragScale·dt)`. Death gate:
|
|
375
|
+
`brightnessOut = curve · smoothstep(1.0, 0.9, lifeT)`, `lifeT = age / ttl`.
|
|
376
|
+
|
|
377
|
+
### Per-type behavior
|
|
378
|
+
|
|
379
|
+
| Type | gravScale | drag (1/s) | ttl (s) | size (norm-h) | windScale |
|
|
380
|
+
| ------ | --------- | -------------------- | ----------------- | ------------- | --------- |
|
|
381
|
+
| rocket | 1.0 | 0.6 | flight + hang | 0.004 head | 0.05 |
|
|
382
|
+
| spark | 0.15 | 2.2 (terminal ~0.25) | 0.75–1.3 | 0.0025–0.004 | 0.30 |
|
|
383
|
+
| ember | 0.30 | 2.4 (terminal ~0.45) | 1.4–2.4 | 0.002–0.003 | 0.70 |
|
|
384
|
+
| smoke | −0.05 → 0 | 3.0 | 1.5–3.0 | 0.010 → 0.050 | 1.0 |
|
|
385
|
+
| flash | 0 | 0 | 0.05–0.09 | 0.025–0.05 | 0 |
|
|
386
|
+
| seeker | 0 → 1.0 | 1.2 (pop) | phased (0.9 rel.) | 0.003–0.005 | 0.4 |
|
|
387
|
+
|
|
388
|
+
Debris gravity is a **fraction** of the rocket's, and that is a look decision,
|
|
389
|
+
not a physical one: a spark's terminal velocity is `gravScale·G / drag`, and at
|
|
390
|
+
full gravity that is several times the burst's own expansion speed — the shell
|
|
391
|
+
never opens into a sphere, it rains straight down as a fountain. Ember gravity
|
|
392
|
+
stays above spark gravity so a willow still droops harder than a peony. Each
|
|
393
|
+
non-straggler spark also draws its own `dragScale ∈ [0.85, 1.30]`: identical
|
|
394
|
+
drag makes every spark fall at exactly the same speed, which combs the debris
|
|
395
|
+
into a parallel curtain.
|
|
396
|
+
|
|
397
|
+
Session wind is seeded once: `wx ∈ ±[0.02, 0.05]`, `wy ∈ ±[0.01, 0.02]` visual
|
|
398
|
+
accel, applied as `a += w · windScale[type]`.
|
|
399
|
+
|
|
400
|
+
### Deterministic flicker
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
hash11(x) = fract(sin(x·127.1)·43758.5453)
|
|
404
|
+
flickerNoise(seed, age, freq): t = age·freq; i = floor(t); f = t − i;
|
|
405
|
+
a = hash11(seed+i); b = hash11(seed+i+1); u = f·f·(3−2f); return a + (b−a)·u
|
|
406
|
+
ember mul = 0.6 + 1.4·flickerNoise(seed, age, 8 + 6·hash11(seed))
|
|
407
|
+
crackle = flickerNoise(seed, age, 24) > 0.5 ? hi : lo
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Shells
|
|
411
|
+
|
|
412
|
+
| Kind | high | mid | low | ember frac | smoke | radius (×scale) | builder |
|
|
413
|
+
| ------ | ----------- | ---- | ---- | ---------- | ----- | --------------------- | ----------- |
|
|
414
|
+
| peony | 320 | 200 | 120 | 0.15 | 1 | 0.10–0.20 (feat 0.24) | sphereBurst |
|
|
415
|
+
| willow | 200 | 130 | 80 | 0.80 | 1–2 | 0.14–0.22 | willowBurst |
|
|
416
|
+
| ring | 180 | 120 | 70 | 0.10 | 0 | 0.12–0.20 | ringBurst |
|
|
417
|
+
| glyph | pts+garnish | +25% | +15% | garnish | 1 | 0.14 | glyphPoints |
|
|
418
|
+
|
|
419
|
+
Break asymmetry ±12% via `hash11(seed+i)`; 5% stragglers at 1.4× speed,
|
|
420
|
+
`dragScale ×0.7`. Depth `d`: `size ×(1 − 0.5d)`, `brightness ×(1 − 0.45d)`,
|
|
421
|
+
`saturation ×(1 − 0.35d)`, `burst radius ×(1 − 0.25d)`. The dim rides on the
|
|
422
|
+
particle (pool field `depthDim`) and multiplies whatever the type's brightness
|
|
423
|
+
curve resolves to each frame, so it reaches sparks, embers, smoke, the
|
|
424
|
+
detonation flash, and glyph seekers alike — not just the spawn value.
|
|
425
|
+
|
|
426
|
+
### Rockets
|
|
427
|
+
|
|
428
|
+
`solveLaunch` inverts the same model `integrate` runs the rocket through —
|
|
429
|
+
gravity `G`, linear drag `k = 0.6`, the session wind (rocket `windScale`), and
|
|
430
|
+
the `pos.x += (vel.x / A)·dt` aspect divide — so the shell breaks at the apex it
|
|
431
|
+
was asked for. Vertically, `vu(t) = (vu0 + g/k)·e^(−kt) − g/k`: given `flightMs`
|
|
432
|
+
that solves to `vu0 = (g/k)·(e^(k·flight) − 1)` (`vy = 0` exactly at flight time,
|
|
433
|
+
apex only sets the horizontal target); otherwise `vu0` is Newton-solved from the
|
|
434
|
+
rise `vu0/k − (g/k²)·ln(1 + k·vu0/g) = from.y − apex.y` and the flight follows
|
|
435
|
+
from it. Horizontally `Δx·A = (ax/k)·t + (vx0 − ax/k)·(1 − e^(−kt))/k`. Passing
|
|
436
|
+
no medium (`drag = 0`, `A = 1`, no wind) collapses both back to the undamped
|
|
437
|
+
`flight = sqrt(2·(from.y − apex.y)/G)`, `v0x = (apex.x − from.x)/flight` forms.
|
|
438
|
+
A ballistic solve fed to the damped integrator undershoots the apex by ~25% of
|
|
439
|
+
the rise and misses horizontally on any non-square canvas.
|
|
440
|
+
|
|
441
|
+
Fuse hang 80–140 ms seeded (the intro "?" uses
|
|
442
|
+
140); `breakMs = flightMs + hangMs`. At break: enqueue the shell at the current
|
|
443
|
+
position plus one flash, then kill the rocket. Trail uses a fractional
|
|
444
|
+
accumulator (`emit += trailRate·dt; while (emit ≥ 1) { spawn; emit-- }`) — trail
|
|
445
|
+
sparks are ttl 0.22–0.45, size 0.0018, ascent-tail brightness, and scatter
|
|
446
|
+
laterally (`±0.035` x, `±0.02` y) — a rigidly co-linear trail reads as a drawn
|
|
447
|
+
wire rather than a comet shedding sparks. The head is ascent-head brightness,
|
|
448
|
+
and the rocket's **position** (never its velocity) carries a 11 Hz lateral
|
|
449
|
+
wobble of `0.045`/s so the climb is not a ruler-straight column; leaving the
|
|
450
|
+
velocity alone keeps the solved apex intact. The trail hue is the comet head mixed 25%
|
|
451
|
+
toward **that launch's** first shell hue (stored on the launch spec), so a
|
|
452
|
+
branded palette never gets the built-in cyan on its ascent.
|
|
453
|
+
|
|
454
|
+
### Glyph seekers
|
|
455
|
+
|
|
456
|
+
1. **Pop** (0–80 ms): velocity toward the seeker's own target, magnitude
|
|
457
|
+
`dist / 0.08`; spring off, drag 1.2, gravity off.
|
|
458
|
+
2. **Spring** (ω = 6, ζ = 0.9), per axis in visual units:
|
|
459
|
+
`a = −ω²(p − target) − 2ζω·v`. Stable (`ω·dt = 0.198 ≪ 2` at 33 ms).
|
|
460
|
+
3. **Hold** until `releaseAt`: brightness `1.3·(0.85 + 0.3·flicker)`.
|
|
461
|
+
4. **Release**: spring off, gravity → 1.0, pop-2.6 decay, ttl 0.9 s, two-stage
|
|
462
|
+
fade. The sim self-triggers release from `releaseAtMs`, so the renderer stays
|
|
463
|
+
agnostic.
|
|
464
|
+
|
|
465
|
+
### Per-frame pipeline (`step`)
|
|
466
|
+
|
|
467
|
+
1. Clamp `dt`.
|
|
468
|
+
2. Drain the spawn queue (previous-frame detonations; over capacity →
|
|
469
|
+
`droppedSpawns++`).
|
|
470
|
+
3. Per live particle: apply accel (gravity + wind) → type behavior (rocket
|
|
471
|
+
trail/break; seeker phases; smoke expand; spark two-stage + life-color; ember
|
|
472
|
+
flicker + terminal velocity; flash exp decay) → integrate → age → brightness
|
|
473
|
+
`= curve · smoothstep(1, 0.9, lifeT)` → dead → **swap-remove** (re-examine the
|
|
474
|
+
swapped index).
|
|
475
|
+
4. `writeInstances`.
|
|
476
|
+
|
|
477
|
+
**Instance layout (8 floats)**: `[posX, posY, size, r·bright, g·bright, b·bright,
|
|
478
|
+
velX·STRETCH, velY·STRETCH]` with `STRETCH = 0.010 s`. The renderer draws a
|
|
479
|
+
velocity-stretched quad (major axis from the velocity terms, capped at 2.5× the
|
|
480
|
+
minor), two-lobe gaussian (hot core + soft halo), additive. Swap-remove keeps
|
|
481
|
+
the pool contiguous — no freelist; allocate at `count++`, reject at capacity.
|
|
482
|
+
|
|
483
|
+
Two things about that quad are load-bearing and identical in both renderers:
|
|
484
|
+
|
|
485
|
+
- **`QUAD_PAD = 1.35`.** The quad is oversized and the gaussian is windowed to
|
|
486
|
+
exactly zero at its edge (`smoothstep(PAD, 0.6·PAD, r)`). Unwindowed, the halo
|
|
487
|
+
still carries ~7% of its peak where the geometry stops, and additive blending
|
|
488
|
+
renders that step as a **hard-edged square** around anything bright — a
|
|
489
|
+
detonation flash reads as a lit box, not a glow.
|
|
490
|
+
- **Stretch is deliberately short.** The accumulation buffer already smears a
|
|
491
|
+
moving particle into a line; a long per-instance stretch on top of it turns
|
|
492
|
+
every spark into a needle.
|