@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.
- package/dist/check-fonts.d.ts +22 -0
- package/dist/check-fonts.d.ts.map +1 -0
- package/dist/check-fonts.js +19 -0
- package/dist/check-fonts.js.map +1 -0
- package/dist/check-renderer.d.ts +31 -0
- package/dist/check-renderer.d.ts.map +1 -0
- package/dist/check-renderer.js +102 -0
- package/dist/check-renderer.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/solarbloom-params.d.ts +48 -0
- package/dist/solarbloom-params.d.ts.map +1 -0
- package/dist/solarbloom-params.js +7 -0
- package/dist/solarbloom-params.js.map +1 -0
- package/dist/solarbloom-renderer.d.ts +40 -0
- package/dist/solarbloom-renderer.d.ts.map +1 -0
- package/dist/solarbloom-renderer.js +110 -0
- package/dist/solarbloom-renderer.js.map +1 -0
- package/dist/solarbloom-shader.d.ts +28 -0
- package/dist/solarbloom-shader.d.ts.map +1 -0
- package/dist/solarbloom-shader.js +447 -0
- package/dist/solarbloom-shader.js.map +1 -0
- package/dist/solarbloom-tempo.d.ts +13 -0
- package/dist/solarbloom-tempo.d.ts.map +1 -0
- package/dist/solarbloom-tempo.js +16 -0
- package/dist/solarbloom-tempo.js.map +1 -0
- package/dist/solarbloom.dope.json +552 -0
- package/package.json +46 -0
- package/src/check-fonts.ts +26 -0
- package/src/check-renderer.ts +109 -0
- package/src/index.ts +96 -0
- package/src/solarbloom-params.ts +50 -0
- package/src/solarbloom-renderer.ts +135 -0
- package/src/solarbloom-shader.ts +461 -0
- package/src/solarbloom-tempo.ts +18 -0
- 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
|
+
}
|