@dopaminefx/core 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.
Files changed (136) hide show
  1. package/dist/engine/color.d.ts +71 -0
  2. package/dist/engine/color.d.ts.map +1 -0
  3. package/dist/engine/color.js +107 -0
  4. package/dist/engine/color.js.map +1 -0
  5. package/dist/engine/context.d.ts +54 -0
  6. package/dist/engine/context.d.ts.map +1 -0
  7. package/dist/engine/context.js +0 -0
  8. package/dist/engine/context.js.map +1 -0
  9. package/dist/engine/gl.d.ts +9 -0
  10. package/dist/engine/gl.d.ts.map +1 -0
  11. package/dist/engine/gl.js +39 -0
  12. package/dist/engine/gl.js.map +1 -0
  13. package/dist/engine/look/glsl.d.ts +95 -0
  14. package/dist/engine/look/glsl.d.ts.map +1 -0
  15. package/dist/engine/look/glsl.js +171 -0
  16. package/dist/engine/look/glsl.js.map +1 -0
  17. package/dist/engine/look/particles.glsl.d.ts +21 -0
  18. package/dist/engine/look/particles.glsl.d.ts.map +1 -0
  19. package/dist/engine/look/particles.glsl.js +44 -0
  20. package/dist/engine/look/particles.glsl.js.map +1 -0
  21. package/dist/engine/sdf.d.ts +77 -0
  22. package/dist/engine/sdf.d.ts.map +1 -0
  23. package/dist/engine/sdf.js +255 -0
  24. package/dist/engine/sdf.js.map +1 -0
  25. package/dist/engine/seed.d.ts +10 -0
  26. package/dist/engine/seed.d.ts.map +1 -0
  27. package/dist/engine/seed.js +20 -0
  28. package/dist/engine/seed.js.map +1 -0
  29. package/dist/engine/shadow.d.ts +41 -0
  30. package/dist/engine/shadow.d.ts.map +1 -0
  31. package/dist/engine/shadow.js +39 -0
  32. package/dist/engine/shadow.js.map +1 -0
  33. package/dist/engine/tempo.d.ts +33 -0
  34. package/dist/engine/tempo.d.ts.map +1 -0
  35. package/dist/engine/tempo.js +51 -0
  36. package/dist/engine/tempo.js.map +1 -0
  37. package/dist/framework/conductor.d.ts +100 -0
  38. package/dist/framework/conductor.d.ts.map +1 -0
  39. package/dist/framework/conductor.js +493 -0
  40. package/dist/framework/conductor.js.map +1 -0
  41. package/dist/framework/content.d.ts +67 -0
  42. package/dist/framework/content.d.ts.map +1 -0
  43. package/dist/framework/content.js +72 -0
  44. package/dist/framework/content.js.map +1 -0
  45. package/dist/framework/dope-pass.d.ts +131 -0
  46. package/dist/framework/dope-pass.d.ts.map +1 -0
  47. package/dist/framework/dope-pass.js +346 -0
  48. package/dist/framework/dope-pass.js.map +1 -0
  49. package/dist/framework/dope-zip.d.ts +22 -0
  50. package/dist/framework/dope-zip.d.ts.map +1 -0
  51. package/dist/framework/dope-zip.js +116 -0
  52. package/dist/framework/dope-zip.js.map +1 -0
  53. package/dist/framework/effect.d.ts +128 -0
  54. package/dist/framework/effect.d.ts.map +1 -0
  55. package/dist/framework/effect.js +19 -0
  56. package/dist/framework/effect.js.map +1 -0
  57. package/dist/framework/frame-expr.d.ts +124 -0
  58. package/dist/framework/frame-expr.d.ts.map +1 -0
  59. package/dist/framework/frame-expr.js +135 -0
  60. package/dist/framework/frame-expr.js.map +1 -0
  61. package/dist/framework/load-effect.d.ts +77 -0
  62. package/dist/framework/load-effect.d.ts.map +1 -0
  63. package/dist/framework/load-effect.js +135 -0
  64. package/dist/framework/load-effect.js.map +1 -0
  65. package/dist/framework/loader.d.ts +309 -0
  66. package/dist/framework/loader.d.ts.map +1 -0
  67. package/dist/framework/loader.js +266 -0
  68. package/dist/framework/loader.js.map +1 -0
  69. package/dist/framework/mood-registry.d.ts +58 -0
  70. package/dist/framework/mood-registry.d.ts.map +1 -0
  71. package/dist/framework/mood-registry.js +58 -0
  72. package/dist/framework/mood-registry.js.map +1 -0
  73. package/dist/framework/panel-runner.d.ts +96 -0
  74. package/dist/framework/panel-runner.d.ts.map +1 -0
  75. package/dist/framework/panel-runner.js +137 -0
  76. package/dist/framework/panel-runner.js.map +1 -0
  77. package/dist/framework/pass-common.d.ts +97 -0
  78. package/dist/framework/pass-common.d.ts.map +1 -0
  79. package/dist/framework/pass-common.js +178 -0
  80. package/dist/framework/pass-common.js.map +1 -0
  81. package/dist/framework/pass-runner.d.ts +183 -0
  82. package/dist/framework/pass-runner.d.ts.map +1 -0
  83. package/dist/framework/pass-runner.js +212 -0
  84. package/dist/framework/pass-runner.js.map +1 -0
  85. package/dist/framework/programs.d.ts +54 -0
  86. package/dist/framework/programs.d.ts.map +1 -0
  87. package/dist/framework/programs.js +33 -0
  88. package/dist/framework/programs.js.map +1 -0
  89. package/dist/framework/registry.d.ts +29 -0
  90. package/dist/framework/registry.d.ts.map +1 -0
  91. package/dist/framework/registry.js +38 -0
  92. package/dist/framework/registry.js.map +1 -0
  93. package/dist/framework/runtime.d.ts +19 -0
  94. package/dist/framework/runtime.d.ts.map +1 -0
  95. package/dist/framework/runtime.js +37 -0
  96. package/dist/framework/runtime.js.map +1 -0
  97. package/dist/index.d.ts +63 -0
  98. package/dist/index.d.ts.map +1 -0
  99. package/dist/index.js +126 -0
  100. package/dist/index.js.map +1 -0
  101. package/dist/overlay.d.ts +46 -0
  102. package/dist/overlay.d.ts.map +1 -0
  103. package/dist/overlay.js +79 -0
  104. package/dist/overlay.js.map +1 -0
  105. package/dist/types.d.ts +68 -0
  106. package/dist/types.d.ts.map +1 -0
  107. package/dist/types.js +10 -0
  108. package/dist/types.js.map +1 -0
  109. package/package.json +37 -0
  110. package/src/engine/color.ts +154 -0
  111. package/src/engine/context.ts +0 -0
  112. package/src/engine/gl.ts +46 -0
  113. package/src/engine/look/glsl.ts +183 -0
  114. package/src/engine/look/particles.glsl.ts +44 -0
  115. package/src/engine/sdf.ts +298 -0
  116. package/src/engine/seed.ts +23 -0
  117. package/src/engine/shadow.ts +66 -0
  118. package/src/engine/tempo.ts +54 -0
  119. package/src/framework/conductor.ts +604 -0
  120. package/src/framework/content.ts +113 -0
  121. package/src/framework/dope-pass.ts +432 -0
  122. package/src/framework/dope-zip.ts +125 -0
  123. package/src/framework/effect.ts +127 -0
  124. package/src/framework/frame-expr.ts +217 -0
  125. package/src/framework/load-effect.ts +204 -0
  126. package/src/framework/loader.ts +502 -0
  127. package/src/framework/mood-registry.ts +87 -0
  128. package/src/framework/panel-runner.ts +233 -0
  129. package/src/framework/pass-common.ts +222 -0
  130. package/src/framework/pass-runner.ts +391 -0
  131. package/src/framework/programs.ts +62 -0
  132. package/src/framework/registry.ts +44 -0
  133. package/src/framework/runtime.ts +38 -0
  134. package/src/index.ts +227 -0
  135. package/src/overlay.ts +109 -0
  136. package/src/types.ts +63 -0
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Shared GLSL "look" chunk library.
3
+ *
4
+ * The three effects grew in parallel and each re-implemented (and drifted) the
5
+ * same building blocks inside its own shader: value-noise + fbm + domain warp,
6
+ * the palette mix, the segment SDF, the ACES tonemap, the IQ-cosine iridescence,
7
+ * the Ben-Day halftone and the ordered dither. Per the cross-pollination plan,
8
+ * those are lifted here into ONE canonical copy each, so every shader composes
9
+ * the SAME function instead of a private fork. Effects assemble their fragment
10
+ * source by concatenating the chunks they need ahead of their own `main()`.
11
+ *
12
+ * Each chunk is a self-contained GLSL snippet (no `#version`/`precision`/IO —
13
+ * those stay in the per-effect shader). The text below is byte-identical to the
14
+ * canonical implementations the effects already shipped, so adopting the library
15
+ * does not change any effect's look; it only removes the duplication + drift.
16
+ *
17
+ * NOTE: This is a GLSL *chunk* library (string includes), not a transpiler — it
18
+ * maps onto the `.dope` format's referenced shader bodies (the format references
19
+ * GLSL; it does not generate it).
20
+ */
21
+
22
+ /** TAU + a couple of constants every effect uses. */
23
+ export const GLSL_CONSTANTS = /* glsl */ `
24
+ #define TAU 6.28318530718
25
+ `;
26
+
27
+ /**
28
+ * Hash helpers (Dave Hoskins style) — `hash11` (1→1) and `hash21` (1→2). Used
29
+ * by the noise field, the particles, and the per-frame dither.
30
+ */
31
+ export const GLSL_HASH = /* glsl */ `
32
+ float hash11(float p){ p = fract(p * 0.1031); p *= p + 33.33; p *= p + p; return fract(p); }
33
+ vec2 hash21(float p){
34
+ vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));
35
+ p3 += dot(p3, p3.yzx + 33.33);
36
+ return fract((p3.xx + p3.yz) * p3.zy);
37
+ }
38
+ `;
39
+
40
+ /**
41
+ * Value noise + 4-octave fbm with a per-octave rotation (kills axis-aligned
42
+ * artifacts). Requires GLSL_HASH. This is the volumetric texture source for the
43
+ * bloom interior and the wet-ink edge wobble.
44
+ */
45
+ export const GLSL_FBM = /* glsl */ `
46
+ float vnoise(vec2 p){
47
+ vec2 i = floor(p), f = fract(p);
48
+ vec2 u = f * f * (3.0 - 2.0 * f);
49
+ float a = hash11(dot(i, vec2(1.0, 57.0)));
50
+ float b = hash11(dot(i + vec2(1.0, 0.0), vec2(1.0, 57.0)));
51
+ float c = hash11(dot(i + vec2(0.0, 1.0), vec2(1.0, 57.0)));
52
+ float d = hash11(dot(i + vec2(1.0, 1.0), vec2(1.0, 57.0)));
53
+ return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
54
+ }
55
+ float fbm(vec2 p){
56
+ float s = 0.0, a = 0.5;
57
+ mat2 rot = mat2(0.80, -0.60, 0.60, 0.80);
58
+ for (int i = 0; i < 4; i++) { s += a * vnoise(p); p = rot * p * 2.03; a *= 0.5; }
59
+ return s;
60
+ }
61
+ `;
62
+
63
+ /**
64
+ * Two-level domain warp: warp the sample point by an fbm-derived offset, then
65
+ * sample fbm again — the smoke-like living interior of a real bloom. Returns the
66
+ * warped fbm value at `p`; `t` is time, `amount` the warp strength. Requires
67
+ * GLSL_FBM.
68
+ */
69
+ export const GLSL_DOMAIN_WARP = /* glsl */ `
70
+ float domainWarp(vec2 p, float t, float amount){
71
+ vec2 warp = vec2(fbm(p + t * 0.18), fbm(p.yx - t * 0.12)) - 0.5;
72
+ return fbm(p + warp * 1.2 * amount + t * 0.25);
73
+ }
74
+ `;
75
+
76
+ /** 3-stop palette mix (inner→mid→outer). Effects declare uC0/uC1/uC2 uniforms. */
77
+ export const GLSL_PALETTE_MIX = /* glsl */ `
78
+ vec3 paletteMix(float t){
79
+ t = clamp(t, 0.0, 1.0);
80
+ return t < 0.5 ? mix(uC0, uC1, t * 2.0) : mix(uC1, uC2, (t - 0.5) * 2.0);
81
+ }
82
+ `;
83
+
84
+ /**
85
+ * Inigo Quilez cosine palette — a smooth spectral sweep used for thin-film
86
+ * iridescence (oil-on-water sheen): cycles through complementary hues, NOT the
87
+ * mood palette, so it reads as an iridescent film over the mark.
88
+ */
89
+ export const GLSL_IRIDESCENT = /* glsl */ `
90
+ vec3 iridescent(float t){
91
+ return 0.55 + 0.45 * cos(TAU * (vec3(1.0) * t + vec3(0.0, 0.33, 0.67)));
92
+ }
93
+ `;
94
+
95
+ /**
96
+ * Spectral dispersion amount at a refractive edge — grows toward the rim and
97
+ * with amplitude, gated by a 0..1 strength. `dn` is normalized radius/edge
98
+ * proximity (1 == edge); used to sample a profile at channel-shifted positions.
99
+ */
100
+ export const GLSL_DISPERSION = /* glsl */ `
101
+ float dispersionAmount(float strength, float dn, float amp){
102
+ return strength * (0.06 + 0.12 * smoothstep(0.2, 1.1, dn)) * (0.7 + 0.5 * amp);
103
+ }
104
+ `;
105
+
106
+ /** Capsule/segment SDF (the safe variant — guards zero-length segments). */
107
+ export const GLSL_SD_SEG = /* glsl */ `
108
+ float sdSeg(vec2 p, vec2 a, vec2 b){
109
+ vec2 pa = p - a, ba = b - a;
110
+ float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-3), 0.0, 1.0);
111
+ return length(pa - ba * h);
112
+ }
113
+ `;
114
+
115
+ /**
116
+ * ACES filmic tonemap (Narkowicz) — richer highlight rolloff than `x/(1+x)`,
117
+ * keeps highlights from going chalky while preserving saturated mid-lights.
118
+ */
119
+ export const GLSL_TONEMAP_ACES = /* glsl */ `
120
+ vec3 tonemapACES(vec3 x){
121
+ const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;
122
+ return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);
123
+ }
124
+ `;
125
+
126
+ /**
127
+ * Ordered/triangular-hash dither, ~1/255, to break the smooth-gradient banding
128
+ * the screen blend reveals on the page beneath. `frag` device px, `t` seconds,
129
+ * `fade` 0..1 (1 = full dither; effects fade it out toward the cel/pop end where
130
+ * hard bands are intended). Requires GLSL_HASH.
131
+ */
132
+ export const GLSL_DITHER = /* glsl */ `
133
+ vec3 ditherAdd(vec3 col, vec2 frag, float t, float fade){
134
+ float dz = hash11(dot(frag, vec2(12.989, 78.233)) + t) - 0.5;
135
+ return col + (dz / 255.0) * fade;
136
+ }
137
+ `;
138
+
139
+ /**
140
+ * Ben-Day halftone coverage: 1 inside a dot, 0 outside, antialiased. Dot RADIUS
141
+ * grows with tone `v`; the screen is rotated by `ang` (classic per-channel
142
+ * screen angle). Requires the matrix helper below.
143
+ */
144
+ export const GLSL_ROT2 = /* glsl */ `
145
+ mat2 rot2(float a){ float s = sin(a), c = cos(a); return mat2(c, -s, s, c); }
146
+ `;
147
+
148
+ export const GLSL_HALFTONE = /* glsl */ `
149
+ float benday(vec2 frag, float cell, float v, float ang){
150
+ vec2 p = rot2(ang) * frag / cell;
151
+ vec2 g = fract(p) - 0.5;
152
+ float d = length(g);
153
+ float r = 0.52 * sqrt(clamp(v, 0.0, 1.0));
154
+ float aa = 0.7 / cell + fwidth(d);
155
+ return 1.0 - smoothstep(r - aa, r + aa, d);
156
+ }
157
+ `;
158
+
159
+ /**
160
+ * PREMULTIPLIED LIGHT OUT — the portable compositing emit.
161
+ *
162
+ * The default web overlay leans on CSS `mix-blend-mode: screen` to cast the
163
+ * effect's light onto the page, which is rich on a dark UI but mathematically
164
+ * invisible on white (`screen(x, 1) == 1`). The native stacks (iOS/Android) have
165
+ * no per-surface screen blend, so they instead emit PREMULTIPLIED light — `rgb`
166
+ * unchanged, `alpha = its own brightness` — and let the OS composite it
167
+ * source-over: dark regions go transparent (the host shows through), bright
168
+ * light reads as cast colour, and crucially it stays visible on ANY backdrop,
169
+ * white included. This is byte-identical to Android's `Look.kt` `dopLightOut`.
170
+ *
171
+ * The web runtime reuses this for its "backdrop"-aware compositing path: when a
172
+ * caller passes a known surface colour, the light pass swaps its opaque
173
+ * `vec4(col, 1.0)` emit for `dopLightOut(col)` and composites source-over
174
+ * (`mix-blend-mode: normal`) instead of screen — see `pass-common.ts`'s
175
+ * `compositeLightFragment`.
176
+ */
177
+ export const GLSL_LIGHT_OUT = /* glsl */ `
178
+ vec4 dopLightOut(vec3 col){
179
+ col = max(col, 0.0);
180
+ float a = clamp(max(max(col.r, col.g), col.b), 0.0, 1.0);
181
+ return vec4(col, a);
182
+ }
183
+ `;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Shared GPU-particle GLSL chunk.
3
+ *
4
+ * Solarbloom's drifting "motes" and Verdict's flung "droplets" are both
5
+ * point-sprite particle fields that differ only in their MOTION model (motes:
6
+ * outward drift + buoyancy + curl, with motion-blur streaks; droplets: a
7
+ * ballistic arc under gravity) and their styling. The cross-pollination plan
8
+ * asks for one parametric particle module behind a shared include + params.
9
+ *
10
+ * Here we extract the parts that were duplicated verbatim between the two: the
11
+ * per-particle soft round sprite (`particleSprite`), the ballistic position
12
+ * (`ballisticPos`), and the standard fade-in/out over a particle's life
13
+ * (`particleFade`). Each effect keeps its own emit shape + motion (the part that
14
+ * is its identity) and composes these shared primitives, so the dot falloff,
15
+ * the gravity arc and the lifetime curve no longer drift between effects.
16
+ *
17
+ * Comic debris can adopt the same primitives later (deferred — noted in the
18
+ * plan as P2). Requires no other chunk.
19
+ */
20
+
21
+ export const GLSL_PARTICLES = /* glsl */ `
22
+ // Soft round particle sprite: an inverse-distance dot that, squared, gives the
23
+ // glowing-photon falloff both motes and droplets use. \`d\` is distance to the
24
+ // particle centre, \`size\` its radius in device px.
25
+ float particleSprite(float d, float size){
26
+ float s = size / (d + size * 0.5);
27
+ return s * s;
28
+ }
29
+
30
+ // Ballistic arc: launch from \`origin\` along \`dir\` at \`speed\`, pulled down by
31
+ // \`gravity\` (device px) over normalized particle life \`t\` (0..1). Screen y is
32
+ // up, so gravity subtracts on y. Used by Verdict's flung ink droplets and any
33
+ // effect that wants sparks thrown off an impact.
34
+ vec2 ballisticPos(vec2 origin, vec2 dir, float speed, float gravity, float t){
35
+ return origin + dir * speed * t - vec2(0.0, 1.0) * gravity * t * t;
36
+ }
37
+
38
+ // Standard particle fade: ramps in fast then fades out across its life. \`t\` is
39
+ // normalized particle life (0..1); \`tailPow\` shapes the decay (higher = longer
40
+ // luminous body before it dims).
41
+ float particleFade(float t, float tailPow){
42
+ return (1.0 - pow(t, tailPow)) * smoothstep(0.0, 0.08, t);
43
+ }
44
+ `;
@@ -0,0 +1,298 @@
1
+ /**
2
+ * SVG path → signed-distance-field (SDF) baker + runtime decoder.
3
+ *
4
+ * This is the "geometry seam": an effect's icon outline lives in its `.dope` as
5
+ * an `svgPath` string (authored, host-swappable). A BUILD step (scripts/bake-sdf
6
+ * or scripts/pack-dope) rasterizes that path into a small, self-contained SDF and
7
+ * inlines it into the distributed `.dope` (a `data:`-style base64 blob, no remote
8
+ * fetch). At RUNTIME the effect only DECODES + SAMPLES the SDF — it never does a
9
+ * live path→SDF conversion — so swapping the path in the `.dope` changes the
10
+ * rendered icon with no shader edit.
11
+ *
12
+ * The SDF here is a stroked-outline distance field: distance to the path's
13
+ * centerline (a polyline flattened from the bezier/line segments), so the icon
14
+ * reads as a *drawn stroke in light* (a tick, a cross, a custom mark), matching
15
+ * the "drawn-in-light" language of the built-ins. The field is signed only in the
16
+ * stroke sense (we store distance-to-stroke, with a coverage falloff applied at
17
+ * sample time by the shader against the declared stroke width), which is all the
18
+ * "drawn in light" look needs and keeps the bake free of robust point-in-polygon
19
+ * winding for arbitrary self-intersecting glyphs.
20
+ *
21
+ * Encoding (intentionally tiny, dependency-free, portable to Swift):
22
+ * - a fixed-size square grid of `size`×`size` 8-bit samples,
23
+ * - each sample = clamp(distance / range, 0..1) * 255 (0 = on the stroke,
24
+ * 255 = `range` author-units or more away),
25
+ * - serialized as a 4-byte header (magic 'D','S', size hi/lo) + the bytes,
26
+ * base64-encoded. `range` + `viewBox` travel as JSON next to the blob.
27
+ */
28
+
29
+ /** A flattened 2D point in author/viewBox units. */
30
+ export interface Pt {
31
+ x: number;
32
+ y: number;
33
+ }
34
+
35
+ /**
36
+ * Parse a (subset of) SVG path data into a list of polylines (each a list of
37
+ * points in author units). Supports absolute + relative M/L/H/V/C/Q/Z (the
38
+ * commands a designer's checkmark / cross / icon outline actually use). Curves
39
+ * are flattened to line segments at `steps` subdivisions — plenty for an SDF.
40
+ */
41
+ export function parseSvgPath(d: string, steps = 24): Pt[][] {
42
+ const polylines: Pt[][] = [];
43
+ let cur: Pt[] = [];
44
+ let cx = 0;
45
+ let cy = 0;
46
+ let startX = 0;
47
+ let startY = 0;
48
+
49
+ // Tokenize into command letters + numeric runs.
50
+ const tokens = d.match(/[a-zA-Z]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) ?? [];
51
+ let i = 0;
52
+ const num = (): number => Number(tokens[i++]);
53
+ const has = (): boolean => i < tokens.length && /^-?\.?\d/.test(tokens[i]!);
54
+
55
+ const push = (x: number, y: number): void => {
56
+ cur.push({ x, y });
57
+ cx = x;
58
+ cy = y;
59
+ };
60
+ const flushCur = (): void => {
61
+ if (cur.length > 1) polylines.push(cur);
62
+ cur = [];
63
+ };
64
+
65
+ let cmd = "";
66
+ while (i < tokens.length) {
67
+ const t = tokens[i]!;
68
+ if (/[a-zA-Z]/.test(t)) {
69
+ cmd = t;
70
+ i++;
71
+ } else if (!cmd) {
72
+ i++;
73
+ continue;
74
+ }
75
+ const rel = cmd === cmd.toLowerCase();
76
+ switch (cmd.toUpperCase()) {
77
+ case "M": {
78
+ flushCur();
79
+ const x = num() + (rel ? cx : 0);
80
+ const y = num() + (rel ? cy : 0);
81
+ startX = x;
82
+ startY = y;
83
+ push(x, y);
84
+ cmd = rel ? "l" : "L"; // subsequent pairs are implicit lineto
85
+ while (has()) {
86
+ const lx = num() + (rel ? cx : 0);
87
+ const ly = num() + (rel ? cy : 0);
88
+ push(lx, ly);
89
+ }
90
+ break;
91
+ }
92
+ case "L": {
93
+ do {
94
+ const x = num() + (rel ? cx : 0);
95
+ const y = num() + (rel ? cy : 0);
96
+ push(x, y);
97
+ } while (has());
98
+ break;
99
+ }
100
+ case "H": {
101
+ do {
102
+ const x = num() + (rel ? cx : 0);
103
+ push(x, cy);
104
+ } while (has());
105
+ break;
106
+ }
107
+ case "V": {
108
+ do {
109
+ const y = num() + (rel ? cy : 0);
110
+ push(cx, y);
111
+ } while (has());
112
+ break;
113
+ }
114
+ case "C": {
115
+ do {
116
+ const x1 = num() + (rel ? cx : 0);
117
+ const y1 = num() + (rel ? cy : 0);
118
+ const x2 = num() + (rel ? cx : 0);
119
+ const y2 = num() + (rel ? cy : 0);
120
+ const x = num() + (rel ? cx : 0);
121
+ const y = num() + (rel ? cy : 0);
122
+ const p0 = { x: cx, y: cy };
123
+ for (let s = 1; s <= steps; s++) {
124
+ const u = s / steps;
125
+ const mt = 1 - u;
126
+ const bx =
127
+ mt * mt * mt * p0.x + 3 * mt * mt * u * x1 + 3 * mt * u * u * x2 + u * u * u * x;
128
+ const by =
129
+ mt * mt * mt * p0.y + 3 * mt * mt * u * y1 + 3 * mt * u * u * y2 + u * u * u * y;
130
+ push(bx, by);
131
+ }
132
+ } while (has());
133
+ break;
134
+ }
135
+ case "Q": {
136
+ do {
137
+ const x1 = num() + (rel ? cx : 0);
138
+ const y1 = num() + (rel ? cy : 0);
139
+ const x = num() + (rel ? cx : 0);
140
+ const y = num() + (rel ? cy : 0);
141
+ const p0 = { x: cx, y: cy };
142
+ for (let s = 1; s <= steps; s++) {
143
+ const u = s / steps;
144
+ const mt = 1 - u;
145
+ const bx = mt * mt * p0.x + 2 * mt * u * x1 + u * u * x;
146
+ const by = mt * mt * p0.y + 2 * mt * u * y1 + u * u * y;
147
+ push(bx, by);
148
+ }
149
+ } while (has());
150
+ break;
151
+ }
152
+ case "Z": {
153
+ if (cur.length) {
154
+ push(startX, startY);
155
+ flushCur();
156
+ }
157
+ break;
158
+ }
159
+ default:
160
+ // Unknown command — skip its number to avoid an infinite loop.
161
+ if (has()) num();
162
+ break;
163
+ }
164
+ }
165
+ flushCur();
166
+ return polylines;
167
+ }
168
+
169
+ /** Distance from point p to segment a→b, in author units. */
170
+ function distToSeg(px: number, py: number, ax: number, ay: number, bx: number, by: number): number {
171
+ const dx = bx - ax;
172
+ const dy = by - ay;
173
+ const len2 = dx * dx + dy * dy;
174
+ let t = len2 > 1e-9 ? ((px - ax) * dx + (py - ay) * dy) / len2 : 0;
175
+ t = t < 0 ? 0 : t > 1 ? 1 : t;
176
+ const qx = ax + dx * t;
177
+ const qy = ay + dy * t;
178
+ return Math.hypot(px - qx, py - qy);
179
+ }
180
+
181
+ /** A baked SDF: a square grid + the metadata a sampler needs. */
182
+ export interface BakedSdf {
183
+ /** Grid resolution (square). */
184
+ size: number;
185
+ /** Author-units of distance that map to the full 0..255 byte range. */
186
+ range: number;
187
+ /** The viewBox the path was authored in: [minX, minY, w, h]. */
188
+ viewBox: [number, number, number, number];
189
+ /**
190
+ * A `data:` URI carrying the header + size×size 8-bit distance bytes (base64).
191
+ * A `data:` URI (not a remote/absolute ref) keeps the `.dope` self-contained
192
+ * and passes the loader's standalone guard. The runtime decodes + samples it.
193
+ */
194
+ data: string;
195
+ }
196
+
197
+ /** The MIME used for the inline SDF `data:` URI. */
198
+ const SDF_MIME = "application/octet-stream";
199
+ const SDF_DATA_PREFIX = `data:${SDF_MIME};base64,`;
200
+
201
+ const MAGIC0 = 0x44; // 'D'
202
+ const MAGIC1 = 0x53; // 'S'
203
+
204
+ /** Encode raw bytes to base64 in both Node and the browser. */
205
+ function bytesToBase64(bytes: Uint8Array): string {
206
+ if (typeof Buffer !== "undefined") return Buffer.from(bytes).toString("base64");
207
+ let bin = "";
208
+ for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]!);
209
+ // eslint-disable-next-line no-undef
210
+ return btoa(bin);
211
+ }
212
+
213
+ /** Decode base64 to raw bytes in both Node and the browser. */
214
+ function base64ToBytes(b64: string): Uint8Array {
215
+ if (typeof Buffer !== "undefined") return new Uint8Array(Buffer.from(b64, "base64"));
216
+ // eslint-disable-next-line no-undef
217
+ const bin = atob(b64);
218
+ const out = new Uint8Array(bin.length);
219
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * BAKE: rasterize an SVG path into a stroked-distance SDF. Pure + deterministic.
225
+ * `size` is the grid resolution; `range` is how many author-units of distance map
226
+ * to the full byte range (a larger range = a softer, wider usable falloff). The
227
+ * path is normalized into a centered square that preserves its aspect inside the
228
+ * grid, leaving a small margin so the stroke + its glow never clip the edge.
229
+ */
230
+ export function bakeSdf(
231
+ svgPath: string,
232
+ viewBox: [number, number, number, number],
233
+ size = 64,
234
+ range = 18,
235
+ ): BakedSdf {
236
+ const polylines = parseSvgPath(svgPath);
237
+ const [vx, vy, vw, vh] = viewBox;
238
+ const bytes = new Uint8Array(size * size);
239
+
240
+ // Map a grid cell center to author/viewBox coordinates (y stays top-down, the
241
+ // shader flips as needed). The whole viewBox maps to the grid 0..size.
242
+ for (let gy = 0; gy < size; gy++) {
243
+ for (let gx = 0; gx < size; gx++) {
244
+ const ax = vx + ((gx + 0.5) / size) * vw;
245
+ const ay = vy + ((gy + 0.5) / size) * vh;
246
+ let best = Infinity;
247
+ for (const poly of polylines) {
248
+ for (let k = 0; k + 1 < poly.length; k++) {
249
+ const d = distToSeg(ax, ay, poly[k]!.x, poly[k]!.y, poly[k + 1]!.x, poly[k + 1]!.y);
250
+ if (d < best) best = d;
251
+ }
252
+ }
253
+ const norm = best === Infinity ? 1 : Math.min(1, best / range);
254
+ bytes[gy * size + gx] = Math.round(norm * 255);
255
+ }
256
+ }
257
+
258
+ const header = new Uint8Array(4);
259
+ header[0] = MAGIC0;
260
+ header[1] = MAGIC1;
261
+ header[2] = (size >> 8) & 0xff;
262
+ header[3] = size & 0xff;
263
+ const blob = new Uint8Array(header.length + bytes.length);
264
+ blob.set(header, 0);
265
+ blob.set(bytes, header.length);
266
+
267
+ return { size, range, viewBox, data: SDF_DATA_PREFIX + bytesToBase64(blob) };
268
+ }
269
+
270
+ /** A decoded SDF ready to upload: the raw single-channel bytes + its size. */
271
+ export interface DecodedSdf {
272
+ size: number;
273
+ range: number;
274
+ viewBox: [number, number, number, number];
275
+ /** size×size single-channel (0..255) distance bytes. */
276
+ bytes: Uint8Array;
277
+ }
278
+
279
+ /**
280
+ * DECODE a baked SDF blob back to its raw distance bytes. Validates the magic +
281
+ * the declared size against the byte count. Used by the runtime to upload an
282
+ * `R8`/alpha texture — the runtime SAMPLES this; it never re-bakes.
283
+ */
284
+ export function decodeSdf(baked: BakedSdf): DecodedSdf {
285
+ const b64 = baked.data.startsWith(SDF_DATA_PREFIX)
286
+ ? baked.data.slice(SDF_DATA_PREFIX.length)
287
+ : baked.data; // tolerate a bare base64 blob too
288
+ const blob = base64ToBytes(b64);
289
+ if (blob.length < 4 || blob[0] !== MAGIC0 || blob[1] !== MAGIC1) {
290
+ throw new Error("dope: not a baked SDF blob (bad magic)");
291
+ }
292
+ const size = (blob[2]! << 8) | blob[3]!;
293
+ const bytes = blob.subarray(4);
294
+ if (bytes.length !== size * size) {
295
+ throw new Error(`dope: SDF size mismatch (header ${size}^2 != ${bytes.length} bytes)`);
296
+ }
297
+ return { size, range: baked.range, viewBox: baked.viewBox, bytes: bytes.slice() };
298
+ }
@@ -0,0 +1,23 @@
1
+ /** Deterministic, seedable PRNG so a given `seed` always yields the same look. */
2
+
3
+ export type Rng = () => number;
4
+
5
+ /**
6
+ * mulberry32 — tiny, fast, good-enough-for-visuals PRNG. Returns values in
7
+ * [0, 1). Deterministic for a given 32-bit seed.
8
+ */
9
+ export function mulberry32(seed: number): Rng {
10
+ let a = seed >>> 0;
11
+ return function next(): number {
12
+ a |= 0;
13
+ a = (a + 0x6d2b79f5) | 0;
14
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
15
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
16
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
17
+ };
18
+ }
19
+
20
+ /** A fresh 32-bit seed — used when the caller doesn't pin one. */
21
+ export function randomSeed(): number {
22
+ return (Math.random() * 0xffffffff) >>> 0;
23
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Shadow-pass geometry — the pure math that turns an effect's amplitude,
3
+ * "height" above the page, and stylization into the offset / softness /
4
+ * strength of the cast soft shadow. Kept framework- and GL-free so it can be
5
+ * unit-tested and reused by any effect that adopts the multiply shadow layer.
6
+ *
7
+ * Conventions (device pixels, gl coords where Y is UP):
8
+ * - The implied key light sits up-and-left of the floating effect, so the
9
+ * shadow falls DOWN-and-right: offset = (+x, -y).
10
+ * - `offset` grows with the occluder's height and with amplitude (a brighter,
11
+ * higher source throws a longer shadow).
12
+ * - `soft` (penumbra blur radius) is larger for a soft photoreal source and
13
+ * tightens toward the hard graphic drop-shadow of the cel end.
14
+ * - `strength` is the max darkening of the multiply layer (0 = none); kept
15
+ * ambient-occlusion subtle, a touch firmer toward cel.
16
+ */
17
+
18
+ export interface ShadowGeometry {
19
+ /** Silhouette offset in device px, gl coords (x right, y up). */
20
+ offsetX: number;
21
+ offsetY: number;
22
+ /** Penumbra blur tap radius in device px. */
23
+ soft: number;
24
+ /** Max multiply darkening, 0..1. */
25
+ strength: number;
26
+ }
27
+
28
+ export interface ShadowInput {
29
+ /** Smaller canvas dimension in device px. */
30
+ minDim: number;
31
+ /**
32
+ * The occluder's "height" above the page as a fraction of `minDim` — bigger
33
+ * forms read as floating higher and cast longer, softer shadows. For
34
+ * Solarbloom this is the bloom radius fraction; for Verdict, the stroke scale.
35
+ */
36
+ heightFrac: number;
37
+ /** Envelope amplitude (peaks > 1). */
38
+ amp: number;
39
+ /** Stylization 0..1 (photoreal → cel). */
40
+ style: number;
41
+ }
42
+
43
+ const clamp = (x: number, lo: number, hi: number): number =>
44
+ x < lo ? lo : x > hi ? hi : x;
45
+
46
+ export function shadowGeometry({ minDim, heightFrac, amp, style }: ShadowInput): ShadowGeometry {
47
+ const height = heightFrac * minDim;
48
+ // Offset length: scales with height and (clamped) amplitude. A meaningful
49
+ // drop so the silhouette clears the bright core (which the screen light owns)
50
+ // and lands as a distinct shadow on the UI beside/below it.
51
+ const off = height * 0.16 * (0.6 + 0.5 * Math.min(amp, 1.5));
52
+ // Penumbra: wide & soft when photoreal, tight when cel; always a small floor.
53
+ const soft = minDim * 0.014 * (1 - 0.6 * style) + minDim * 0.005;
54
+ // Darkening of the multiply layer. Kept ambient at the soft end, firmer (a
55
+ // graphic drop-shadow) toward cel. Reads clearly where it falls on the
56
+ // lighter raised faces / the white primary button.
57
+ const strength = clamp(0.6 * (0.8 + 0.45 * style), 0, 1);
58
+ return {
59
+ // Down-and-right: x positive, y negative (gl Y up). x is a fraction of the
60
+ // drop so the shadow leans, not straight down.
61
+ offsetX: off * 0.55,
62
+ offsetY: -off,
63
+ soft,
64
+ strength,
65
+ };
66
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Animation tempo PRIMITIVES — the generic easing + envelope building blocks
3
+ * shared across effects.
4
+ *
5
+ * Linear motion reads as unnatural, so everything here is eased. These are the
6
+ * GENERIC primitives only: each effect's BESPOKE envelope (the comic slam, the
7
+ * fail stamp/shake, the heartburst lub-dub, the lightning strike/strobe, the
8
+ * solarbloom check draw, the inkstroke stroke draw, …) lives in that effect's
9
+ * own `<name>-tempo.ts` inside `@dopaminefx/effect-<name>`, built on top of these.
10
+ */
11
+
12
+ /**
13
+ * Coarse animation step (ms) for the hand-drawn "animate on twos" look at full
14
+ * whimsy — ~12 updates/sec, i.e. 24fps on twos. Motion is snapped toward this
15
+ * grid as style rises (see the pass-runner), giving discrete, posed beats
16
+ * instead of smooth interpolation.
17
+ */
18
+ export const NPR_TIME_STEP_MS = 1000 / 12;
19
+
20
+ /** Clamp a value into [0, 1]. */
21
+ export const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x);
22
+
23
+ /** Classic ease-out cubic — quick start, gentle settle. */
24
+ export function easeOutCubic(x: number): number {
25
+ const t = clamp01(x);
26
+ return 1 - Math.pow(1 - t, 3);
27
+ }
28
+
29
+ /**
30
+ * Ease-out "back" — overshoots past 1 then settles exactly to 1 at x=1. This is
31
+ * the held-breath release. `overshoot` scales how far past 1 it swells.
32
+ */
33
+ export function easeOutBack(x: number, overshoot = 1): number {
34
+ const t = clamp01(x);
35
+ const c1 = 1.70158 * overshoot;
36
+ const c3 = c1 + 1;
37
+ return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
38
+ }
39
+
40
+ /**
41
+ * Bloom amplitude over normalized life `t` ∈ [0, 1].
42
+ * Fast attack with overshoot in the first ~18%, then a long decay to zero.
43
+ * `envelope(0) === 0`, `envelope(1) === 0`, peak > 1 during the attack.
44
+ */
45
+ export function envelope(t: number, overshoot = 1): number {
46
+ if (t <= 0 || t >= 1) return 0;
47
+ const attack = 0.18;
48
+ if (t < attack) {
49
+ return easeOutBack(t / attack, overshoot);
50
+ }
51
+ const x = (t - attack) / (1 - attack);
52
+ // Decays from 1 → 0; exponent > 1 keeps a slow, luxurious tail.
53
+ return Math.pow(1 - x, 1.6);
54
+ }