@dopaminefx/effect-dots 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,38 @@
1
+ /**
2
+ * GLSL ES 3.00 source for **Dots** — Dopamine's calm ambient "thinking" indicator.
3
+ *
4
+ * Governing metaphor: a centred ROW of soft luminous dots, centred on the action
5
+ * point (`uOrigin`). They gently BREATHE together (a slow sine on radius +
6
+ * brightness) while a brightness PULSE travels across the row in sequence (the
7
+ * classic "typing… / loading…" read). Electric / serene OKLCH hues from the
8
+ * seeded palette.
9
+ *
10
+ * Dopamine's SECOND CONTINUOUS effect (after halo). Like halo it rides the
11
+ * first-class `tempo.loop` contract:
12
+ * - The `.dope` declares `tempo.loop.periodMs = 1000`; the runner derives the
13
+ * standard periodic clocks from it each frame — `uPhase` (normalized loop
14
+ * phase in [0, 1)) and `uLoopS` (seconds within the loop) — off the SAME
15
+ * "animate on twos"-snapped clock as `uTimeS`. The parser validates the seam
16
+ * invariants (the period is 12 on-twos steps; `durationMs` 4000 = 4 whole
17
+ * periods), so the frame at `t == durationMs` equals `t == 0` at EVERY whimsy.
18
+ * - ALL animation here is a periodic function of `uPhase`: `sin(TAU·uPhase)`
19
+ * for the breathe, and a chase whose head position is `fract(uPhase)` so it
20
+ * winds exactly ONE turn around the row per period and rejoins seamlessly.
21
+ * Nothing reads a monotonic clock (`uLife`/`uTimeS`), so every period
22
+ * boundary is seamless.
23
+ * - `frame()` returns a STEADY periodic `amp = 0.85 + 0.15·sin(TAU·phase)`
24
+ * (never `envelope(life)`), so there is no one-shot fade to break the seam.
25
+ * - The conductor re-arms the effect at `durationMs` instead of tearing down;
26
+ * the host stops it via the play handle (and can pause/resume it drift-free).
27
+ *
28
+ * whimsy == uStyle:
29
+ * 0 = photoreal soft glow — gaussian dots + a smooth feathered chase.
30
+ * 1 = cel / flat — the dots become hard flat discs and the chase snaps into a
31
+ * single posterized "lit" dot stepping along the row.
32
+ */
33
+ /** Compile-time cap on the dot count: BOTH the GLSL `#define MAX_DOTS` (below)
34
+ * and the integer-clamp const the `.dope` mapping references (`MAX_DOTS`). */
35
+ export declare const MAX_DOTS = 7;
36
+ export declare const DOTS_VERTEX_SRC = "#version 300 es\nout vec2 vUv;\nvoid main() {\n // Single full-screen triangle from gl_VertexID \u2014 no vertex buffers needed.\n vec2 pos = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));\n vUv = pos;\n gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);\n}";
37
+ export declare const DOTS_FRAGMENT_SRC = "#version 300 es\nprecision highp float;\nout vec4 fragColor;\n\nuniform vec2 uResolution; // device pixels\nuniform vec2 uOrigin; // row centre, gl coords (y up)\nuniform float uAmp; // STEADY periodic breathe gate (~0.85..1.0), not an envelope\nuniform float uPhase; // normalized loop phase [0,1) (tempo.loop) \u2014 drives ALL motion\nuniform float uLoopS; // seconds within the current loop (the dither's temporal seed)\nuniform float uExposure;\nuniform float uDotCount; // number of dots in the row (clamped to MAX_DOTS)\nuniform float uDotRadius; // dot radius as a fraction of min viewport dim\nuniform float uDotGap; // centre-to-centre spacing as a fraction of min viewport dim\nuniform float uBreathe; // 0..1 breathe depth (radius/brightness sine swing)\nuniform float uChase; // 0..1+ sharpness of the traveling pulse (tighter = livelier)\nuniform float uGlow; // 0..1 ambient under-glow brightness\nuniform float uStyle; // 0..1 photoreal soft glow -> cel/flat discs (whimsy)\nuniform float uShadow; // 0 = light pass (screen), 1 = shadow pass (multiply)\nuniform vec2 uShadowOffset; // device-px offset of the cast silhouette (away from light)\nuniform float uShadowSoft; // penumbra softness in device px (blur tap radius)\nuniform float uShadowStrength;// 0..1 max darkening of the multiply layer\nuniform vec3 uC0; // dot core color\nuniform vec3 uC1; // mid\nuniform vec3 uC2; // pulse accent\n\n#define MAX_DOTS 7\n\n\n#define TAU 6.28318530718\n\n\nfloat hash11(float p){ p = fract(p * 0.1031); p *= p + 33.33; p *= p + p; return fract(p); }\nvec2 hash21(float p){\n vec3 p3 = fract(vec3(p) * vec3(0.1031, 0.1030, 0.0973));\n p3 += dot(p3, p3.yzx + 33.33);\n return fract((p3.xx + p3.yz) * p3.zy);\n}\n\n\nvec3 paletteMix(float t){\n t = clamp(t, 0.0, 1.0);\n return t < 0.5 ? mix(uC0, uC1, t * 2.0) : mix(uC1, uC2, (t - 0.5) * 2.0);\n}\n\n\nvec3 tonemapACES(vec3 x){\n const float a = 2.51, b = 0.03, c = 2.43, d = 0.59, e = 0.14;\n return clamp((x * (a * x + b)) / (x * (c * x + d) + e), 0.0, 1.0);\n}\n\n\nvec3 ditherAdd(vec3 col, vec2 frag, float t, float fade){\n float dz = hash11(dot(frag, vec2(12.989, 78.233)) + t) - 0.5;\n return col + (dz / 255.0) * fade;\n}\n\n\n// The live count, clamped to the compile-time cap so the loop bound is constant.\nint dotCount(){\n return int(clamp(uDotCount, 1.0, float(MAX_DOTS)) + 0.5);\n}\n\n// The breathing dot radius. One slow sine per loop period swings the radius by\n// \u00B1uBreathe\u00B7radius around the base \u2014 the gentle pulse \"breath\". Periodic in\n// uPhase, so it returns to its t=0 value at every seam.\nfloat liveRadius(){\n return uDotRadius * (1.0 + sin(TAU * uPhase) * uBreathe * 0.45);\n}\n\n// The normalized x-centre of dot i: a row of dotCount dots, spaced uDotGap apart,\n// centred on the origin (so the whole row is symmetric about x = 0).\nfloat dotCenterX(int i, int count){\n float c = float(count);\n return (float(i) - (c - 1.0) * 0.5) * uDotGap;\n}\n\n// 0..1 \"lit\" weight of dot i under the traveling pulse. The pulse HEAD winds one\n// full turn around the row per period (head = fract(uPhase) -> seamless); each\n// dot lights as the head sweeps over its slot, with a comet-like falloff set by\n// uChase. Wrapped distance keeps it continuous across the seam.\nfloat pulseLit(int i, int count){\n float head = fract(uPhase) * float(count); // 0..count head position\n float d = abs(float(i) + 0.5 - head);\n d = min(d, float(count) - d); // wrap around the row\n float sharp = mix(0.9, 2.4, clamp(uChase, 0.0, 1.5) / 1.5);\n return exp(-(d * d) * sharp);\n}\n\n// The whole row's emitted light at a fragment.\nvec3 dotsLight(vec2 frag, float minDim){\n vec2 rel = (frag - uOrigin) / minDim; // normalized, row-centred (y up)\n int count = dotCount();\n float radius = max(liveRadius(), 1e-3);\n\n // The breathe also gently modulates overall brightness (brighter on the inhale).\n float breatheB = 1.0 + sin(TAU * uPhase) * uBreathe * 0.5;\n float gain = uAmp * uExposure * breatheB;\n\n vec3 col = vec3(0.0);\n for (int i = 0; i < MAX_DOTS; i++) {\n if (i >= count) break;\n float cx = dotCenterX(i, count);\n vec2 dpos = rel - vec2(cx, 0.0);\n float dist = length(dpos);\n\n // Per-dot hue register: stops walk along the row (C0 -> C1 -> C2), so the row\n // reads as a coherent gradient rather than identical clones.\n float tcol = (count > 1) ? float(i) / float(count - 1) : 0.5;\n vec3 dotCol = paletteMix(clamp(tcol, 0.0, 1.0));\n\n // The pulse brightens whichever dot the head is sweeping over.\n float lit = pulseLit(i, count);\n float bright = 0.35 + 0.65 * lit; // dim base, bright under the pulse\n\n // 1. CORE: a soft gaussian dot.\n float cov = exp(-(dist * dist) / (2.0 * radius * radius));\n col += dotCol * cov * gain * bright;\n\n // 2. GLOW: a wider, dim ambient halo under each dot.\n float gr = radius * 2.6;\n float glow = exp(-(dist * dist) / (2.0 * gr * gr));\n col += mix(dotCol, uC2, lit * 0.4) * glow * uGlow * gain * 0.3 * bright;\n }\n return col;\n}\n\n// ---------------------------------------------------------------------------\n// SHADOW silhouette \u2014 the dots are small floating discs, so each casts a faint\n// soft occlusion. Sample the row coverage at the offset shadow point and darken\n// in proportion, kept subtle (small discs throw little shadow).\nfloat dotsOcclusion(vec2 frag, float minDim){\n vec2 rel = (frag - uOrigin) / minDim;\n int count = dotCount();\n float radius = max(liveRadius(), 1e-3);\n float occ = 0.0;\n for (int i = 0; i < MAX_DOTS; i++) {\n if (i >= count) break;\n vec2 dpos = rel - vec2(dotCenterX(i, count), 0.0);\n float dist = length(dpos);\n occ = max(occ, exp(-(dist * dist) / (2.0 * radius * radius)));\n }\n return clamp(occ * uAmp, 0.0, 1.0);\n}\n\nvec4 dotsShadowColor(vec2 frag, float minDim){\n vec2 sp = frag - uShadowOffset;\n float soft = uShadowSoft;\n float occ = dotsOcclusion(sp, minDim);\n occ += dotsOcclusion(sp + vec2( soft, 0.0), minDim);\n occ += dotsOcclusion(sp + vec2(-soft, 0.0), minDim);\n occ += dotsOcclusion(sp + vec2(0.0, soft), minDim);\n occ += dotsOcclusion(sp + vec2(0.0, -soft), minDim);\n occ /= 5.0;\n float dark = clamp(occ, 0.0, 1.0) * uShadowStrength * 0.5;\n vec3 tint = mix(vec3(1.0), 0.6 + 0.4 * normalize(uC0 + 1e-3), 0.2);\n vec3 mul = mix(vec3(1.0), tint, dark);\n return vec4(mul, 1.0);\n}\n\nvoid main(){\n vec2 frag = gl_FragCoord.xy;\n vec2 res = uResolution;\n float minDim = min(res.x, res.y);\n\n if (uShadow > 0.5) {\n fragColor = dotsShadowColor(frag, minDim);\n return;\n }\n\n vec3 col = dotsLight(frag, minDim);\n\n // ---- Tone + finishing ----\n col = tonemapACES(col * 0.95);\n\n // ---- Non-photoreal pass: cel / flat discs (whimsy). ----\n // Toward the cel end the soft gaussians become hard flat DISCS and the chase\n // snaps to a single posterized \"lit\" dot. The pass-runner already steps the\n // clock \"on twos\" (periodic \u2014 tempo.loop tiles the grid \u2014 so the loop seam\n // survives); here we flatten the tone.\n if (uStyle > 0.001) {\n vec2 rel = (frag - uOrigin) / minDim;\n int count = dotCount();\n float radius = max(liveRadius(), 1e-3);\n float breatheB = 1.0 + sin(TAU * uPhase) * uBreathe * 0.5;\n float gain = uAmp * uExposure * breatheB;\n vec3 cel = vec3(0.0);\n for (int i = 0; i < MAX_DOTS; i++) {\n if (i >= count) break;\n vec2 dpos = rel - vec2(dotCenterX(i, count), 0.0);\n float dist = length(dpos);\n float disc = 1.0 - smoothstep(radius * 0.85, radius, dist); // hard flat disc\n float tcol = (count > 1) ? float(i) / float(count - 1) : 0.5;\n vec3 dotCol = clamp(paletteMix(clamp(tcol, 0.0, 1.0)) * 1.25, 0.0, 1.2);\n float lit = pulseLit(i, count);\n float litStep = step(0.6, lit); // one dot lit at a time\n cel += dotCol * disc * (0.4 + 0.6 * litStep)\n + mix(uC2, vec3(1.0), 0.5) * disc * litStep * 0.4;\n }\n cel *= gain;\n col = mix(col, cel, uStyle);\n }\n\n // Ordered dither (~1/255) to kill banding the screen blend reveals; faded out\n // toward the cel end where hard discs are intended. Seeded by the LOOP clock,\n // so even the dither field repeats exactly at the seam.\n col = ditherAdd(col, frag, uLoopS, 1.0 - uStyle);\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}";
38
+ //# sourceMappingURL=dots-shader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dots-shader.d.ts","sourceRoot":"","sources":["../src/dots-shader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAUH;8EAC8E;AAC9E,eAAO,MAAM,QAAQ,IAAI,CAAC;AAE1B,eAAO,MAAM,eAAe,oRAO1B,CAAC;AAEH,eAAO,MAAM,iBAAiB,06QAwL5B,CAAC"}
@@ -0,0 +1,230 @@
1
+ /**
2
+ * GLSL ES 3.00 source for **Dots** — Dopamine's calm ambient "thinking" indicator.
3
+ *
4
+ * Governing metaphor: a centred ROW of soft luminous dots, centred on the action
5
+ * point (`uOrigin`). They gently BREATHE together (a slow sine on radius +
6
+ * brightness) while a brightness PULSE travels across the row in sequence (the
7
+ * classic "typing… / loading…" read). Electric / serene OKLCH hues from the
8
+ * seeded palette.
9
+ *
10
+ * Dopamine's SECOND CONTINUOUS effect (after halo). Like halo it rides the
11
+ * first-class `tempo.loop` contract:
12
+ * - The `.dope` declares `tempo.loop.periodMs = 1000`; the runner derives the
13
+ * standard periodic clocks from it each frame — `uPhase` (normalized loop
14
+ * phase in [0, 1)) and `uLoopS` (seconds within the loop) — off the SAME
15
+ * "animate on twos"-snapped clock as `uTimeS`. The parser validates the seam
16
+ * invariants (the period is 12 on-twos steps; `durationMs` 4000 = 4 whole
17
+ * periods), so the frame at `t == durationMs` equals `t == 0` at EVERY whimsy.
18
+ * - ALL animation here is a periodic function of `uPhase`: `sin(TAU·uPhase)`
19
+ * for the breathe, and a chase whose head position is `fract(uPhase)` so it
20
+ * winds exactly ONE turn around the row per period and rejoins seamlessly.
21
+ * Nothing reads a monotonic clock (`uLife`/`uTimeS`), so every period
22
+ * boundary is seamless.
23
+ * - `frame()` returns a STEADY periodic `amp = 0.85 + 0.15·sin(TAU·phase)`
24
+ * (never `envelope(life)`), so there is no one-shot fade to break the seam.
25
+ * - The conductor re-arms the effect at `durationMs` instead of tearing down;
26
+ * the host stops it via the play handle (and can pause/resume it drift-free).
27
+ *
28
+ * whimsy == uStyle:
29
+ * 0 = photoreal soft glow — gaussian dots + a smooth feathered chase.
30
+ * 1 = cel / flat — the dots become hard flat discs and the chase snaps into a
31
+ * single posterized "lit" dot stepping along the row.
32
+ */
33
+ import { GLSL_CONSTANTS, GLSL_DITHER, GLSL_HASH, GLSL_PALETTE_MIX, GLSL_TONEMAP_ACES, } from "@dopaminefx/core";
34
+ /** Compile-time cap on the dot count: BOTH the GLSL `#define MAX_DOTS` (below)
35
+ * and the integer-clamp const the `.dope` mapping references (`MAX_DOTS`). */
36
+ export const MAX_DOTS = 7;
37
+ export const DOTS_VERTEX_SRC = /* glsl */ `#version 300 es
38
+ out vec2 vUv;
39
+ void main() {
40
+ // Single full-screen triangle from gl_VertexID — no vertex buffers needed.
41
+ vec2 pos = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
42
+ vUv = pos;
43
+ gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
44
+ }`;
45
+ export const DOTS_FRAGMENT_SRC = /* glsl */ `#version 300 es
46
+ precision highp float;
47
+ out vec4 fragColor;
48
+
49
+ uniform vec2 uResolution; // device pixels
50
+ uniform vec2 uOrigin; // row centre, gl coords (y up)
51
+ uniform float uAmp; // STEADY periodic breathe gate (~0.85..1.0), not an envelope
52
+ uniform float uPhase; // normalized loop phase [0,1) (tempo.loop) — drives ALL motion
53
+ uniform float uLoopS; // seconds within the current loop (the dither's temporal seed)
54
+ uniform float uExposure;
55
+ uniform float uDotCount; // number of dots in the row (clamped to MAX_DOTS)
56
+ uniform float uDotRadius; // dot radius as a fraction of min viewport dim
57
+ uniform float uDotGap; // centre-to-centre spacing as a fraction of min viewport dim
58
+ uniform float uBreathe; // 0..1 breathe depth (radius/brightness sine swing)
59
+ uniform float uChase; // 0..1+ sharpness of the traveling pulse (tighter = livelier)
60
+ uniform float uGlow; // 0..1 ambient under-glow brightness
61
+ uniform float uStyle; // 0..1 photoreal soft glow -> cel/flat discs (whimsy)
62
+ uniform float uShadow; // 0 = light pass (screen), 1 = shadow pass (multiply)
63
+ uniform vec2 uShadowOffset; // device-px offset of the cast silhouette (away from light)
64
+ uniform float uShadowSoft; // penumbra softness in device px (blur tap radius)
65
+ uniform float uShadowStrength;// 0..1 max darkening of the multiply layer
66
+ uniform vec3 uC0; // dot core color
67
+ uniform vec3 uC1; // mid
68
+ uniform vec3 uC2; // pulse accent
69
+
70
+ #define MAX_DOTS ${MAX_DOTS}
71
+
72
+ ${GLSL_CONSTANTS}
73
+ ${GLSL_HASH}
74
+ ${GLSL_PALETTE_MIX}
75
+ ${GLSL_TONEMAP_ACES}
76
+ ${GLSL_DITHER}
77
+
78
+ // The live count, clamped to the compile-time cap so the loop bound is constant.
79
+ int dotCount(){
80
+ return int(clamp(uDotCount, 1.0, float(MAX_DOTS)) + 0.5);
81
+ }
82
+
83
+ // The breathing dot radius. One slow sine per loop period swings the radius by
84
+ // ±uBreathe·radius around the base — the gentle pulse "breath". Periodic in
85
+ // uPhase, so it returns to its t=0 value at every seam.
86
+ float liveRadius(){
87
+ return uDotRadius * (1.0 + sin(TAU * uPhase) * uBreathe * 0.45);
88
+ }
89
+
90
+ // The normalized x-centre of dot i: a row of dotCount dots, spaced uDotGap apart,
91
+ // centred on the origin (so the whole row is symmetric about x = 0).
92
+ float dotCenterX(int i, int count){
93
+ float c = float(count);
94
+ return (float(i) - (c - 1.0) * 0.5) * uDotGap;
95
+ }
96
+
97
+ // 0..1 "lit" weight of dot i under the traveling pulse. The pulse HEAD winds one
98
+ // full turn around the row per period (head = fract(uPhase) -> seamless); each
99
+ // dot lights as the head sweeps over its slot, with a comet-like falloff set by
100
+ // uChase. Wrapped distance keeps it continuous across the seam.
101
+ float pulseLit(int i, int count){
102
+ float head = fract(uPhase) * float(count); // 0..count head position
103
+ float d = abs(float(i) + 0.5 - head);
104
+ d = min(d, float(count) - d); // wrap around the row
105
+ float sharp = mix(0.9, 2.4, clamp(uChase, 0.0, 1.5) / 1.5);
106
+ return exp(-(d * d) * sharp);
107
+ }
108
+
109
+ // The whole row's emitted light at a fragment.
110
+ vec3 dotsLight(vec2 frag, float minDim){
111
+ vec2 rel = (frag - uOrigin) / minDim; // normalized, row-centred (y up)
112
+ int count = dotCount();
113
+ float radius = max(liveRadius(), 1e-3);
114
+
115
+ // The breathe also gently modulates overall brightness (brighter on the inhale).
116
+ float breatheB = 1.0 + sin(TAU * uPhase) * uBreathe * 0.5;
117
+ float gain = uAmp * uExposure * breatheB;
118
+
119
+ vec3 col = vec3(0.0);
120
+ for (int i = 0; i < MAX_DOTS; i++) {
121
+ if (i >= count) break;
122
+ float cx = dotCenterX(i, count);
123
+ vec2 dpos = rel - vec2(cx, 0.0);
124
+ float dist = length(dpos);
125
+
126
+ // Per-dot hue register: stops walk along the row (C0 -> C1 -> C2), so the row
127
+ // reads as a coherent gradient rather than identical clones.
128
+ float tcol = (count > 1) ? float(i) / float(count - 1) : 0.5;
129
+ vec3 dotCol = paletteMix(clamp(tcol, 0.0, 1.0));
130
+
131
+ // The pulse brightens whichever dot the head is sweeping over.
132
+ float lit = pulseLit(i, count);
133
+ float bright = 0.35 + 0.65 * lit; // dim base, bright under the pulse
134
+
135
+ // 1. CORE: a soft gaussian dot.
136
+ float cov = exp(-(dist * dist) / (2.0 * radius * radius));
137
+ col += dotCol * cov * gain * bright;
138
+
139
+ // 2. GLOW: a wider, dim ambient halo under each dot.
140
+ float gr = radius * 2.6;
141
+ float glow = exp(-(dist * dist) / (2.0 * gr * gr));
142
+ col += mix(dotCol, uC2, lit * 0.4) * glow * uGlow * gain * 0.3 * bright;
143
+ }
144
+ return col;
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // SHADOW silhouette — the dots are small floating discs, so each casts a faint
149
+ // soft occlusion. Sample the row coverage at the offset shadow point and darken
150
+ // in proportion, kept subtle (small discs throw little shadow).
151
+ float dotsOcclusion(vec2 frag, float minDim){
152
+ vec2 rel = (frag - uOrigin) / minDim;
153
+ int count = dotCount();
154
+ float radius = max(liveRadius(), 1e-3);
155
+ float occ = 0.0;
156
+ for (int i = 0; i < MAX_DOTS; i++) {
157
+ if (i >= count) break;
158
+ vec2 dpos = rel - vec2(dotCenterX(i, count), 0.0);
159
+ float dist = length(dpos);
160
+ occ = max(occ, exp(-(dist * dist) / (2.0 * radius * radius)));
161
+ }
162
+ return clamp(occ * uAmp, 0.0, 1.0);
163
+ }
164
+
165
+ vec4 dotsShadowColor(vec2 frag, float minDim){
166
+ vec2 sp = frag - uShadowOffset;
167
+ float soft = uShadowSoft;
168
+ float occ = dotsOcclusion(sp, minDim);
169
+ occ += dotsOcclusion(sp + vec2( soft, 0.0), minDim);
170
+ occ += dotsOcclusion(sp + vec2(-soft, 0.0), minDim);
171
+ occ += dotsOcclusion(sp + vec2(0.0, soft), minDim);
172
+ occ += dotsOcclusion(sp + vec2(0.0, -soft), minDim);
173
+ occ /= 5.0;
174
+ float dark = clamp(occ, 0.0, 1.0) * uShadowStrength * 0.5;
175
+ vec3 tint = mix(vec3(1.0), 0.6 + 0.4 * normalize(uC0 + 1e-3), 0.2);
176
+ vec3 mul = mix(vec3(1.0), tint, dark);
177
+ return vec4(mul, 1.0);
178
+ }
179
+
180
+ void main(){
181
+ vec2 frag = gl_FragCoord.xy;
182
+ vec2 res = uResolution;
183
+ float minDim = min(res.x, res.y);
184
+
185
+ if (uShadow > 0.5) {
186
+ fragColor = dotsShadowColor(frag, minDim);
187
+ return;
188
+ }
189
+
190
+ vec3 col = dotsLight(frag, minDim);
191
+
192
+ // ---- Tone + finishing ----
193
+ col = tonemapACES(col * 0.95);
194
+
195
+ // ---- Non-photoreal pass: cel / flat discs (whimsy). ----
196
+ // Toward the cel end the soft gaussians become hard flat DISCS and the chase
197
+ // snaps to a single posterized "lit" dot. The pass-runner already steps the
198
+ // clock "on twos" (periodic — tempo.loop tiles the grid — so the loop seam
199
+ // survives); here we flatten the tone.
200
+ if (uStyle > 0.001) {
201
+ vec2 rel = (frag - uOrigin) / minDim;
202
+ int count = dotCount();
203
+ float radius = max(liveRadius(), 1e-3);
204
+ float breatheB = 1.0 + sin(TAU * uPhase) * uBreathe * 0.5;
205
+ float gain = uAmp * uExposure * breatheB;
206
+ vec3 cel = vec3(0.0);
207
+ for (int i = 0; i < MAX_DOTS; i++) {
208
+ if (i >= count) break;
209
+ vec2 dpos = rel - vec2(dotCenterX(i, count), 0.0);
210
+ float dist = length(dpos);
211
+ float disc = 1.0 - smoothstep(radius * 0.85, radius, dist); // hard flat disc
212
+ float tcol = (count > 1) ? float(i) / float(count - 1) : 0.5;
213
+ vec3 dotCol = clamp(paletteMix(clamp(tcol, 0.0, 1.0)) * 1.25, 0.0, 1.2);
214
+ float lit = pulseLit(i, count);
215
+ float litStep = step(0.6, lit); // one dot lit at a time
216
+ cel += dotCol * disc * (0.4 + 0.6 * litStep)
217
+ + mix(uC2, vec3(1.0), 0.5) * disc * litStep * 0.4;
218
+ }
219
+ cel *= gain;
220
+ col = mix(col, cel, uStyle);
221
+ }
222
+
223
+ // Ordered dither (~1/255) to kill banding the screen blend reveals; faded out
224
+ // toward the cel end where hard discs are intended. Seeded by the LOOP clock,
225
+ // so even the dither field repeats exactly at the seam.
226
+ col = ditherAdd(col, frag, uLoopS, 1.0 - uStyle);
227
+
228
+ fragColor = vec4(max(col, 0.0), 1.0);
229
+ }`;
230
+ //# sourceMappingURL=dots-shader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dots-shader.js","sourceRoot":"","sources":["../src/dots-shader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EACL,cAAc,EACd,WAAW,EACX,SAAS,EACT,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,kBAAkB,CAAC;AAE1B;8EAC8E;AAC9E,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,CAAC;AAE1B,MAAM,CAAC,MAAM,eAAe,GAAG,UAAU,CAAC;;;;;;;EAOxC,CAAC;AAEH,MAAM,CAAC,MAAM,iBAAiB,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;mBAyBzB,QAAQ;;EAEzB,cAAc;EACd,SAAS;EACT,gBAAgB;EAChB,iBAAiB;EACjB,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAyJX,CAAC"}
@@ -0,0 +1,331 @@
1
+ {
2
+ "fmt": "dopamine-effect",
3
+ "v": "1.0.0",
4
+ "id": "dopamine.loading.dots",
5
+ "meta": {
6
+ "name": "Dots",
7
+ "description": "A calm ambient 'thinking' indicator: a centred row of soft luminous dots that gently breathe while a brightness PULSE travels across them in sequence (the classic typing / loading read). Dopamine's second CONTINUOUS effect — like halo it LOOPS SEAMLESSLY via the first-class tempo.loop contract: all motion is driven by the standard periodic clocks uPhase/uLoopS the runners derive from tempo.loop.periodMs (1000 ms). The parser validates the seam invariants (the period is exactly 12 'animate-on-twos' steps, NPR_TIME_STEP_MS = 1000/12, and durationMs 4000 is exactly 4 periods), so the frame at t==durationMs matches t==0 at every whimsy. The conductor re-arms at durationMs until the host stops the play handle; amp is a steady gentle periodic breathe of phase (NOT envelope(life)), so there is no one-shot fade to break the seam. Fully declarative — no swift/ or android/ folder; the toolchain generates the Metal + Kotlin factories and shaders from this one .dope plus the single GLSL ES 3.00 source.",
8
+ "tags": [
9
+ "loading",
10
+ "ambient",
11
+ "continuous",
12
+ "looping",
13
+ "thinking"
14
+ ]
15
+ },
16
+ "controls": {
17
+ "mood": {
18
+ "type": "enum",
19
+ "default": "celebratory",
20
+ "options": [
21
+ "serene",
22
+ "celebratory",
23
+ "electric"
24
+ ],
25
+ "ui": "segmented"
26
+ },
27
+ "intensity": {
28
+ "type": "scalar",
29
+ "default": 0.7,
30
+ "min": 0,
31
+ "max": 1,
32
+ "step": 0.01,
33
+ "ui": "slider"
34
+ },
35
+ "whimsy": {
36
+ "type": "scalar",
37
+ "default": 0.5,
38
+ "min": 0,
39
+ "max": 1,
40
+ "step": 0.01,
41
+ "ui": "slider"
42
+ },
43
+ "seed": {
44
+ "type": "int",
45
+ "default": null,
46
+ "nullable": true
47
+ },
48
+ "origin": {
49
+ "type": "point",
50
+ "default": "center"
51
+ },
52
+ "target": {
53
+ "type": "selector",
54
+ "default": "document.body"
55
+ }
56
+ },
57
+ "baselines": {
58
+ "serene": {
59
+ "durationMs": 4000,
60
+ "lightness": 0.86,
61
+ "chroma": 0.08,
62
+ "hueCenter": 225,
63
+ "hueRange": 110,
64
+ "dotCount": 3,
65
+ "dotRadius": 0.032,
66
+ "dotGap": 0.11,
67
+ "breathe": 0.16,
68
+ "chase": 0.6,
69
+ "glow": 0.5
70
+ },
71
+ "celebratory": {
72
+ "durationMs": 4000,
73
+ "lightness": 0.82,
74
+ "chroma": 0.15,
75
+ "hueCenter": 200,
76
+ "hueRange": 280,
77
+ "dotCount": 4,
78
+ "dotRadius": 0.03,
79
+ "dotGap": 0.1,
80
+ "breathe": 0.2,
81
+ "chase": 0.85,
82
+ "glow": 0.65
83
+ },
84
+ "electric": {
85
+ "durationMs": 4000,
86
+ "lightness": 0.8,
87
+ "chroma": 0.22,
88
+ "hueCenter": 195,
89
+ "hueRange": 160,
90
+ "dotCount": 5,
91
+ "dotRadius": 0.026,
92
+ "dotGap": 0.092,
93
+ "breathe": 0.24,
94
+ "chase": 1.1,
95
+ "glow": 0.9
96
+ }
97
+ },
98
+ "palette": {
99
+ "model": "oklch",
100
+ "space": "linear-srgb",
101
+ "generator": "golden-angle",
102
+ "goldenAngleDeg": 137.50776405003785,
103
+ "stops": 3,
104
+ "hueSpread": 0.55,
105
+ "lightness": {
106
+ "baseline": "lightness",
107
+ "perStop": [
108
+ 0,
109
+ 0.06,
110
+ -0.05
111
+ ]
112
+ },
113
+ "chroma": {
114
+ "from": {
115
+ "mul": [
116
+ {
117
+ "baseline": "chroma"
118
+ },
119
+ {
120
+ "lerp": [
121
+ "intensity",
122
+ 0.7,
123
+ 1.5
124
+ ]
125
+ }
126
+ ]
127
+ },
128
+ "perStop": [
129
+ 0,
130
+ 0.02,
131
+ -0.01
132
+ ]
133
+ },
134
+ "seed": {
135
+ "deterministic": true,
136
+ "source": "controls.seed",
137
+ "prng": "mulberry32"
138
+ },
139
+ "perMood": {
140
+ "serene": {
141
+ "hueCenter": 225,
142
+ "hueRange": 110,
143
+ "lightness": 0.86,
144
+ "chroma": 0.08
145
+ },
146
+ "celebratory": {
147
+ "hueCenter": 200,
148
+ "hueRange": 280,
149
+ "lightness": 0.82,
150
+ "chroma": 0.15
151
+ },
152
+ "electric": {
153
+ "hueCenter": 195,
154
+ "hueRange": 160,
155
+ "lightness": 0.8,
156
+ "chroma": 0.22
157
+ }
158
+ }
159
+ },
160
+ "tempo": {
161
+ "durationMs": {
162
+ "from": {
163
+ "baseline": "durationMs"
164
+ }
165
+ },
166
+ "loop": {
167
+ "periodMs": 1000
168
+ },
169
+ "frame": {
170
+ "amp": {
171
+ "add": [
172
+ 0.85,
173
+ {
174
+ "mul": [
175
+ 0.15,
176
+ {
177
+ "sin": {
178
+ "mul": [
179
+ 6.283185307179586,
180
+ {
181
+ "input": "phase"
182
+ }
183
+ ]
184
+ }
185
+ }
186
+ ]
187
+ }
188
+ ]
189
+ },
190
+ "extras": {}
191
+ },
192
+ "reducedMotion": {
193
+ "peakMs": 0,
194
+ "holdMs": 600
195
+ }
196
+ },
197
+ "render": {
198
+ "params": {
199
+ "exposure": {
200
+ "type": "float",
201
+ "from": {
202
+ "lerp": [
203
+ "intensity",
204
+ 0.7,
205
+ 1.45
206
+ ]
207
+ }
208
+ },
209
+ "dotCount": {
210
+ "type": "int",
211
+ "from": {
212
+ "round": {
213
+ "baseline": "dotCount"
214
+ }
215
+ },
216
+ "clampMax": "MAX_DOTS",
217
+ "clampMin": "MIN_DOTS"
218
+ },
219
+ "dotRadius": {
220
+ "type": "float",
221
+ "from": {
222
+ "mul": [
223
+ {
224
+ "baseline": "dotRadius"
225
+ },
226
+ {
227
+ "lerp": [
228
+ "intensity",
229
+ 1.2,
230
+ 0.82
231
+ ]
232
+ }
233
+ ]
234
+ }
235
+ },
236
+ "dotGap": {
237
+ "type": "float",
238
+ "from": {
239
+ "baseline": "dotGap"
240
+ }
241
+ },
242
+ "breathe": {
243
+ "type": "float",
244
+ "from": {
245
+ "baseline": "breathe"
246
+ }
247
+ },
248
+ "chase": {
249
+ "type": "float",
250
+ "from": {
251
+ "mul": [
252
+ {
253
+ "baseline": "chase"
254
+ },
255
+ {
256
+ "lerp": [
257
+ "intensity",
258
+ 0.8,
259
+ 1.25
260
+ ]
261
+ }
262
+ ]
263
+ }
264
+ },
265
+ "glow": {
266
+ "type": "float",
267
+ "from": {
268
+ "mul": [
269
+ {
270
+ "baseline": "glow"
271
+ },
272
+ {
273
+ "lerp": [
274
+ "intensity",
275
+ 0.6,
276
+ 1.3
277
+ ]
278
+ }
279
+ ]
280
+ }
281
+ },
282
+ "style": {
283
+ "type": "float",
284
+ "from": {
285
+ "control": "whimsy"
286
+ }
287
+ }
288
+ },
289
+ "shadowHeightFrac": {
290
+ "min": [
291
+ {
292
+ "add": [
293
+ {
294
+ "param": "dotRadius"
295
+ },
296
+ 0.04
297
+ ]
298
+ },
299
+ 1
300
+ ]
301
+ },
302
+ "consts": {
303
+ "MAX_DOTS": 7,
304
+ "MIN_DOTS": 2
305
+ },
306
+ "config": {
307
+ "usesOrigin": true
308
+ },
309
+ "backends": {
310
+ "webgl2": {
311
+ "stage": "fullscreen-triangle",
312
+ "blend": "screen",
313
+ "shader": {
314
+ "program": "dots"
315
+ }
316
+ }
317
+ },
318
+ "fallbackOrder": [
319
+ "webgl2"
320
+ ]
321
+ },
322
+ "binding": {
323
+ "note": "CROSS-PLATFORM uniform-binding contract; SHIPS in the portable .dope. dotsSeed feeds the seeded palette only (no scatterWeb — the shader reads no seed uniform). dotCount is an integer uniform the shader reads to lay out the row. style is whimsy (set automatically), never a uniform.",
324
+ "excludeParams": [
325
+ "style"
326
+ ],
327
+ "scatterKey": "dotsSeed",
328
+ "extras": [],
329
+ "samplers": []
330
+ }
331
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Dots (the calm ambient "thinking" indicator) as an `EffectFactory` on the
3
+ * Dopamine backbone.
4
+ *
5
+ * FULLY DATA-DRIVEN: everything that isn't the GLSL lives in dots.dope.json —
6
+ * the mood→params mapping + OKLCH palette (the loader), AND the per-frame logic:
7
+ * `tempo.frame` (the steady periodic breathe gate), `render.shadowHeightFrac`
8
+ * (the dots' outer reach), `render.consts` (MAX_DOTS/MIN_DOTS), `render.config`
9
+ * and the uniform `binding` contract. `registerDopeEffect` interprets that data
10
+ * through the generic pass runner; this module is just the dot-row SHADER + the
11
+ * registration call. No swift/ or android/ folder — those factories + the MSL /
12
+ * Kotlin shaders are generated from this one .dope plus the GLSL source.
13
+ *
14
+ * CONTINUOUS / LOOPING. Dots is Dopamine's second continuous effect (after halo):
15
+ * it declares the first-class `tempo.loop` contract (`periodMs = 1000`): the
16
+ * parser validates the seam invariants (the period is exactly 12
17
+ * "animate-on-twos" steps and `durationMs = 4000` is exactly 4 periods), the
18
+ * runner derives the standard periodic clocks `uPhase`/`uLoopS` every frame, and
19
+ * `tempo.frame.amp` is a STEADY periodic breathe of that phase —
20
+ * `0.85 + 0.15·sin(2π·phase)` — so the frame at `t == durationMs` matches
21
+ * `t == 0` at every whimsy. The conductor re-arms it at every `durationMs` seam;
22
+ * the host stops it (and can pause/resume it) via the handle `play()` returns.
23
+ */
24
+ import { type EffectFactory, type PassParams } from "@dopaminefx/core";
25
+ /** The resolved render params Dots' shader consumes. */
26
+ export interface DotsParams extends PassParams {
27
+ exposure: number;
28
+ dotCount: number;
29
+ dotRadius: number;
30
+ dotGap: number;
31
+ breathe: number;
32
+ chase: number;
33
+ glow: number;
34
+ dotsSeed: number;
35
+ }
36
+ export declare const dots: EffectFactory<DotsParams>;
37
+ export default dots;
38
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAGH,OAAO,EAAiC,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAKtG,wDAAwD;AACxD,MAAM,WAAW,UAAW,SAAQ,UAAU;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;CAClB;AAID,eAAO,MAAM,IAAI,EAGkB,aAAa,CAAC,UAAU,CAAC,CAAC;AAE7D,eAAe,IAAI,CAAC"}