@dopaminefx/effect-solarbloom 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 (38) hide show
  1. package/dist/check-fonts.d.ts +22 -0
  2. package/dist/check-fonts.d.ts.map +1 -0
  3. package/dist/check-fonts.js +19 -0
  4. package/dist/check-fonts.js.map +1 -0
  5. package/dist/check-renderer.d.ts +31 -0
  6. package/dist/check-renderer.d.ts.map +1 -0
  7. package/dist/check-renderer.js +102 -0
  8. package/dist/check-renderer.js.map +1 -0
  9. package/dist/index.d.ts +35 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +73 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/solarbloom-params.d.ts +48 -0
  14. package/dist/solarbloom-params.d.ts.map +1 -0
  15. package/dist/solarbloom-params.js +7 -0
  16. package/dist/solarbloom-params.js.map +1 -0
  17. package/dist/solarbloom-renderer.d.ts +40 -0
  18. package/dist/solarbloom-renderer.d.ts.map +1 -0
  19. package/dist/solarbloom-renderer.js +110 -0
  20. package/dist/solarbloom-renderer.js.map +1 -0
  21. package/dist/solarbloom-shader.d.ts +28 -0
  22. package/dist/solarbloom-shader.d.ts.map +1 -0
  23. package/dist/solarbloom-shader.js +447 -0
  24. package/dist/solarbloom-shader.js.map +1 -0
  25. package/dist/solarbloom-tempo.d.ts +13 -0
  26. package/dist/solarbloom-tempo.d.ts.map +1 -0
  27. package/dist/solarbloom-tempo.js +16 -0
  28. package/dist/solarbloom-tempo.js.map +1 -0
  29. package/dist/solarbloom.dope.json +552 -0
  30. package/package.json +46 -0
  31. package/src/check-fonts.ts +26 -0
  32. package/src/check-renderer.ts +109 -0
  33. package/src/index.ts +96 -0
  34. package/src/solarbloom-params.ts +50 -0
  35. package/src/solarbloom-renderer.ts +135 -0
  36. package/src/solarbloom-shader.ts +461 -0
  37. package/src/solarbloom-tempo.ts +18 -0
  38. package/src/solarbloom.dope.json +552 -0
@@ -0,0 +1,461 @@
1
+ /**
2
+ * GLSL ES 3.00 source for Solarbloom.
3
+ *
4
+ * One full-screen pass renders several layers, all summed as light (the canvas
5
+ * is black and composited with `mix-blend-mode: screen`, so black == no change
6
+ * and bright == cast light onto the page beneath):
7
+ * 1. a volumetric, domain-warped FBM bloom with angular light shafts and a
8
+ * chromatic / spectral split + iridescent thin-film shimmer at its edge,
9
+ * 2. drifting light "motes" on buoyant, curling paths — depth-layered, with
10
+ * velocity-aligned motion-blur streaks and per-mote twinkle,
11
+ * 3. a checkmark drawn in light, with a bright leading spark + afterglow.
12
+ * A filmic (ACES-ish) tonemap and an ordered dither finish the frame, killing
13
+ * the banding that a smooth radial gradient would otherwise show.
14
+ *
15
+ * It is deliberately a single fragment pass: under software WebGL (SwiftShader)
16
+ * a multi-pass FBO blur is the expensive thing, whereas analytic noise/SDF math
17
+ * stays cheap and is identical frame-to-frame (pure function of uTimeS).
18
+ */
19
+
20
+ import {
21
+ GLSL_CONSTANTS,
22
+ GLSL_DISPERSION,
23
+ GLSL_DITHER,
24
+ GLSL_DOMAIN_WARP,
25
+ GLSL_FBM,
26
+ GLSL_HASH,
27
+ GLSL_IRIDESCENT,
28
+ GLSL_PALETTE_MIX,
29
+ GLSL_SD_SEG,
30
+ GLSL_TONEMAP_ACES,
31
+ } from "@dopaminefx/core";
32
+ import { GLSL_PARTICLES } from "@dopaminefx/core";
33
+
34
+ /**
35
+ * Max drifting light motes. The single source of truth for the cap: it is BOTH
36
+ * the GLSL `#define MAX_MOTES` (interpolated below) and the integer-clamp const
37
+ * the `.dope` mapping references (passed to the loader as `MAX_MOTES`). Counts
38
+ * above this won't render (the shader loop is bounded by the define).
39
+ */
40
+ export const MAX_MOTES = 80;
41
+
42
+ export const VERTEX_SRC = /* glsl */ `#version 300 es
43
+ void main() {
44
+ // Single full-screen triangle from gl_VertexID — no vertex buffers needed.
45
+ vec2 pos = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
46
+ gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
47
+ }`;
48
+
49
+ export const FRAGMENT_SRC = /* glsl */ `#version 300 es
50
+ precision highp float;
51
+ out vec4 fragColor;
52
+
53
+ uniform vec2 uResolution; // device pixels
54
+ uniform vec2 uOrigin; // bloom origin, gl coords (y up)
55
+ uniform float uAmp; // envelope amplitude (peaks > 1)
56
+ uniform float uCheck; // checkmark draw progress 0..1
57
+ uniform float uLife; // total normalized progress 0..1
58
+ uniform float uTimeS; // elapsed seconds
59
+ uniform float uExposure;
60
+ uniform float uBloomRadius; // fraction of min viewport dim
61
+ uniform float uTurbulence;
62
+ uniform sampler2D uMotePanel; // RGB = pre-rasterized drifting motes (lit colour × fade × twinkle)
63
+ uniform float uMoteSeed; // still seeds the bloom's domain-warp offset
64
+ uniform float uIridescence; // 0..1 thin-film shimmer strength
65
+ uniform float uDispersion; // 0..1 spectral split strength at the bloom edge
66
+ uniform float uStyle; // 0..1 photoreal -> non-photoreal (cel-shaded / hand-drawn)
67
+ uniform float uShadow; // 0 = light pass (screen), 1 = shadow pass (multiply)
68
+ uniform vec2 uShadowOffset; // device-px offset of the cast silhouette (away from light)
69
+ uniform float uShadowSoft; // penumbra softness in device px (blur tap radius)
70
+ uniform float uShadowStrength;// 0..1 max darkening of the multiply layer
71
+ uniform vec3 uC0;
72
+ uniform vec3 uC1;
73
+ uniform vec3 uC2;
74
+ uniform sampler2D uCheckTex; // alpha mask of the chosen check GLYPH (✓ / ✔), centred, premult-free
75
+ uniform float uCheckTexOn; // 1 = sample the real font glyph; 0 = analytic SDF fallback
76
+ uniform float uCheckBox; // half-size (device px) of the square glyph box around uOrigin
77
+ uniform sampler2D uSdfTex; // baked outline SDF (R/A = normalized distance-to-stroke 0..1)
78
+ uniform float uSdfOn; // 1 = drive the icon from the baked SDF (geometry seam)
79
+ uniform float uSdfRangePx; // device px that map to the SDF's full 0..1 distance range
80
+ uniform float uSdfStrokePx; // half stroke width (device px) the SDF coverage reads at
81
+
82
+ #define MAX_MOTES ${MAX_MOTES}
83
+ ${GLSL_CONSTANTS}
84
+ ${GLSL_HASH}
85
+ ${GLSL_FBM}
86
+ ${GLSL_DOMAIN_WARP}
87
+ ${GLSL_PALETTE_MIX}
88
+ ${GLSL_IRIDESCENT}
89
+ ${GLSL_DISPERSION}
90
+ ${GLSL_SD_SEG}
91
+ ${GLSL_TONEMAP_ACES}
92
+ ${GLSL_DITHER}
93
+ ${GLSL_PARTICLES}
94
+
95
+ // Radial bloom intensity at a normalized radius dn (1.0 == bloom edge). Sampled
96
+ // three times at channel-shifted radii to get a spectral split at the rim.
97
+ float bloomProfile(float dn){
98
+ // Softened central spike (a flatter core) so the very middle keeps its
99
+ // palette colour and doesn't clip to white — leaving room for the checkmark
100
+ // to read as the brightest thing at the centre.
101
+ float core = exp(-dn * dn * 2.4) * 0.92;
102
+ float halo = exp(-dn * 1.3) * 0.5;
103
+ return core + halo;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // GLYPH CHECKMARK — a REAL font glyph (✓ / ✔, chosen by whimsy) rasterized into
108
+ // uCheckTex and sampled here. It sits centred on uOrigin in a square box of half
109
+ // size uCheckBox. The glyph "draws itself": we reveal it along a diagonal wipe
110
+ // (lower-left → upper-right, the natural pen path of a tick) driven by uCheck, so
111
+ // the reveal stays a pure function of time. Returns coverage in .x and the wipe
112
+ // frontier coordinate (0..1 along the draw axis) in .y for the leading spark.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ // Map a device-pixel sample to this glyph's UV (origin bottom-left, y up). The
116
+ // texture is uploaded FLIP_Y so the canvas (y-down) glyph lands upright.
117
+ vec2 glyphUV(vec2 frag){
118
+ return (frag - uOrigin) / (2.0 * uCheckBox) + 0.5;
119
+ }
120
+
121
+ // Normalized progress along the diagonal draw axis at a glyph-UV point. The tick
122
+ // is drawn from its bottom-left to its top-right tip, so the axis is mostly +x
123
+ // with a gentle upward bias; tuned so the short down-stroke reveals first.
124
+ float glyphDrawAxis(vec2 uv){
125
+ return clamp((uv.x * 0.86 + uv.y * 0.14), 0.0, 1.0);
126
+ }
127
+
128
+ // Glyph coverage at frag, gated by the draw-in wipe. uCheck 0..1 sweeps the
129
+ // frontier across the draw axis with a soft leading edge.
130
+ float glyphCoverage(vec2 frag, out float axisHere){
131
+ vec2 uv = glyphUV(frag);
132
+ axisHere = glyphDrawAxis(uv);
133
+ // Outside the box there is no glyph (CLAMP_TO_EDGE would otherwise smear).
134
+ if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) return 0.0;
135
+ float a = texture(uCheckTex, uv).a;
136
+ // Soft wipe: pixels ahead of the frontier are still "undrawn". The frontier
137
+ // runs a touch past 1.0 at uCheck=1 so the whole glyph completes.
138
+ float frontier = uCheck * 1.12;
139
+ float wipe = smoothstep(frontier, frontier - 0.07, axisHere);
140
+ return a * wipe;
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // BAKED-SDF ICON — the geometry seam. The icon outline (a checkmark, a cross,
145
+ // any swapped svgPath) is baked into a small distance field at build time and
146
+ // uploaded as uSdfTex; this shader only SAMPLES it. The texture stores, per
147
+ // texel, the normalized distance to the stroke centerline (0 = on the stroke,
148
+ // 1 = uSdfRange author-units away). We turn that into crisp stroke coverage +
149
+ // a soft glow at sample time, reusing the SAME diagonal draw-in wipe + leading
150
+ // spark as the glyph path so swapping the path changes the rendered icon with
151
+ // NO shader edit.
152
+ // ---------------------------------------------------------------------------
153
+ float sdfCoverage(vec2 frag, out float axisHere, out float distPx){
154
+ vec2 uv = glyphUV(frag);
155
+ // The baked checkmark fills its viewBox edge-to-edge, so the glyph box is tight
156
+ // around the strokes. Rather than HARD-cutting outside [0,1] (which crops the
157
+ // soft glow into a rectangle at the corners), sample the SDF CLAMPED to the box
158
+ // edge and ADD the extra device-px distance beyond it — so the glow keeps fading
159
+ // radially and vanishes smoothly. Stroke coverage is unaffected (it only reads
160
+ // sub-stroke distances, always well inside the box).
161
+ vec2 cl = clamp(uv, 0.0, 1.0);
162
+ axisHere = glyphDrawAxis(cl);
163
+ float nd = texture(uSdfTex, cl).r;
164
+ float outsidePx = length((uv - cl) * 2.0 * uCheckBox);
165
+ distPx = nd * uSdfRangePx + outsidePx;
166
+ float frontier = uCheck * 1.12;
167
+ float wipe = smoothstep(frontier, frontier - 0.07, axisHere);
168
+ return wipe;
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // SHADOW silhouette — a cheap occlusion field for the bright forms (bloom core,
173
+ // motes, checkmark). Used only by the shadow pass: we don't need the full
174
+ // volumetric look, just where the effect is "solid enough" to block light. It's
175
+ // deliberately a fraction of the cost of the light pass (no FBM, no per-mote
176
+ // streak/twinkle), so the extra pass stays cheap under software WebGL.
177
+ // p is a device-pixel sample point. Returns 0..~1 coverage.
178
+
179
+ // Bloom mass: the focused core casts the bulk of the shadow; the halo only a
180
+ // faint ambient occlusion. Matches bloomProfile's shape but flatter. Cheap +
181
+ // fragment-local, so it's fine to re-evaluate per blur tap.
182
+ float bloomOcc(vec2 p, float r){
183
+ float dn = length(p - uOrigin) / r;
184
+ return exp(-dn * dn * 2.0) * 0.9 + exp(-dn * 1.4) * 0.18;
185
+ }
186
+
187
+ // Checkmark mass — cast from the SAME source as the light pass so the
188
+ // silhouette matches: the real font glyph when present, else the analytic SDF.
189
+ // Fragment-local (a texture sample / segment SDF), so re-evaluated per tap.
190
+ float checkOcc(vec2 p, float minDim){
191
+ float cr = minDim * 0.11;
192
+ float sw = cr * 0.12;
193
+ if (uSdfOn > 0.5) {
194
+ float axisHere; float distPx;
195
+ float wipe = sdfCoverage(p, axisHere, distPx);
196
+ return (1.0 - smoothstep(uSdfStrokePx * 0.6, uSdfStrokePx * 1.4, distPx)) * wipe * 0.8;
197
+ } else if (uCheckTexOn > 0.5) {
198
+ float axisHere;
199
+ return glyphCoverage(p, axisHere) * 0.8;
200
+ }
201
+ vec2 A = uOrigin + cr * vec2(-0.9, 0.15);
202
+ vec2 B = uOrigin + cr * vec2(-0.25, -0.55);
203
+ vec2 C = uOrigin + cr * vec2(1.0, 0.78);
204
+ float l1 = length(B - A), l2 = length(C - B);
205
+ float drawn = uCheck * (l1 + l2);
206
+ float vis1 = clamp(drawn, 0.0, l1);
207
+ vec2 tip = A + (B - A) * (vis1 / l1);
208
+ float dseg = sdSeg(p, A, tip);
209
+ if (drawn > l1) {
210
+ float d2 = clamp(drawn - l1, 0.0, l2);
211
+ vec2 tip2 = B + (C - B) * (d2 / l2);
212
+ dseg = min(dseg, sdSeg(p, B, tip2));
213
+ }
214
+ return (1.0 - smoothstep(sw * 0.6, sw * 1.4, dseg)) * 0.8;
215
+ }
216
+
217
+ // Soft, offset cast silhouette → multiply colour. An 8-tap ring blur (+centre)
218
+ // gives the penumbra. The expensive part is the MAX_MOTES loop, whose per-mote
219
+ // pose (hash-driven position/size/fade) is fragment-INDEPENDENT — re-walking it
220
+ // per tap recomputed those poses 9x (the dominant cost under software/ANGLE
221
+ // WebGL). Instead we walk the motes ONCE, computing each pose a single time, and
222
+ // fold its soft-dot contribution into all 9 tap accumulators. The cheap, frag-
223
+ // local bloom + checkmark terms are still evaluated per tap. Mathematically
224
+ // identical to summing 9 independent solarOcclusion() calls.
225
+ vec4 shadowColor(vec2 frag){
226
+ float minDim = min(uResolution.x, uResolution.y);
227
+ float r = uBloomRadius * minDim;
228
+ // Sample point is pushed AGAINST the shadow offset, so the resulting dark
229
+ // silhouette lands offset away from the bright core (toward uShadowOffset).
230
+ vec2 sp = frag - uShadowOffset;
231
+ float soft = uShadowSoft;
232
+ float s2 = soft * 0.7071;
233
+ vec2 taps[9];
234
+ taps[0] = sp;
235
+ taps[1] = sp + vec2( soft, 0.0);
236
+ taps[2] = sp + vec2(-soft, 0.0);
237
+ taps[3] = sp + vec2(0.0, soft);
238
+ taps[4] = sp + vec2(0.0, -soft);
239
+ taps[5] = sp + vec2( s2, s2);
240
+ taps[6] = sp + vec2(-s2, s2);
241
+ taps[7] = sp + vec2( s2, -s2);
242
+ taps[8] = sp + vec2(-s2, -s2);
243
+
244
+ // Frag-local terms (bloom + checkmark) per tap.
245
+ float occ[9];
246
+ for (int k = 0; k < 9; k++) occ[k] = bloomOcc(taps[k], r) + checkOcc(taps[k], minDim);
247
+
248
+ // Motes: sample the pre-rasterized mote panel (its luma = mote mass) per tap,
249
+ // instead of re-deriving every mote's pose here.
250
+ for (int k = 0; k < 9; k++) {
251
+ vec3 m = texture(uMotePanel, taps[k] / uResolution).rgb;
252
+ occ[k] += max(max(m.r, m.g), m.b) * 0.6;
253
+ }
254
+
255
+ // Match the original: clamp each tap's mass*uAmp, then average the 9 taps.
256
+ float blurred = 0.0;
257
+ for (int k = 0; k < 9; k++) blurred += clamp(occ[k] * uAmp, 0.0, 1.0);
258
+ blurred /= 9.0;
259
+ // Soften and gate by strength. multiply layer: 1.0 = no change, lower = darker.
260
+ float dark = clamp(blurred, 0.0, 1.0) * uShadowStrength;
261
+ // Slightly warm/cool tint via palette so the shadow isn't pure neutral grey —
262
+ // it reads as the effect's own coloured occlusion. Keep it subtle.
263
+ vec3 tint = mix(vec3(1.0), 0.6 + 0.4 * normalize(uC0 + 1e-3), 0.25);
264
+ vec3 mul = mix(vec3(1.0), tint, dark);
265
+ return vec4(mul, 1.0);
266
+ }
267
+
268
+ void main(){
269
+ vec2 frag = gl_FragCoord.xy;
270
+ float minDim = min(uResolution.x, uResolution.y);
271
+ float r = uBloomRadius * minDim;
272
+ vec3 col = vec3(0.0);
273
+
274
+ if (uShadow > 0.5) {
275
+ fragColor = shadowColor(frag);
276
+ return;
277
+ }
278
+
279
+ vec2 rel = frag - uOrigin;
280
+ float ang = atan(rel.y, rel.x);
281
+ float d = length(rel);
282
+ vec2 ndir = rel / max(d, 1e-4);
283
+
284
+ // ---- Volumetric bloom: domain-warped FBM + spectral edge + iridescence ----
285
+ // Two-level domain warp: warp the sample point by an fbm-derived offset, then
286
+ // sample fbm again. This gives the smoke-like, living interior of a real bloom
287
+ // instead of a clean gaussian.
288
+ vec2 sp = vec2(ang * 1.6, d / r * 2.2) + uMoteSeed;
289
+ // Shared two-level domain warp (look/glsl domainWarp): the smoke-like interior.
290
+ float fbmTex = domainWarp(sp, uTimeS, uTurbulence);
291
+ // The warp perturbs the radius only modestly, so the core stays a focused
292
+ // burst rather than blooming into an all-over haze.
293
+ float dn = d / r * (1.0 + 0.18 * (fbmTex - 0.5) * uTurbulence);
294
+
295
+ // Spectral split: sample the radial falloff at slightly different radii per
296
+ // channel. Strength grows toward the rim (where dn ~ 1) — like dispersion at
297
+ // a refractive edge — and is gated by uDispersion + amplitude (shared helper).
298
+ float disp = dispersionAmount(uDispersion, dn, uAmp);
299
+ float pr = bloomProfile(dn * (1.0 - disp));
300
+ float pg = bloomProfile(dn);
301
+ float pb = bloomProfile(dn * (1.0 + disp));
302
+ vec3 spectral = vec3(pr, pg, pb);
303
+
304
+ // Tint the bloom by the palette (inner→outer) and modulate by the spectral
305
+ // split so the rim fringes into chromatic color.
306
+ vec3 bloomTint = paletteMix(dn * 0.9);
307
+ // Filaments / faint god-ray shafts: an angular noise field, sharpened, that
308
+ // streaks outward and rotates slowly. Subtle, fades with radius.
309
+ float shafts = fbm(vec2(ang * 5.0 + uTimeS * 0.2, d / r * 1.5));
310
+ shafts = pow(smoothstep(0.4, 0.95, shafts), 2.0);
311
+ float shaftFall = exp(-dn * 1.3) * smoothstep(0.05, 0.5, dn);
312
+ float bloomGain = uAmp * uExposure;
313
+ col += bloomTint * spectral * bloomGain;
314
+ col += bloomTint * shafts * shaftFall * 0.3 * bloomGain * (0.5 + 0.5 * uTurbulence);
315
+
316
+ // Iridescent thin-film shimmer riding on the bloom shell. Rather than adding
317
+ // full-spectrum light (which washes the mood color out), we use it to *tint*
318
+ // the existing bloom on a thin mid-bloom ring — an oil-slick sheen that
319
+ // colours the rim without overpowering the palette identity of the mood.
320
+ float shell = exp(-pow((dn - 0.6) * 3.0, 2.0)); // narrow mid ring
321
+ float irPhase = ang * 0.5 + fbmTex * 1.5 + uTimeS * 0.4;
322
+ vec3 irid = iridescent(fract(irPhase));
323
+ float irMask = shell * uIridescence * pg; // gated by bloom light
324
+ col = mix(col, col * (0.4 + 1.6 * irid), irMask * 0.5);
325
+ col += irid * irMask * 0.18 * bloomGain; // faint additive sheen
326
+
327
+ // ---- Drifting light motes (depth-layered, streaked, twinkling) ----
328
+ // PERF: the motes used to be an 80-iteration per-pixel loop (O(pixels × motes),
329
+ // the dominant software-WebGL cost — the bloom itself is cheap). They're now
330
+ // rasterized into an offscreen panel each frame (pose + palette colour + streak
331
+ // + twinkle computed ONCE in JS — see solarbloom-renderer.ts); here we just
332
+ // sample that panel and apply the shared bloom gain. The volumetric bloom,
333
+ // iridescence, dispersion, shafts and the checkmark all stay procedural above.
334
+ col += texture(uMotePanel, gl_FragCoord.xy / uResolution).rgb * bloomGain;
335
+
336
+ // ---- Checkmark drawn in light, with leading spark + afterglow ----
337
+ // The checkmark is a REAL font glyph (✓ / ✔ chosen by whimsy) sampled from
338
+ // uCheckTex, falling back to the analytic two-segment SDF if the glyph texture
339
+ // failed to load. Either way it stays the brightest "drawn in light" element,
340
+ // preserves the draw-in wipe + leading spark, and casts light + shadow.
341
+ float cr = minDim * 0.11;
342
+ float sw = cr * 0.12;
343
+ float ccore; // crisp glyph body coverage (0..1)
344
+ float cglow; // soft surrounding glow
345
+ vec2 tip; // leading-edge point for the spark
346
+ float drawing; // 1 while the stroke is being laid down (gates the spark)
347
+
348
+ if (uSdfOn > 0.5) {
349
+ // -- BAKED-SDF PATH: sample the build-time distance field for the icon. The
350
+ // icon's SHAPE comes entirely from the .dope svgPath (baked → uSdfTex), so a
351
+ // host swapping the path changes the rendered icon with no shader edit. Same
352
+ // boil jitter + diagonal wipe + leading spark as the glyph path.
353
+ float bt = floor(uTimeS * 12.0);
354
+ vec2 boil = (hash21(bt + 1.7) - 0.5) * cr * 0.05 * uStyle;
355
+ vec2 gfrag = frag - boil;
356
+ float axisHere; float distPx;
357
+ float wipe = sdfCoverage(gfrag, axisHere, distPx);
358
+ float sw2 = uSdfStrokePx;
359
+ float softCore = smoothstep(sw2, sw2 * 0.35, distPx);
360
+ float hardCore = 1.0 - smoothstep(sw2 * 0.85, sw2, distPx);
361
+ ccore = mix(softCore, hardCore, uStyle) * wipe;
362
+ cglow = exp(-distPx / (sw2 * 2.0)) * 0.6 * (1.0 - 0.7 * uStyle) * wipe;
363
+ // The baked SDF saturates beyond its encoded range, so this soft glow's exp()
364
+ // never reaches zero inside the glyph box — a faint ghost BOX. Fade it out at
365
+ // the range edge so the glow hugs the stroke, not the box.
366
+ cglow *= 1.0 - smoothstep(uSdfRangePx * 0.55, uSdfRangePx * 0.9, distPx);
367
+ float frontier = clamp(uCheck * 1.12, 0.0, 1.0);
368
+ vec2 boxUVtoPx = vec2(2.0 * uCheckBox);
369
+ vec2 frontUV = vec2(frontier, 0.30 + frontier * 0.55);
370
+ tip = uOrigin + (frontUV - 0.5) * boxUVtoPx;
371
+ drawing = smoothstep(0.0, 0.04, uCheck) * (1.0 - smoothstep(0.92, 1.06, uCheck));
372
+ } else if (uCheckTexOn > 0.5) {
373
+ // -- GLYPH PATH: sample the rasterized font check, revealed by a diagonal --
374
+ // wipe so it "draws itself". A tiny screen-space boil jitter (on twos, scaled
375
+ // by style) keeps the hand-drawn feel at high whimsy.
376
+ float bt = floor(uTimeS * 12.0);
377
+ vec2 boil = (hash21(bt + 1.7) - 0.5) * cr * 0.05 * uStyle;
378
+ vec2 gfrag = frag - boil;
379
+ float axisHere;
380
+ float cov = glyphCoverage(gfrag, axisHere);
381
+ // Soft glow: re-sample the coverage slightly blurred via the box gradient.
382
+ // (Cheap: reuse the same masked coverage with a falloff vs the wipe frontier.)
383
+ ccore = smoothstep(0.35, 0.6, cov);
384
+ cglow = cov * 0.6 * (1.0 - 0.7 * uStyle);
385
+ // Leading spark: brightest where the wipe frontier currently crosses inked
386
+ // glyph pixels. frontier in draw-axis space; convert to a device-px point on
387
+ // the diagonal for the radial spark sprite.
388
+ float frontier = clamp(uCheck * 1.12, 0.0, 1.0);
389
+ // Reconstruct an approximate frontier point on the draw diagonal in the box.
390
+ vec2 axisDir = normalize(vec2(0.86, 0.14));
391
+ vec2 boxUVtoPx = vec2(2.0 * uCheckBox);
392
+ // Param 0..1 along axis → uv along the diagonal anchored at box centre line.
393
+ vec2 frontUV = vec2(frontier, 0.30 + frontier * 0.55);
394
+ tip = uOrigin + (frontUV - 0.5) * boxUVtoPx;
395
+ drawing = smoothstep(0.0, 0.04, uCheck) * (1.0 - smoothstep(0.92, 1.06, uCheck));
396
+ } else {
397
+ // -- ANALYTIC FALLBACK: the original two-segment SDF "drawn in light". ------
398
+ float bt = floor(uTimeS * 12.0);
399
+ vec2 A = uOrigin + cr * vec2(-0.9, 0.15) + (hash21(bt + 1.1) - 0.5) * cr * 0.06 * uStyle;
400
+ vec2 B = uOrigin + cr * vec2(-0.25, -0.55) + (hash21(bt + 2.2) - 0.5) * cr * 0.06 * uStyle;
401
+ vec2 C = uOrigin + cr * vec2(1.0, 0.78) + (hash21(bt + 3.3) - 0.5) * cr * 0.06 * uStyle;
402
+ float l1 = length(B - A), l2 = length(C - B);
403
+ float total = l1 + l2;
404
+ float drawn = uCheck * total;
405
+ float vis1 = clamp(drawn, 0.0, l1);
406
+ tip = A + (B - A) * (vis1 / l1);
407
+ float dseg = sdSeg(frag, A, tip);
408
+ if (drawn > l1) {
409
+ float d2 = clamp(drawn - l1, 0.0, l2);
410
+ tip = B + (C - B) * (d2 / l2);
411
+ dseg = min(dseg, sdSeg(frag, B, tip));
412
+ }
413
+ float softCore = smoothstep(sw, sw * 0.35, dseg);
414
+ float hardCore = 1.0 - smoothstep(sw * 0.85, sw, dseg);
415
+ ccore = mix(softCore, hardCore, uStyle);
416
+ cglow = exp(-dseg / (sw * 2.0)) * 0.7 * (1.0 - 0.7 * uStyle);
417
+ drawing = smoothstep(0.0, 0.04, uCheck) * (1.0 - smoothstep(0.92, 1.06, uCheck));
418
+ }
419
+
420
+ // Leading spark: a bright hot point at the pen tip while it's drawing, with a
421
+ // soft afterglow that lingers a moment after the stroke completes.
422
+ float tipDist = length(frag - tip);
423
+ float tipSize = sw * 1.6;
424
+ float sparkHead = tipSize / (tipDist + tipSize * 0.4);
425
+ sparkHead *= sparkHead;
426
+ float cFade = 1.0 - smoothstep(0.7, 1.0, uLife);
427
+ vec3 checkTint = mix(vec3(1.0), uC0 + 0.4, 0.5);
428
+ // The checkmark is the unambiguous confirmation, so it must out-shine the
429
+ // bloom core it sits inside — overdrive its core and leading spark. Keep it at
430
+ // FULL brightness regardless of intensity (don't dim via uExposure =
431
+ // lerp(intensity, 0.75, 1.5)); 1.5 = the exposure at full intensity, so the
432
+ // glyph reads identically at any intensity. (Still fades out via cFade.)
433
+ float checkExposure = 1.5;
434
+ col += (vec3(1.0) * ccore * 1.6 + checkTint * cglow) * cFade * checkExposure;
435
+ col += vec3(1.0) * sparkHead * drawing * cFade * checkExposure * 2.0;
436
+
437
+ // ---- Filmic tonemap + dither ----
438
+ // Pre-exposure < 1 keeps the bloom a focused burst (not an all-over haze)
439
+ // while ACES gives a graceful highlight rolloff.
440
+ col = tonemapACES(col * 0.62);
441
+
442
+ // ---- Non-photoreal pass: cel shading + neon flattening ----
443
+ // As style (whimsy) rises we leave true lighting behind: boost chroma toward
444
+ // flat neon and quantize the light into hard cel bands (fewer bands = more
445
+ // cartoon / cyberpunk). At style 0 this is a no-op.
446
+ if (uStyle > 0.001) {
447
+ float l = dot(col, vec3(0.299, 0.587, 0.114));
448
+ vec3 neon = clamp(l + (col - l) * 1.6, 0.0, 1.0); // punch up saturation
449
+ vec3 styled = mix(col, neon, 0.7);
450
+ float bands = mix(40.0, 4.0, uStyle); // hard posterize
451
+ styled = floor(styled * bands + 0.5) / bands;
452
+ col = mix(col, styled, uStyle);
453
+ }
454
+
455
+ // Ordered dither (~1/255, shared look/glsl ditherAdd) to break up the
456
+ // smooth-gradient banding the screen blend would otherwise reveal on the page.
457
+ // Faded out toward the cel end, where hard bands are the intended look.
458
+ col = ditherAdd(col, frag, uTimeS, 1.0 - uStyle);
459
+
460
+ fragColor = vec4(col, 1.0);
461
+ }`;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Solarbloom's bespoke timing — the checkmark draw window.
3
+ *
4
+ * The functional confirmation (the checkmark) draws within ~240 ms regardless
5
+ * of total duration — fast enough to land near the ~100 ms reward-prediction
6
+ * signal and read as an unambiguous "it worked". Built on the generic
7
+ * `easeOutCubic` primitive from `@dopaminefx/core`.
8
+ */
9
+
10
+ import { easeOutCubic } from "@dopaminefx/core";
11
+
12
+ /** Window (ms) over which the checkmark draws in, independent of total length. */
13
+ export const CHECK_DRAW_MS = 240;
14
+
15
+ /** Checkmark draw progress (0..1) given elapsed ms. */
16
+ export function checkProgress(elapsedMs: number): number {
17
+ return easeOutCubic(elapsedMs / CHECK_DRAW_MS);
18
+ }