@dopaminefx/effect-heartburst 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Heartburst (the love / like / favorite success effect) — the DATA-DRIVEN
3
+ * panel factory shim.
4
+ *
5
+ * Everything that isn't the shader or the Canvas2D draw is DATA in
6
+ * heartburst.dope.json, interpreted by the shared backbone:
7
+ * - the mood→params mapping + the warm OKLCH golden-angle palette (the loader),
8
+ * - the per-frame lub-dub/burst logic (`tempo.frame` — amp + the
9
+ * presence/beat/burst/flash extras, delta-0 with the old hand hooks),
10
+ * - the shadow height (`render.shadowHeightFrac`), the dpr-scaled halftone
11
+ * cell (`render.pass`), the panel wiring (`render.panel`), the no-snap
12
+ * clock (`render.config.stepping: "none"`), and `tempo.reducedMotion`,
13
+ * - the uniform `binding` contract (the `u<Name>` list + exceptions).
14
+ *
15
+ * The genuinely code-shaped parts that stay JS are the GLSL
16
+ * (heartburst-shader.ts — the single source the MSL + Kotlin shaders are
17
+ * generated from) and the Canvas2D panel draw (heartburst-renderer.ts).
18
+ */
19
+ export type { HeartburstRenderParams } from "./heartburst-renderer.js";
20
+ export declare const heartburst: import("@dopaminefx/core").EffectFactory<import("@dopaminefx/core").PassParams>;
21
+ export default heartburst;
22
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAUH,YAAY,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAMvE,eAAO,MAAM,UAAU,iFAItB,CAAC;AAEF,eAAe,UAAU,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Heartburst (the love / like / favorite success effect) — the DATA-DRIVEN
3
+ * panel factory shim.
4
+ *
5
+ * Everything that isn't the shader or the Canvas2D draw is DATA in
6
+ * heartburst.dope.json, interpreted by the shared backbone:
7
+ * - the mood→params mapping + the warm OKLCH golden-angle palette (the loader),
8
+ * - the per-frame lub-dub/burst logic (`tempo.frame` — amp + the
9
+ * presence/beat/burst/flash extras, delta-0 with the old hand hooks),
10
+ * - the shadow height (`render.shadowHeightFrac`), the dpr-scaled halftone
11
+ * cell (`render.pass`), the panel wiring (`render.panel`), the no-snap
12
+ * clock (`render.config.stepping: "none"`), and `tempo.reducedMotion`,
13
+ * - the uniform `binding` contract (the `u<Name>` list + exceptions).
14
+ *
15
+ * The genuinely code-shaped parts that stay JS are the GLSL
16
+ * (heartburst-shader.ts — the single source the MSL + Kotlin shaders are
17
+ * generated from) and the Canvas2D panel draw (heartburst-renderer.ts).
18
+ */
19
+ import { HEARTBURST_FRAGMENT_SRC, HEARTBURST_VERTEX_SRC, } from "./heartburst-shader.js";
20
+ import { drawHeartburstFrame } from "./heartburst-renderer.js";
21
+ import { parseDope, registerDopePanelEffect } from "@dopaminefx/core";
22
+ import doc from "./heartburst.dope.json";
23
+ const DOPE = parseDope(doc);
24
+ // Registers the EffectFactory AND the bundled "heartburst" program (so
25
+ // loadEffect() can bind a host-authored heartburst variant with no code).
26
+ export const heartburst = registerDopePanelEffect(DOPE, { vertex: HEARTBURST_VERTEX_SRC, fragment: HEARTBURST_FRAGMENT_SRC }, drawHeartburstFrame);
27
+ export default heartburst;
28
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EACL,uBAAuB,EACvB,qBAAqB,GACtB,MAAM,wBAAwB,CAAC;AAChC,OAAO,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAC;AAC/D,OAAO,EAAE,SAAS,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AACtE,OAAO,GAAG,MAAM,wBAAwB,CAAC;AAIzC,MAAM,IAAI,GAAG,SAAS,CAAC,GAAa,CAAC,CAAC;AAEtC,uEAAuE;AACvE,0EAA0E;AAC1E,MAAM,CAAC,MAAM,UAAU,GAAG,uBAAuB,CAC/C,IAAI,EACJ,EAAE,MAAM,EAAE,qBAAqB,EAAE,QAAQ,EAAE,uBAAuB,EAAE,EACpE,mBAAmB,CACpB,CAAC;AAEF,eAAe,UAAU,CAAC"}
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@dopaminefx/effect-heartburst",
3
+ "version": "0.1.0",
4
+ "description": "Heartburst — a lub-dub heart-burst love/favorite effect for Dopamine.",
5
+ "keywords": [
6
+ "dopamine-effect"
7
+ ],
8
+ "type": "module",
9
+ "main": "./dist/index.js",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src"
21
+ ],
22
+ "sideEffects": [
23
+ "./src/index.ts",
24
+ "./dist/index.js"
25
+ ],
26
+ "scripts": {
27
+ "build": "tsc -p tsconfig.json"
28
+ },
29
+ "dependencies": {
30
+ "@dopaminefx/core": "^0.1.0"
31
+ },
32
+ "license": "MIT",
33
+ "author": "10in30",
34
+ "homepage": "https://github.com/10in30/dopamine#readme",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/10in30/dopamine.git",
38
+ "directory": "effects/heartburst/web"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/10in30/dopamine/issues"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Heartburst Canvas2D PANEL drawing.
3
+ *
4
+ * The crisp, vector-y parts of Heartburst — the big swelling hero heart and the
5
+ * flurry of little hearts that fly out on the burst — are drawn into an OFFSCREEN
6
+ * Canvas2D each frame (cheap: a couple dozen filled paths) and uploaded as the
7
+ * "panel" texture; the fragment shader (heartburst-shader.ts) adds the soft warm
8
+ * bloom, the gloss highlight, the halftone blush, the noir↔pop styling, the beat
9
+ * flash and casts the warm light + soft shadow.
10
+ *
11
+ * Both the hero and the little hearts are a single parametric HEART CURVE (the
12
+ * classic `16sin³t` cardioid-ish heart) traced as a Canvas2D path, so the form
13
+ * is true vector geometry the GPU can't easily do.
14
+ *
15
+ * Panel channel encoding consumed by the shader:
16
+ * R = hero heart FILL · G = INK (outline) + gloss seed · B = burst hearts FILL
17
+ */
18
+
19
+ import { easeOutCubic, clamp01, mulberry32, type PanelDraw } from "@dopaminefx/core";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // DRAW-side tempo. The per-frame UNIFORM logic (amp/presence/beat/burst/flash)
23
+ // is DATA — `tempo.frame` in heartburst.dope.json, evaluated by the core
24
+ // frame-expr evaluator (pinned delta-0 against these functions by the tests).
25
+ // The same curves are ALSO needed by the panel GEOMETRY (the hero swells with
26
+ // the beat, the little hearts fly out with the burst), and panel geometry is
27
+ // code by design — so the draw-side copies live here, next to the draw.
28
+ //
29
+ // life 0.00 .. 0.30 : LUB-DUB — two beats; the second tucked behind the first
30
+ // life 0.30 .. 1.00 : BURST + AFTERGLOW — little hearts fly out, hero fades
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /** Fraction of life occupied by the lub-dub beat phase before the burst. */
34
+ export const HEARTBEAT_PHASE = 0.3;
35
+
36
+ /**
37
+ * A single soft beat pulse centred at `center` (in life units) with half-width
38
+ * `width`: rises fast, eases back down. Returns 0..1 (peak 1 at `center`).
39
+ */
40
+ function beatPulse(t: number, center: number, width: number): number {
41
+ const x = (t - center) / width;
42
+ if (x <= -1 || x >= 1) return 0;
43
+ const lobe = 0.5 + 0.5 * Math.cos(x * Math.PI);
44
+ return x < 0 ? Math.pow(lobe, 0.7) : Math.pow(lobe, 1.4);
45
+ }
46
+
47
+ /**
48
+ * Heart SCALE multiplier over normalized life. A resting 1.0 with two beats
49
+ * superimposed, then it settles to rest through the burst and gently shrinks as
50
+ * it fades. `strength` scales beat swell; `doubleBeat` blends single → lub-dub.
51
+ */
52
+ export function heartbeatScale(life: number, strength = 1, doubleBeat = 1): number {
53
+ const t = clamp01(life);
54
+ const lub = beatPulse(t, 0.1, 0.1);
55
+ const dub = beatPulse(t, 0.21, 0.075) * 0.62 * clamp01(doubleBeat);
56
+ const beat = Math.max(lub, dub);
57
+ const sag = t > HEARTBEAT_PHASE ? 0.06 * easeOutCubic((t - HEARTBEAT_PHASE) / (1 - HEARTBEAT_PHASE)) : 0;
58
+ return 1 + beat * 0.42 * strength - sag;
59
+ }
60
+
61
+ /**
62
+ * The amplitude/energy envelope (→ uAmp + shadow strength). Tracks the beats
63
+ * during the lub-dub then a bright flare at the burst, decaying through the
64
+ * afterglow. NOTE: shipped as DATA (`tempo.frame.amp`); this copy exists only
65
+ * so the tests can pin the data delta-0 against the readable formula.
66
+ */
67
+ export function heartburstEnvelope(life: number, strength = 1, doubleBeat = 1): number {
68
+ const t = clamp01(life);
69
+ if (t <= 0 || t >= 1) return 0;
70
+ const lub = beatPulse(t, 0.1, 0.1);
71
+ const dub = beatPulse(t, 0.21, 0.075) * 0.62 * clamp01(doubleBeat);
72
+ const beats = Math.max(lub, dub) * 0.9 * strength;
73
+ const b = burstProgress(life);
74
+ const flare = b * Math.pow(1 - b, 1.1) * 2.4;
75
+ return clamp01(Math.max(beats, flare * (0.7 + 0.3 * strength)));
76
+ }
77
+
78
+ /**
79
+ * Burst progress 0..1 over the post-beat phase: 0 until the dub finishes, then
80
+ * eases out to 1 as the little hearts fly out and fade.
81
+ */
82
+ export function burstProgress(life: number): number {
83
+ const t = clamp01(life);
84
+ if (t <= HEARTBEAT_PHASE) return 0;
85
+ return easeOutCubic((t - HEARTBEAT_PHASE) / (1 - HEARTBEAT_PHASE));
86
+ }
87
+
88
+ /**
89
+ * Overall panel presence over normalized life: a quick snap-in, a proud hold
90
+ * through the beats + burst, then a clean fade at the tail so the panel clears.
91
+ */
92
+ export function heartPresence(life: number): number {
93
+ const t = life < 0 ? 0 : life > 1 ? 1 : life;
94
+ if (t < 0.04) return t / 0.04;
95
+ if (t < 0.8) return 1;
96
+ const fade = 1 - (t - 0.8) / 0.2;
97
+ return Math.pow(Math.max(0, fade), 1.4);
98
+ }
99
+
100
+ /** Resolved render params Heartburst's panel + shader consume. */
101
+ export interface HeartburstRenderParams {
102
+ durationMs: number;
103
+ palette: unknown;
104
+ style: number; // = whimsy (photoreal heart 0 -> flat cel sticker 1)
105
+ heartburstSeed: number; // per-fire scatter offset
106
+ heartScale: number; // hero heart size as fraction of min canvas dim
107
+ burstCount: number; // number of little hearts in the flurry
108
+ burstSpread: number; // how far the little hearts fly (fraction of min dim)
109
+ inkWeight: number; // outline weight (device-independent base)
110
+ beatStrength: number; // beat swell magnitude (intensity)
111
+ doubleBeat: number; // 0 single gentle pulse -> 1 full lub-dub
112
+ dotSize: number; // halftone cell size (dpr-scaled in the shader pass)
113
+ exposure: number; // cast-light gain (shader uniform)
114
+ glow: number; // soft bloom (shader uniform)
115
+ gloss: number; // specular gloss (shader uniform)
116
+ halftone: number; // halftone blush strength (shader uniform)
117
+ saturation: number; // panel saturation (shader uniform)
118
+ }
119
+
120
+ /**
121
+ * Trace a parametric heart of half-size `s` centred at the current origin into
122
+ * the given context's current path. `rot` rotates it (radians). The classic
123
+ * heart curve, normalized so its bounding extent ≈ `s` and the cusp points UP.
124
+ */
125
+ /** Hero-heart size relative to the targeted element box (≈1.5×). See the Swift
126
+ * `HEARTBURST_TARGET_FILL` — keep the two in sync. */
127
+ const HEARTBURST_TARGET_FILL = 3.6;
128
+
129
+ function traceHeart(ctx: CanvasRenderingContext2D, s: number, rot: number): void {
130
+ const steps = 48;
131
+ ctx.beginPath();
132
+ for (let i = 0; i <= steps; i++) {
133
+ const t = (i / steps) * Math.PI * 2;
134
+ // x in [-16,16], y in roughly [-17,12] for the standard curve.
135
+ const x = 16 * Math.pow(Math.sin(t), 3);
136
+ const y =
137
+ 13 * Math.cos(t) -
138
+ 5 * Math.cos(2 * t) -
139
+ 2 * Math.cos(3 * t) -
140
+ Math.cos(4 * t);
141
+ // Normalize to ~[-1,1] and flip Y so the lobes are at the top (canvas y-down).
142
+ const nx = (x / 17) * s;
143
+ const ny = (-y / 17) * s;
144
+ const cx = nx * Math.cos(rot) - ny * Math.sin(rot);
145
+ const cy = nx * Math.sin(rot) + ny * Math.cos(rot);
146
+ if (i === 0) ctx.moveTo(cx, cy);
147
+ else ctx.lineTo(cx, cy);
148
+ }
149
+ ctx.closePath();
150
+ }
151
+
152
+ /**
153
+ * Draw the offscreen panel for this frame.
154
+ * - the hero heart at the current beat scale, fill in RED, outline in GREEN,
155
+ * plus a gloss-highlight blob painted into GREEN that sits ON the fill (the
156
+ * shader reads ink∩fill as the specular seed),
157
+ * - the little burst hearts flying outward along seeded arcs, fill in BLUE.
158
+ *
159
+ * `heartScaleMul` is the lub-dub beat multiplier on the hero (1 at rest).
160
+ * `presence` fades the whole panel in/out.
161
+ */
162
+ /**
163
+ * The per-frame panel draw in the generic `PanelDraw` shape — the ONE
164
+ * code-shaped hook the data-driven factory wires (`registerDopePanelEffect`).
165
+ * Computes the draw-side tempo (beat scale, presence, target span) and hands
166
+ * off to {@link drawHeartburstPanel}.
167
+ */
168
+ export const drawHeartburstFrame: PanelDraw = (pctx, w, h, params, info) => {
169
+ const p = params as unknown as HeartburstRenderParams;
170
+ const scale = heartbeatScale(info.life, p.beatStrength, p.doubleBeat);
171
+ const presence = heartPresence(info.life);
172
+ const span = Math.min(info.targetPx.width, info.targetPx.height);
173
+ drawHeartburstPanel(pctx, w, h, p, scale, info.life, presence, info.dpr, info.centerPx, span);
174
+ };
175
+
176
+ export function drawHeartburstPanel(
177
+ ctx: CanvasRenderingContext2D,
178
+ w: number,
179
+ h: number,
180
+ params: HeartburstRenderParams,
181
+ heartScaleMul: number,
182
+ life: number,
183
+ presence: number,
184
+ dpr: number,
185
+ center: { x: number; y: number },
186
+ span: number,
187
+ ): void {
188
+ ctx.clearRect(0, 0, w, h);
189
+ if (presence <= 0.001) return;
190
+
191
+ // Position the hearts on the targeted element (centre) and size them to its box
192
+ // (`span`), so the centrepiece matches the page element instead of the canvas.
193
+ const cx = center.x;
194
+ const cy = center.y;
195
+ // The hero heart reads at ~150% of the targeted element (heartScale ~0.22 ⇒
196
+ // heart extent ≈ 1.5× the box), clamped to the canvas so a full-page fire
197
+ // (target == canvas) keeps its original size. Sync w/ HeartburstPanel.swift.
198
+ const minDim = Math.min(span * HEARTBURST_TARGET_FILL, Math.min(w, h));
199
+ const seed = (params.heartburstSeed * 1000) >>> 0;
200
+ const rng = mulberry32(seed);
201
+
202
+ const ink = Math.max(1, params.inkWeight * dpr);
203
+
204
+ // ---------- HERO HEART (R fill, G outline + gloss seed) ------------------
205
+ const heroS = minDim * params.heartScale * heartScaleMul;
206
+ // a tiny per-fire tilt so it feels hand-placed.
207
+ const tilt = ((params.heartburstSeed % 1) - 0.5) * 0.12;
208
+
209
+ // As the burst takes over, the hero heart shrinks/cracks open a touch so the
210
+ // little hearts read as having spilled OUT of it.
211
+ const b = burstProgress(life);
212
+ const heroPresence = presence * (1 - 0.65 * b);
213
+
214
+ ctx.save();
215
+ ctx.translate(cx, cy);
216
+ ctx.globalCompositeOperation = "lighter"; // additive into channels
217
+
218
+ if (heroPresence > 0.002) {
219
+ const heroFillA = Math.round(255 * heroPresence);
220
+ // FILL -> RED.
221
+ traceHeart(ctx, heroS, tilt);
222
+ ctx.fillStyle = `rgba(${heroFillA},0,0,1)`;
223
+ ctx.fill();
224
+
225
+ // OUTLINE -> GREEN.
226
+ traceHeart(ctx, heroS, tilt);
227
+ ctx.lineJoin = "round";
228
+ ctx.lineWidth = ink * 1.6;
229
+ ctx.strokeStyle = `rgba(0,${heroFillA},0,1)`;
230
+ ctx.stroke();
231
+
232
+ // GLOSS SEED -> GREEN, painted ON the fill (upper-left lobe). The shader
233
+ // reads ink∩fill as the specular highlight, so a soft blob here becomes the
234
+ // gel-heart shine. Clip to the heart so it never bleeds past the silhouette.
235
+ ctx.save();
236
+ traceHeart(ctx, heroS, tilt);
237
+ ctx.clip();
238
+ const gx = -heroS * 0.34;
239
+ const gy = -heroS * 0.42;
240
+ const gr = heroS * 0.42;
241
+ const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, gr);
242
+ grad.addColorStop(0, `rgba(0,${heroFillA},0,1)`);
243
+ grad.addColorStop(1, "rgba(0,0,0,0)");
244
+ ctx.fillStyle = grad;
245
+ ctx.beginPath();
246
+ ctx.arc(gx, gy, gr, 0, Math.PI * 2);
247
+ ctx.fill();
248
+ ctx.restore();
249
+ }
250
+ ctx.restore();
251
+
252
+ // ---------- BURST HEARTS (B fill) ----------------------------------------
253
+ // A flurry of little hearts fly outward along seeded directions, arc under a
254
+ // little "gravity", spin, and shrink as they go. Fully crisp vector hearts.
255
+ if (b > 0.001) {
256
+ const count = Math.max(0, Math.round(params.burstCount));
257
+ const maxDist = minDim * params.burstSpread;
258
+ ctx.save();
259
+ ctx.globalCompositeOperation = "lighter";
260
+ for (let i = 0; i < count; i++) {
261
+ // deterministic per-heart launch params.
262
+ const ang = (i / count) * Math.PI * 2 + (rng() - 0.5) * 0.9;
263
+ const speed = 0.55 + rng() * 0.45; // some fly farther
264
+ const spin = (rng() - 0.5) * 2.0;
265
+ const littleS = minDim * (0.035 + rng() * 0.04) * params.heartScale * 1.6;
266
+ // staggered launch so they don't all leave at once.
267
+ const stagger = rng() * 0.25;
268
+ const lp = Math.max(0, Math.min(1, (b - stagger) / (1 - stagger)));
269
+ if (lp <= 0) continue;
270
+ const dist = maxDist * speed * lp;
271
+ // arc: a parabola so they rise then fall slightly.
272
+ const arc = minDim * 0.10 * speed * (lp - lp * lp) * 4.0;
273
+ const px = cx + Math.cos(ang) * dist;
274
+ const py = cy + Math.sin(ang) * dist - arc;
275
+ // fade + shrink late in the flight.
276
+ const fade = 1 - Math.pow(lp, 2.2);
277
+ if (fade <= 0.01) continue;
278
+ const a = Math.round(255 * presence * fade);
279
+ const s = littleS * (0.6 + 0.4 * (1 - lp));
280
+ ctx.save();
281
+ ctx.translate(px, py);
282
+ traceHeart(ctx, s, spin * lp * Math.PI);
283
+ ctx.fillStyle = `rgba(0,0,${a},1)`;
284
+ ctx.fill();
285
+ ctx.restore();
286
+ }
287
+ ctx.restore();
288
+ }
289
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * GLSL ES 3.00 source for **Heartburst** — Dopamine's love / like / favorite
3
+ * moment: a heart swells with a double-beat (lub-dub) then BURSTS into a flurry
4
+ * of small hearts that fly outward, arc, and fade.
5
+ *
6
+ * This is a HYBRID (Canvas2D-panel) effect. The crisp vector hearts — the big
7
+ * hero heart and the little burst hearts — are drawn with a parametric heart
8
+ * curve into an OFFSCREEN Canvas2D each frame (heartburst-renderer.ts) and
9
+ * handed to this shader as a single "panel" texture. The shader then does
10
+ * everything that wants to be procedural / screen-space:
11
+ * - a soft warm BLOOM behind the heart (the love glow),
12
+ * - a tight GLOSS / specular highlight on the hero heart at the photoreal end,
13
+ * - the NOIR ↔ POP styling: a moody near-monochrome heart with one warm spot
14
+ * color (noir) → a flat saturated cel "sticker" heart with a hard rim and
15
+ * halftone blush (pop), keyed off uStyle/uSaturation,
16
+ * - a hot beat FLASH that throws warm light onto the page (the cast).
17
+ *
18
+ * Everything is summed as light (canvas is black, composited via
19
+ * `mix-blend-mode: screen`, so black == no change, bright == cast light).
20
+ *
21
+ * Panel texture channel encoding (see heartburst-renderer.ts):
22
+ * R = hero heart FILL mask (the big swelling heart's interior)
23
+ * G = INK / contour mask (heart outline + the gloss seed highlight)
24
+ * B = burst hearts FILL (all the little flying hearts)
25
+ * A = unused
26
+ *
27
+ * Pure function of uniforms → frame-perfect & cheap under SwiftShader.
28
+ */
29
+
30
+ import {
31
+ GLSL_CONSTANTS,
32
+ GLSL_DITHER,
33
+ GLSL_HALFTONE,
34
+ GLSL_HASH,
35
+ GLSL_ROT2,
36
+ GLSL_TONEMAP_ACES,
37
+ } from "@dopaminefx/core";
38
+
39
+ export const HEARTBURST_VERTEX_SRC = /* glsl */ `#version 300 es
40
+ out vec2 vUv;
41
+ void main() {
42
+ vec2 pos = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
43
+ vUv = pos;
44
+ gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
45
+ }`;
46
+
47
+ export const HEARTBURST_FRAGMENT_SRC = /* glsl */ `#version 300 es
48
+ precision highp float;
49
+ in vec2 vUv;
50
+ out vec4 fragColor;
51
+
52
+ uniform sampler2D uPanel; // R=heartFill G=ink B=burstFill
53
+ uniform vec2 uResolution; // device pixels
54
+ uniform vec2 uOrigin; // heart centre (the anchor), device px
55
+ uniform float uLife; // whole-effect progress 0..1
56
+ uniform float uTimeS; // elapsed seconds
57
+ uniform float uPresence; // panel opacity / presence 0..1
58
+ uniform float uBeat; // 0..1 current beat amplitude (lub-dub thump)
59
+ uniform float uBurst; // 0..1 burst progress (little hearts flying out)
60
+ uniform float uFlash; // 0..1 warm beat/burst flash amount
61
+ uniform float uExposure; // cast-light gain
62
+ uniform float uGlow; // 0..1 soft bloom radius/strength behind the heart
63
+ uniform float uGloss; // 0..1 specular gloss on the hero heart (photoreal)
64
+ uniform float uHalftone; // 0..1 Ben-Day blush dot strength (pop)
65
+ uniform float uDotSize; // halftone cell size in device px
66
+ uniform float uSaturation; // 0..1 panel color saturation (noir->pop)
67
+ uniform float uSeed; // per-fire hash
68
+ uniform float uStyle; // 0..1 photoreal/noir -> flat cel sticker (whimsy)
69
+ uniform float uShadow; // 0 = light pass (screen), 1 = shadow pass (multiply)
70
+ uniform vec2 uShadowOffset; // device-px offset of the cast silhouette
71
+ uniform float uShadowSoft; // penumbra softness in device px
72
+ uniform float uShadowStrength;// 0..1 max darkening of the multiply layer
73
+ uniform vec3 uC0; // hero heart core color (warm red)
74
+ uniform vec3 uC1; // heart shade / burst color (pink/coral)
75
+ uniform vec3 uC2; // accent / glow / blush color
76
+
77
+ ${GLSL_CONSTANTS}
78
+ ${GLSL_HASH}
79
+ ${GLSL_ROT2}
80
+ ${GLSL_HALFTONE}
81
+ ${GLSL_TONEMAP_ACES}
82
+ ${GLSL_DITHER}
83
+
84
+ void main(){
85
+ vec2 frag = vUv * uResolution;
86
+ vec2 res = uResolution;
87
+ float minDim = min(res.x, res.y);
88
+
89
+ // ---- SHADOW PASS (multiply layer) ---------------------------------------
90
+ // Cheap occlusion: the panel's solid forms (hero + burst fills) sampled at an
91
+ // offset toward the implied key light, with a small ring blur for a penumbra.
92
+ // White = no shadow (multiply identity); darker = cast shadow. Presence fades
93
+ // it with the effect.
94
+ if (uShadow > 0.5) {
95
+ vec2 px = 1.0 / res;
96
+ vec2 souv = vUv - uShadowOffset * px;
97
+ float occ = 0.0;
98
+ for (int i = 0; i < 8; i++) {
99
+ float a = float(i) / 8.0 * TAU;
100
+ vec2 o = vec2(cos(a), sin(a)) * uShadowSoft * px;
101
+ vec2 tuv = souv + o;
102
+ vec2 inb = step(vec2(0.0), tuv) * step(tuv, vec2(1.0));
103
+ float mask = inb.x * inb.y;
104
+ vec4 s = texture(uPanel, tuv);
105
+ occ += clamp(s.r + s.b, 0.0, 1.0) * mask;
106
+ }
107
+ occ /= 8.0;
108
+ float dark = clamp(occ * uShadowStrength, 0.0, 1.0);
109
+ fragColor = vec4(vec3(1.0 - dark), 1.0);
110
+ return;
111
+ }
112
+
113
+ vec2 fromC = frag - uOrigin;
114
+ float rad = length(fromC);
115
+
116
+ vec4 panel = texture(uPanel, vUv);
117
+ float heartFill = panel.r;
118
+ float ink = panel.g;
119
+ float burstFill = panel.b;
120
+
121
+ vec3 col = vec3(0.0);
122
+
123
+ // ---- SOFT BLOOM behind the heart (the love glow) ------------------------
124
+ // A warm radial glow centred on the heart, pulsing with the beat + flaring on
125
+ // the burst. Sampled as a smooth falloff so it reads as light blooming behind
126
+ // the form, not a hard disc. Warmer (toward uC2) as it goes pop.
127
+ float glowR = minDim * (0.18 + 0.30 * uGlow) * (1.0 + 0.25 * uBeat);
128
+ float bloom = exp(-rad / glowR);
129
+ float bloomAmp = (0.35 + 0.65 * uBeat) * (0.6 + 0.8 * uBurst * (1.0 - uBurst) * 3.0);
130
+ vec3 glowCol = mix(uC0, uC2, 0.45 + 0.3 * uSaturation);
131
+ col += glowCol * bloom * bloomAmp * uPresence * uGlow * uExposure * 0.9;
132
+
133
+ // ---- HERO HEART ---------------------------------------------------------
134
+ // The big swelling heart. A rich warm body with a vertical light->shade
135
+ // gradient (top catches the key light). Photoreal end: smooth gradient + a
136
+ // tight gloss highlight. Pop end: flatter, more saturated, with a halftone
137
+ // blush and a crisp rim.
138
+ // Vertical shading term: 1 at the top of the panel, 0 at the bottom.
139
+ float vshade = clamp(1.0 - vUv.y, 0.0, 1.0);
140
+ vec3 bodyLit = mix(uC1, uC0, 0.35 + 0.65 * uSaturation); // mid body
141
+ vec3 bodyHi = clamp(bodyLit * 1.5 + 0.18, 0.0, 1.6); // lit top
142
+ vec3 bodyLow = bodyLit * 0.55; // shaded base
143
+ // Photoreal: smooth top->bottom gradient. Cel: snap to two flat zones.
144
+ float g = smoothstep(0.15, 0.95, vshade);
145
+ float gCel = step(0.5, vshade);
146
+ float grad = mix(g, gCel, uStyle);
147
+ vec3 heartCol = mix(bodyLow, bodyHi, grad);
148
+
149
+ // Soft inner-rim self-shadow toward the silhouette so the form reads round
150
+ // (photoreal); fades out toward the flat cel sticker.
151
+ // (We approximate the rim from how isolated the fill is via a small blur.)
152
+ float edge = 0.0;
153
+ {
154
+ vec2 px = 1.0 / res;
155
+ for (int i = 0; i < 6; i++){
156
+ float a = float(i) / 6.0 * TAU;
157
+ edge += texture(uPanel, vUv + vec2(cos(a), sin(a)) * px * 3.0).r;
158
+ }
159
+ edge /= 6.0;
160
+ }
161
+ float rimDark = clamp((heartFill - edge), 0.0, 1.0); // bright near the outline
162
+ heartCol *= 1.0 - rimDark * 0.5 * (1.0 - uStyle);
163
+
164
+ // Halftone blush on the heart toward the pop end (printed sticker shading).
165
+ float blush = benday(frag, uDotSize, mix(0.35, 0.6, uHalftone), radians(20.0) + uSeed);
166
+ heartCol += (uC2 - heartCol) * blush * uHalftone * uStyle * 0.28;
167
+
168
+ col += heartCol * heartFill * uPresence * uExposure * 1.6;
169
+
170
+ // GLOSS: a tight specular highlight near the upper-left of the heart at the
171
+ // photoreal end (a glassy gel-heart). Seeded by the ink-channel highlight blob
172
+ // the renderer paints, modulated up by the beat (the heart "shines" as it
173
+ // thumps). Vanishes toward the flat cel end.
174
+ float gloss = ink * heartFill; // ink highlight that sits ON the fill
175
+ float glossAmt = uGloss * (1.0 - uStyle) * (0.6 + 0.6 * uBeat);
176
+ col += vec3(1.0) * gloss * glossAmt * uPresence * 1.4;
177
+
178
+ // ---- BURST: the flurry of little hearts ---------------------------------
179
+ // Drawn fully in the panel (positions/arc/scale computed in JS for crisp
180
+ // vector hearts). Here we just light them — saturated warm fills with a soft
181
+ // self-glow, fading as they fly out (uBurst late => dimmer).
182
+ float burstFade = 1.0 - smoothstep(0.55, 1.0, uBurst);
183
+ vec3 littleCol = mix(uC1, uC2, 0.3 + 0.4 * uSaturation);
184
+ littleCol = clamp(littleCol * 1.25 + 0.1, 0.0, 1.5);
185
+ col += littleCol * burstFill * uPresence * burstFade * uExposure * 1.5;
186
+ // a soft sparkle bloom around the little hearts so they twinkle as they go.
187
+ col += littleCol * burstFill * 0.4 * burstFade * (0.5 + 0.5 * sin(uTimeS * 30.0 + uSeed));
188
+
189
+ // ---- INK / CONTOUR ------------------------------------------------------
190
+ // Bold outline. On a screen-blend canvas ink is the ABSENCE of light, so the
191
+ // contour CARVES the lit shapes (reads as a dark outline) — strongest toward
192
+ // the flat cel sticker (a clean black keyline), softer at the photoreal end.
193
+ // The gloss seed (ink ON the fill) is NOT carved (handled above as highlight).
194
+ float contour = ink * (1.0 - heartFill); // outline pixels only
195
+ float carve = contour * uPresence * mix(0.45, 0.95, uStyle);
196
+ col *= (1.0 - carve);
197
+
198
+ // ---- BEAT / BURST FLASH -------------------------------------------------
199
+ // A warm flash that throws colored light onto the page on each thump and at
200
+ // the burst. Fast spike (driven by uFlash), warm core.
201
+ float flashFall = exp(-rad / (minDim * 0.40));
202
+ vec3 flashCol = mix(uC0, vec3(1.0, 0.85, 0.8), 0.4 + 0.25 * uStyle);
203
+ col += flashCol * flashFall * uFlash * uExposure * 1.2;
204
+ // tiny white-hot core at the very centre on the strongest beats.
205
+ float core = exp(-rad / (minDim * 0.08));
206
+ col += vec3(1.0, 0.92, 0.9) * core * uFlash * uBeat * 1.3;
207
+
208
+ // ---- TONE + FINISH ------------------------------------------------------
209
+ col = tonemapACES(col * 0.9);
210
+
211
+ // Cel posterize toward the pop/sticker end (flat printed color); leaves the
212
+ // dark page untouched so we don't shatter it.
213
+ if (uStyle > 0.001) {
214
+ float lit = smoothstep(0.02, 0.2, max(max(col.r, col.g), col.b));
215
+ vec3 q = floor(col * 4.0 + 0.5) / 4.0;
216
+ col = mix(col, mix(col, q, lit), uStyle * 0.7);
217
+ }
218
+
219
+ // Ordered dither to kill banding the screen blend reveals (faded toward cel).
220
+ col = ditherAdd(col, frag, uTimeS, 1.0 - uStyle * 0.7);
221
+
222
+ fragColor = vec4(max(col, 0.0), 1.0);
223
+ }`;