@dopaminefx/effect-ripple 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,32 @@
1
+ /**
2
+ * Ripple (the tactile "droplet in a still pool" acknowledge effect) as an
3
+ * `EffectFactory` on the Dopamine backbone.
4
+ *
5
+ * FULLY DATA-DRIVEN (P2): everything that isn't the GLSL lives in
6
+ * ripple.dope.json — the mood→params mapping + OKLCH palette (the loader), AND
7
+ * the per-frame logic: `tempo.frame` (the held-breath envelope amp),
8
+ * `render.shadowHeightFrac` (the wave field's outward reach), `render.consts`
9
+ * (MAX_RINGS/MIN_RINGS), `render.config` and the uniform `binding` contract.
10
+ * `registerDopeEffect` interprets that data through the generic
11
+ * `createPassInstance` fullscreen-pass runner; this module is just the water
12
+ * SHADER + the registration call.
13
+ *
14
+ * Anchored at `uOrigin` (usesOrigin: true): concentric wavefronts expand from
15
+ * the action point. Distinct from Solarbloom's soft radial CORE — Ripple's light
16
+ * lives only on thin, moving ring crests + the caustics they refract.
17
+ */
18
+ import { type EffectFactory, type PassParams } from "@dopaminefx/core";
19
+ /** The resolved render params Ripple's shader consumes. */
20
+ export interface RippleParams extends PassParams {
21
+ exposure: number;
22
+ amplitude: number;
23
+ rings: number;
24
+ wavelength: number;
25
+ speed: number;
26
+ caustic: number;
27
+ overshoot: number;
28
+ rippleSeed: number;
29
+ }
30
+ export declare const ripple: EffectFactory<RippleParams>;
31
+ export default ripple;
32
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAGH,OAAO,EAAiC,KAAK,aAAa,EAAE,KAAK,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAKtG,2DAA2D;AAC3D,MAAM,WAAW,YAAa,SAAQ,UAAU;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB;AAID,eAAO,MAAM,MAAM,EAGgB,aAAa,CAAC,YAAY,CAAC,CAAC;AAE/D,eAAe,MAAM,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Ripple (the tactile "droplet in a still pool" acknowledge effect) as an
3
+ * `EffectFactory` on the Dopamine backbone.
4
+ *
5
+ * FULLY DATA-DRIVEN (P2): everything that isn't the GLSL lives in
6
+ * ripple.dope.json — the mood→params mapping + OKLCH palette (the loader), AND
7
+ * the per-frame logic: `tempo.frame` (the held-breath envelope amp),
8
+ * `render.shadowHeightFrac` (the wave field's outward reach), `render.consts`
9
+ * (MAX_RINGS/MIN_RINGS), `render.config` and the uniform `binding` contract.
10
+ * `registerDopeEffect` interprets that data through the generic
11
+ * `createPassInstance` fullscreen-pass runner; this module is just the water
12
+ * SHADER + the registration call.
13
+ *
14
+ * Anchored at `uOrigin` (usesOrigin: true): concentric wavefronts expand from
15
+ * the action point. Distinct from Solarbloom's soft radial CORE — Ripple's light
16
+ * lives only on thin, moving ring crests + the caustics they refract.
17
+ */
18
+ import { RIPPLE_FRAGMENT_SRC, RIPPLE_VERTEX_SRC } from "./ripple-shader.js";
19
+ import { parseDope, registerDopeEffect } from "@dopaminefx/core";
20
+ import doc from "./ripple.dope.json";
21
+ const DOPE = parseDope(doc);
22
+ // The whole factory (resolve / create / reducedMotion / program registration)
23
+ // is data: ripple.dope.json interpreted by the core backbone.
24
+ export const ripple = registerDopeEffect(DOPE, {
25
+ vertex: RIPPLE_VERTEX_SRC,
26
+ fragment: RIPPLE_FRAGMENT_SRC,
27
+ });
28
+ export default ripple;
29
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC5E,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAuC,MAAM,kBAAkB,CAAC;AACtG,OAAO,GAAG,MAAM,oBAAoB,CAAC;AAErC,MAAM,IAAI,GAAG,SAAS,CAAC,GAAa,CAAC,CAAC;AActC,8EAA8E;AAC9E,8DAA8D;AAC9D,MAAM,CAAC,MAAM,MAAM,GAAG,kBAAkB,CAAC,IAAI,EAAE;IAC7C,MAAM,EAAE,iBAAiB;IACzB,QAAQ,EAAE,mBAAmB;CAC9B,CAA6D,CAAC;AAE/D,eAAe,MAAM,CAAC"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * GLSL ES 3.00 source for **Ripple** — Dopamine's tactile "droplet in a still
3
+ * pool" acknowledge effect.
4
+ *
5
+ * Governing metaphor: a single drop strikes a calm water surface at the action
6
+ * point (`uOrigin`). Concentric WAVES expand outward, and each travelling
7
+ * wavefront REFRACTS bright caustic light that dances across the UI as the ring
8
+ * passes; behind the front, the surface settles back to still. It reads as
9
+ * water + light: physical, tactile, anchored.
10
+ *
11
+ * This is a deliberate DIVERGENCE from Solarbloom's volumetric radial bloom.
12
+ * Solarbloom is a soft glowing CORE that fills outward and lingers; Ripple has
13
+ * NO bright core — its light lives entirely on thin, moving RING crests and the
14
+ * caustic sparkle they refract. The motion is a set of discrete expanding
15
+ * annuli, not a swelling blob.
16
+ *
17
+ * Layers, summed as light (canvas is black, `mix-blend-mode: screen`, so black
18
+ * == no change, bright == cast light onto the page beneath):
19
+ * 1. WAVEFIELD — a sum of `uRings` radially-travelling cosine waves whose
20
+ * phase = k*r - w*t, launched in a quick stagger from the origin. The
21
+ * surface height + its radial gradient (slope) drive everything else.
22
+ * 2. CAUSTICS — the wave SLOPE bends light: bright filaments form where the
23
+ * curved surface focuses light (|slope| high, near crests), animated as the
24
+ * rings travel. This is the dancing light the brief describes.
25
+ * 3. CREST GLINT — a thin specular highlight riding the leading crest of each
26
+ * ring (the wet "shine" of the moving wavefront).
27
+ * 4. SETTLE — the whole field is gated by a radial wavefront envelope so light
28
+ * only appears where a ring currently is, then the pool goes still.
29
+ *
30
+ * Reward timing: uAmp (held-breath envelope) gates global brightness — a quick
31
+ * expanding swell then a gentle settle. Pure function of uTimeS (frame-perfect,
32
+ * cheap under SwiftShader: analytic waves + noise, single pass).
33
+ *
34
+ * whimsy == uStyle:
35
+ * 0 = photoreal smooth refraction — soft caustics, continuous crests, smooth
36
+ * OKLCH colour drift across the rings.
37
+ * 1 = stylized: hard concentric CEL rings (posterized crest bands), posterized
38
+ * caustics (chunky light cells), and the motion snaps "on twos" (the
39
+ * pass-runner already steps the clock; we also quantize the wave phase so
40
+ * the rings advance in discrete, posed jumps).
41
+ */
42
+ /**
43
+ * Max concurrent expanding rings. Single source of truth for the loop cap: it is
44
+ * BOTH the GLSL `#define MAX_RINGS` (interpolated below) and the integer-clamp
45
+ * const the `.dope` mapping references (passed to the loader as `MAX_RINGS`).
46
+ */
47
+ export declare const MAX_RINGS = 7;
48
+ export declare const RIPPLE_VERTEX_SRC = "#version 300 es\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 gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);\n}";
49
+ export declare const RIPPLE_FRAGMENT_SRC = "#version 300 es\nprecision highp float;\nout vec4 fragColor;\n\nuniform vec2 uResolution; // device pixels\nuniform vec2 uOrigin; // drop point, gl coords (y up)\nuniform float uAmp; // envelope amplitude (peaks > 1)\nuniform float uLife; // whole-effect progress 0..1\nuniform float uTimeS; // elapsed seconds (snapped \"on twos\" by style)\nuniform float uExposure;\nuniform float uAmplitude; // wave height (intensity)\nuniform float uRings; // number of concentric wavefronts launched\nuniform float uWavelength; // crest spacing as a fraction of min viewport dim\nuniform float uSpeed; // wave propagation speed (fraction of minDim / s)\nuniform float uCaustic; // 0..1 caustic-light brightness (intensity)\nuniform float uSeed; // per-fire hash offset\nuniform float uStyle; // 0..1 photoreal smooth refraction -> cel rings (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; // crest core color\nuniform vec3 uC1; // mid\nuniform vec3 uC2; // caustic accent\n\n#define MAX_RINGS 7\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\nfloat vnoise(vec2 p){\n vec2 i = floor(p), f = fract(p);\n vec2 u = f * f * (3.0 - 2.0 * f);\n float a = hash11(dot(i, vec2(1.0, 57.0)));\n float b = hash11(dot(i + vec2(1.0, 0.0), vec2(1.0, 57.0)));\n float c = hash11(dot(i + vec2(0.0, 1.0), vec2(1.0, 57.0)));\n float d = hash11(dot(i + vec2(1.0, 1.0), vec2(1.0, 57.0)));\n return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);\n}\nfloat fbm(vec2 p){\n float s = 0.0, a = 0.5;\n mat2 rot = mat2(0.80, -0.60, 0.60, 0.80);\n for (int i = 0; i < 4; i++) { s += a * vnoise(p); p = rot * p * 2.03; a *= 0.5; }\n return s;\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// A travelling ring's launch time as a fraction of life. The drop strikes at\n// t=0 and successive rings (the secondary swells of a real impact) follow in a\n// stagger wide enough that, at any instant, the rings sit at clearly DIFFERENT\n// radii \u2014 a family of distinct sizes rippling out, not bunched near-duplicates.\nfloat ringLaunch(int i){\n return float(i) * 0.12;\n}\n\n// The wave surface as a function of normalized radius rn (= r / minDim) and the\n// life clock. Returns height in h; the radial SLOPE (dHeight/dr) in slope;\n// and a 0..1 wavefront ENVELOPE in front (1 where a ring currently is, 0 in\n// the still water ahead/behind). Shared by the light pass and the shadow so the\n// cast occlusion tracks exactly the troughs that are drawn.\n//\n// Each ring is a radially-expanding wave packet: a cosine carrier (phase =\n// k*r - w*t) under a gaussian envelope that travels outward at uSpeed and\n// spreads/decays as 1/sqrt(r) (energy conservation on an expanding circle).\nvoid waveField(float rn, out float h, out float slope, out float front){\n h = 0.0; slope = 0.0; front = 0.0;\n float k = TAU / max(uWavelength, 0.001); // angular wavenumber (per rn)\n float w = k * uSpeed; // angular frequency\n int rings = int(clamp(uRings, 0.0, float(MAX_RINGS)) + 0.5);\n for (int i = 0; i < MAX_RINGS; i++) {\n if (i >= rings) break;\n float t0 = ringLaunch(i);\n float age = uLife - t0; // 0..(1-t0)\n if (age <= 0.0) continue;\n // Front radius travels outward; the packet starts tight and SWELLS markedly as\n // the ring expands, so each ring visibly changes size as it travels out (and an\n // older ring is both farther AND fatter than a younger one).\n float front_r = uSpeed * age; // expected crest of this ring\n float width = uWavelength * (1.0 + 2.6 * age); // packet half-extent (grows as it expands)\n float d = rn - front_r; // signed distance to the front\n float pkt = exp(-(d * d) / (2.0 * width * width));\n if (pkt < 0.002) continue;\n // Amplitude fades CONTINUOUSLY as the ring ages/expands (not just a late cutoff),\n // so each crest dims steadily as it grows \u2014 on top of the 1/sqrt(r) spreading.\n float decay = pow(max(1.0 - age, 0.0), 1.3);\n // 1/sqrt(r) spreading (clamped near the origin so the drop isn't a spike).\n float spread = 1.0 / sqrt(max(rn, uWavelength * 0.5));\n // On the cel end, quantize the carrier phase so the rings advance \"on twos\"\n // (discrete posed crests) instead of sliding smoothly.\n float phase = k * rn - w * uLife;\n float qstep = TAU * 0.5;\n float qphase = floor(phase / qstep) * qstep;\n phase = mix(phase, qphase, uStyle * 0.85);\n float amp = uAmplitude * pkt * decay * spread;\n h += amp * cos(phase);\n // d(h)/d(rn): carrier derivative dominates (the steep part that bends light).\n slope += -amp * k * sin(phase);\n front = max(front, pkt * decay);\n }\n}\n\n// ---------------------------------------------------------------------------\n// SHADOW silhouette \u2014 the wave TROUGHS cast a faint soft occlusion (a real\n// rippled surface dimples the light it sits in). We sample the wave height at\n// the offset shadow point and darken where the surface dips below rest (h < 0),\n// gated by the wavefront envelope so still water casts nothing. Kept subtle.\nfloat rippleOcclusion(vec2 frag){\n float minDim = min(uResolution.x, uResolution.y);\n float rn = length(frag - uOrigin) / minDim;\n float h, slope, front;\n waveField(rn, h, slope, front);\n float trough = max(-h, 0.0); // depth below rest\n return clamp(trough * 2.2 * front * uAmp, 0.0, 1.0);\n}\n\nvec4 rippleShadowColor(vec2 frag){\n vec2 sp = frag - uShadowOffset;\n float soft = uShadowSoft;\n float occ = rippleOcclusion(sp);\n occ += rippleOcclusion(sp + vec2( soft, 0.0));\n occ += rippleOcclusion(sp + vec2(-soft, 0.0));\n occ += rippleOcclusion(sp + vec2(0.0, soft));\n occ += rippleOcclusion(sp + vec2(0.0, -soft));\n occ /= 5.0;\n // Troughs are a faint dimple, so cap the darkening well below full strength.\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 = rippleShadowColor(frag);\n return;\n }\n\n vec3 col = vec3(0.0);\n vec2 rel = frag - uOrigin;\n float r = length(rel);\n float rn = r / minDim; // normalized radius\n vec2 rdir = rel / max(r, 1e-3); // outward unit (toward rim)\n\n // ---- The wave surface at this fragment. ----\n float h, slope, front;\n waveField(rn, h, slope, front);\n\n float gain = uAmp * uExposure;\n\n // Colour register: hue drifts gently OUTWARD across the rings (OKLCH palette\n // C0->C1->C2), so each expanding crest reads as a slightly different light \u2014\n // unique per fire (the palette is seeded). A touch of slow temporal drift +\n // tiny fbm break keeps it alive without going rainbow.\n float tcol = clamp(rn / (uWavelength * float(MAX_RINGS) * 0.9), 0.0, 1.0);\n tcol = fract(tcol + uTimeS * 0.04 + fbm(rel / minDim * 5.0 + uSeed) * 0.06);\n vec3 ringCol = paletteMix(tcol);\n\n // ---- 1. CRESTS: the bright wet ridge of each travelling wavefront. ----\n // Light lives on the positive crests (h > 0), masked to where a ring is.\n float crest = smoothstep(0.0, uAmplitude * 0.5, h) * front;\n col += ringCol * crest * gain * 0.9;\n\n // ---- 2. CAUSTICS: the wave SLOPE refracts/focuses light. A curved surface\n // bends parallel light into bright filaments; |slope| peaks on the steep\n // flanks between crest and trough, so the caustic web sits BETWEEN the rings\n // and dances as they travel. Sharpened to thin, bright lines. ----\n float foc = abs(slope);\n float caustic = pow(clamp(foc / (uAmplitude * 1.2 + 1e-3), 0.0, 1.0), 1.8);\n // A little noise breaks the caustic into a living, glittering web.\n float glit = 0.6 + 0.6 * fbm(rel / minDim * 22.0 - uTimeS * 0.5 + uSeed);\n caustic *= glit * front;\n // The accent hue carries the caustic light (a brighter, whiter highlight on top).\n col += mix(uC2, vec3(1.0), 0.35) * caustic * uCaustic * gain * 1.3;\n\n // ---- 3. CREST GLINT: a thin specular line riding each leading crest. ----\n float glint = smoothstep(0.85, 1.0, front) * smoothstep(uAmplitude * 0.55, uAmplitude * 0.9, h);\n col += vec3(1.0) * glint * gain * 0.5 * (0.5 + 0.5 * uCaustic);\n\n // ---- Tone + finishing ----\n col = tonemapACES(col * 0.95);\n\n // ---- Non-photoreal pass: cel rings + posterized caustics (whimsy). ----\n // Toward the cel end the smooth refraction becomes hard concentric BANDS: the\n // crest mask is thresholded into a flat ring, and the caustic web is posterized\n // into chunky light cells. The phase quantization in waveField already steps\n // the rings \"on twos\"; here we flatten their tone.\n if (uStyle > 0.001) {\n // Hard ring: a flat band where the crest is strong, with a brighter inner core.\n float band = smoothstep(0.18, 0.30, crest);\n float core = smoothstep(0.45, 0.60, crest);\n vec3 celRing = clamp(ringCol * 1.3, 0.0, 1.2) * band\n + clamp(uC0 * 1.6 + 0.1, 0.0, 1.3) * core;\n // Posterize the caustic light into 2 chunky levels (Ben-Day-ish cells),\n // and keep only the BRIGHT cells (drop the dim wash so the cel read stays\n // clean white-on-dark rings instead of a muddy mid-tone field).\n float caus = clamp(caustic * uCaustic, 0.0, 1.0);\n float causQ = step(0.5, caus) * 0.6 + step(0.8, caus) * 0.4;\n vec3 celCaustic = mix(uC2, vec3(1.0), 0.5) * causQ;\n vec3 cel = (celRing + celCaustic) * 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 bands are intended.\n col = ditherAdd(col, frag, uTimeS, 1.0 - uStyle);\n\n fragColor = vec4(max(col, 0.0), 1.0);\n}";
50
+ //# sourceMappingURL=ripple-shader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ripple-shader.d.ts","sourceRoot":"","sources":["../src/ripple-shader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAWH;;;;GAIG;AACH,eAAO,MAAM,SAAS,IAAI,CAAC;AAE3B,eAAO,MAAM,iBAAiB,uPAK5B,CAAC;AAEH,eAAO,MAAM,mBAAmB,izVAqM9B,CAAC"}
@@ -0,0 +1,253 @@
1
+ /**
2
+ * GLSL ES 3.00 source for **Ripple** — Dopamine's tactile "droplet in a still
3
+ * pool" acknowledge effect.
4
+ *
5
+ * Governing metaphor: a single drop strikes a calm water surface at the action
6
+ * point (`uOrigin`). Concentric WAVES expand outward, and each travelling
7
+ * wavefront REFRACTS bright caustic light that dances across the UI as the ring
8
+ * passes; behind the front, the surface settles back to still. It reads as
9
+ * water + light: physical, tactile, anchored.
10
+ *
11
+ * This is a deliberate DIVERGENCE from Solarbloom's volumetric radial bloom.
12
+ * Solarbloom is a soft glowing CORE that fills outward and lingers; Ripple has
13
+ * NO bright core — its light lives entirely on thin, moving RING crests and the
14
+ * caustic sparkle they refract. The motion is a set of discrete expanding
15
+ * annuli, not a swelling blob.
16
+ *
17
+ * Layers, summed as light (canvas is black, `mix-blend-mode: screen`, so black
18
+ * == no change, bright == cast light onto the page beneath):
19
+ * 1. WAVEFIELD — a sum of `uRings` radially-travelling cosine waves whose
20
+ * phase = k*r - w*t, launched in a quick stagger from the origin. The
21
+ * surface height + its radial gradient (slope) drive everything else.
22
+ * 2. CAUSTICS — the wave SLOPE bends light: bright filaments form where the
23
+ * curved surface focuses light (|slope| high, near crests), animated as the
24
+ * rings travel. This is the dancing light the brief describes.
25
+ * 3. CREST GLINT — a thin specular highlight riding the leading crest of each
26
+ * ring (the wet "shine" of the moving wavefront).
27
+ * 4. SETTLE — the whole field is gated by a radial wavefront envelope so light
28
+ * only appears where a ring currently is, then the pool goes still.
29
+ *
30
+ * Reward timing: uAmp (held-breath envelope) gates global brightness — a quick
31
+ * expanding swell then a gentle settle. Pure function of uTimeS (frame-perfect,
32
+ * cheap under SwiftShader: analytic waves + noise, single pass).
33
+ *
34
+ * whimsy == uStyle:
35
+ * 0 = photoreal smooth refraction — soft caustics, continuous crests, smooth
36
+ * OKLCH colour drift across the rings.
37
+ * 1 = stylized: hard concentric CEL rings (posterized crest bands), posterized
38
+ * caustics (chunky light cells), and the motion snaps "on twos" (the
39
+ * pass-runner already steps the clock; we also quantize the wave phase so
40
+ * the rings advance in discrete, posed jumps).
41
+ */
42
+ import { GLSL_CONSTANTS, GLSL_DITHER, GLSL_FBM, GLSL_HASH, GLSL_PALETTE_MIX, GLSL_TONEMAP_ACES, } from "@dopaminefx/core";
43
+ /**
44
+ * Max concurrent expanding rings. Single source of truth for the loop cap: it is
45
+ * BOTH the GLSL `#define MAX_RINGS` (interpolated below) and the integer-clamp
46
+ * const the `.dope` mapping references (passed to the loader as `MAX_RINGS`).
47
+ */
48
+ export const MAX_RINGS = 7;
49
+ export const RIPPLE_VERTEX_SRC = /* glsl */ `#version 300 es
50
+ void main() {
51
+ // Single full-screen triangle from gl_VertexID — no vertex buffers needed.
52
+ vec2 pos = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
53
+ gl_Position = vec4(pos * 2.0 - 1.0, 0.0, 1.0);
54
+ }`;
55
+ export const RIPPLE_FRAGMENT_SRC = /* glsl */ `#version 300 es
56
+ precision highp float;
57
+ out vec4 fragColor;
58
+
59
+ uniform vec2 uResolution; // device pixels
60
+ uniform vec2 uOrigin; // drop point, gl coords (y up)
61
+ uniform float uAmp; // envelope amplitude (peaks > 1)
62
+ uniform float uLife; // whole-effect progress 0..1
63
+ uniform float uTimeS; // elapsed seconds (snapped "on twos" by style)
64
+ uniform float uExposure;
65
+ uniform float uAmplitude; // wave height (intensity)
66
+ uniform float uRings; // number of concentric wavefronts launched
67
+ uniform float uWavelength; // crest spacing as a fraction of min viewport dim
68
+ uniform float uSpeed; // wave propagation speed (fraction of minDim / s)
69
+ uniform float uCaustic; // 0..1 caustic-light brightness (intensity)
70
+ uniform float uSeed; // per-fire hash offset
71
+ uniform float uStyle; // 0..1 photoreal smooth refraction -> cel rings (whimsy)
72
+ uniform float uShadow; // 0 = light pass (screen), 1 = shadow pass (multiply)
73
+ uniform vec2 uShadowOffset; // device-px offset of the cast silhouette (away from light)
74
+ uniform float uShadowSoft; // penumbra softness in device px (blur tap radius)
75
+ uniform float uShadowStrength;// 0..1 max darkening of the multiply layer
76
+ uniform vec3 uC0; // crest core color
77
+ uniform vec3 uC1; // mid
78
+ uniform vec3 uC2; // caustic accent
79
+
80
+ #define MAX_RINGS ${MAX_RINGS}
81
+ ${GLSL_CONSTANTS}
82
+ ${GLSL_HASH}
83
+ ${GLSL_FBM}
84
+ ${GLSL_PALETTE_MIX}
85
+ ${GLSL_TONEMAP_ACES}
86
+ ${GLSL_DITHER}
87
+
88
+ // A travelling ring's launch time as a fraction of life. The drop strikes at
89
+ // t=0 and successive rings (the secondary swells of a real impact) follow in a
90
+ // stagger wide enough that, at any instant, the rings sit at clearly DIFFERENT
91
+ // radii — a family of distinct sizes rippling out, not bunched near-duplicates.
92
+ float ringLaunch(int i){
93
+ return float(i) * 0.12;
94
+ }
95
+
96
+ // The wave surface as a function of normalized radius rn (= r / minDim) and the
97
+ // life clock. Returns height in h; the radial SLOPE (dHeight/dr) in slope;
98
+ // and a 0..1 wavefront ENVELOPE in front (1 where a ring currently is, 0 in
99
+ // the still water ahead/behind). Shared by the light pass and the shadow so the
100
+ // cast occlusion tracks exactly the troughs that are drawn.
101
+ //
102
+ // Each ring is a radially-expanding wave packet: a cosine carrier (phase =
103
+ // k*r - w*t) under a gaussian envelope that travels outward at uSpeed and
104
+ // spreads/decays as 1/sqrt(r) (energy conservation on an expanding circle).
105
+ void waveField(float rn, out float h, out float slope, out float front){
106
+ h = 0.0; slope = 0.0; front = 0.0;
107
+ float k = TAU / max(uWavelength, 0.001); // angular wavenumber (per rn)
108
+ float w = k * uSpeed; // angular frequency
109
+ int rings = int(clamp(uRings, 0.0, float(MAX_RINGS)) + 0.5);
110
+ for (int i = 0; i < MAX_RINGS; i++) {
111
+ if (i >= rings) break;
112
+ float t0 = ringLaunch(i);
113
+ float age = uLife - t0; // 0..(1-t0)
114
+ if (age <= 0.0) continue;
115
+ // Front radius travels outward; the packet starts tight and SWELLS markedly as
116
+ // the ring expands, so each ring visibly changes size as it travels out (and an
117
+ // older ring is both farther AND fatter than a younger one).
118
+ float front_r = uSpeed * age; // expected crest of this ring
119
+ float width = uWavelength * (1.0 + 2.6 * age); // packet half-extent (grows as it expands)
120
+ float d = rn - front_r; // signed distance to the front
121
+ float pkt = exp(-(d * d) / (2.0 * width * width));
122
+ if (pkt < 0.002) continue;
123
+ // Amplitude fades CONTINUOUSLY as the ring ages/expands (not just a late cutoff),
124
+ // so each crest dims steadily as it grows — on top of the 1/sqrt(r) spreading.
125
+ float decay = pow(max(1.0 - age, 0.0), 1.3);
126
+ // 1/sqrt(r) spreading (clamped near the origin so the drop isn't a spike).
127
+ float spread = 1.0 / sqrt(max(rn, uWavelength * 0.5));
128
+ // On the cel end, quantize the carrier phase so the rings advance "on twos"
129
+ // (discrete posed crests) instead of sliding smoothly.
130
+ float phase = k * rn - w * uLife;
131
+ float qstep = TAU * 0.5;
132
+ float qphase = floor(phase / qstep) * qstep;
133
+ phase = mix(phase, qphase, uStyle * 0.85);
134
+ float amp = uAmplitude * pkt * decay * spread;
135
+ h += amp * cos(phase);
136
+ // d(h)/d(rn): carrier derivative dominates (the steep part that bends light).
137
+ slope += -amp * k * sin(phase);
138
+ front = max(front, pkt * decay);
139
+ }
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // SHADOW silhouette — the wave TROUGHS cast a faint soft occlusion (a real
144
+ // rippled surface dimples the light it sits in). We sample the wave height at
145
+ // the offset shadow point and darken where the surface dips below rest (h < 0),
146
+ // gated by the wavefront envelope so still water casts nothing. Kept subtle.
147
+ float rippleOcclusion(vec2 frag){
148
+ float minDim = min(uResolution.x, uResolution.y);
149
+ float rn = length(frag - uOrigin) / minDim;
150
+ float h, slope, front;
151
+ waveField(rn, h, slope, front);
152
+ float trough = max(-h, 0.0); // depth below rest
153
+ return clamp(trough * 2.2 * front * uAmp, 0.0, 1.0);
154
+ }
155
+
156
+ vec4 rippleShadowColor(vec2 frag){
157
+ vec2 sp = frag - uShadowOffset;
158
+ float soft = uShadowSoft;
159
+ float occ = rippleOcclusion(sp);
160
+ occ += rippleOcclusion(sp + vec2( soft, 0.0));
161
+ occ += rippleOcclusion(sp + vec2(-soft, 0.0));
162
+ occ += rippleOcclusion(sp + vec2(0.0, soft));
163
+ occ += rippleOcclusion(sp + vec2(0.0, -soft));
164
+ occ /= 5.0;
165
+ // Troughs are a faint dimple, so cap the darkening well below full strength.
166
+ float dark = clamp(occ, 0.0, 1.0) * uShadowStrength * 0.5;
167
+ vec3 tint = mix(vec3(1.0), 0.6 + 0.4 * normalize(uC0 + 1e-3), 0.2);
168
+ vec3 mul = mix(vec3(1.0), tint, dark);
169
+ return vec4(mul, 1.0);
170
+ }
171
+
172
+ void main(){
173
+ vec2 frag = gl_FragCoord.xy;
174
+ vec2 res = uResolution;
175
+ float minDim = min(res.x, res.y);
176
+
177
+ if (uShadow > 0.5) {
178
+ fragColor = rippleShadowColor(frag);
179
+ return;
180
+ }
181
+
182
+ vec3 col = vec3(0.0);
183
+ vec2 rel = frag - uOrigin;
184
+ float r = length(rel);
185
+ float rn = r / minDim; // normalized radius
186
+ vec2 rdir = rel / max(r, 1e-3); // outward unit (toward rim)
187
+
188
+ // ---- The wave surface at this fragment. ----
189
+ float h, slope, front;
190
+ waveField(rn, h, slope, front);
191
+
192
+ float gain = uAmp * uExposure;
193
+
194
+ // Colour register: hue drifts gently OUTWARD across the rings (OKLCH palette
195
+ // C0->C1->C2), so each expanding crest reads as a slightly different light —
196
+ // unique per fire (the palette is seeded). A touch of slow temporal drift +
197
+ // tiny fbm break keeps it alive without going rainbow.
198
+ float tcol = clamp(rn / (uWavelength * float(MAX_RINGS) * 0.9), 0.0, 1.0);
199
+ tcol = fract(tcol + uTimeS * 0.04 + fbm(rel / minDim * 5.0 + uSeed) * 0.06);
200
+ vec3 ringCol = paletteMix(tcol);
201
+
202
+ // ---- 1. CRESTS: the bright wet ridge of each travelling wavefront. ----
203
+ // Light lives on the positive crests (h > 0), masked to where a ring is.
204
+ float crest = smoothstep(0.0, uAmplitude * 0.5, h) * front;
205
+ col += ringCol * crest * gain * 0.9;
206
+
207
+ // ---- 2. CAUSTICS: the wave SLOPE refracts/focuses light. A curved surface
208
+ // bends parallel light into bright filaments; |slope| peaks on the steep
209
+ // flanks between crest and trough, so the caustic web sits BETWEEN the rings
210
+ // and dances as they travel. Sharpened to thin, bright lines. ----
211
+ float foc = abs(slope);
212
+ float caustic = pow(clamp(foc / (uAmplitude * 1.2 + 1e-3), 0.0, 1.0), 1.8);
213
+ // A little noise breaks the caustic into a living, glittering web.
214
+ float glit = 0.6 + 0.6 * fbm(rel / minDim * 22.0 - uTimeS * 0.5 + uSeed);
215
+ caustic *= glit * front;
216
+ // The accent hue carries the caustic light (a brighter, whiter highlight on top).
217
+ col += mix(uC2, vec3(1.0), 0.35) * caustic * uCaustic * gain * 1.3;
218
+
219
+ // ---- 3. CREST GLINT: a thin specular line riding each leading crest. ----
220
+ float glint = smoothstep(0.85, 1.0, front) * smoothstep(uAmplitude * 0.55, uAmplitude * 0.9, h);
221
+ col += vec3(1.0) * glint * gain * 0.5 * (0.5 + 0.5 * uCaustic);
222
+
223
+ // ---- Tone + finishing ----
224
+ col = tonemapACES(col * 0.95);
225
+
226
+ // ---- Non-photoreal pass: cel rings + posterized caustics (whimsy). ----
227
+ // Toward the cel end the smooth refraction becomes hard concentric BANDS: the
228
+ // crest mask is thresholded into a flat ring, and the caustic web is posterized
229
+ // into chunky light cells. The phase quantization in waveField already steps
230
+ // the rings "on twos"; here we flatten their tone.
231
+ if (uStyle > 0.001) {
232
+ // Hard ring: a flat band where the crest is strong, with a brighter inner core.
233
+ float band = smoothstep(0.18, 0.30, crest);
234
+ float core = smoothstep(0.45, 0.60, crest);
235
+ vec3 celRing = clamp(ringCol * 1.3, 0.0, 1.2) * band
236
+ + clamp(uC0 * 1.6 + 0.1, 0.0, 1.3) * core;
237
+ // Posterize the caustic light into 2 chunky levels (Ben-Day-ish cells),
238
+ // and keep only the BRIGHT cells (drop the dim wash so the cel read stays
239
+ // clean white-on-dark rings instead of a muddy mid-tone field).
240
+ float caus = clamp(caustic * uCaustic, 0.0, 1.0);
241
+ float causQ = step(0.5, caus) * 0.6 + step(0.8, caus) * 0.4;
242
+ vec3 celCaustic = mix(uC2, vec3(1.0), 0.5) * causQ;
243
+ vec3 cel = (celRing + celCaustic) * gain;
244
+ col = mix(col, cel, uStyle);
245
+ }
246
+
247
+ // Ordered dither (~1/255) to kill banding the screen blend reveals; faded out
248
+ // toward the cel end where hard bands are intended.
249
+ col = ditherAdd(col, frag, uTimeS, 1.0 - uStyle);
250
+
251
+ fragColor = vec4(max(col, 0.0), 1.0);
252
+ }`;
253
+ //# sourceMappingURL=ripple-shader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ripple-shader.js","sourceRoot":"","sources":["../src/ripple-shader.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,OAAO,EACL,cAAc,EACd,WAAW,EACX,QAAQ,EACR,SAAS,EACT,gBAAgB,EAChB,iBAAiB,GAClB,MAAM,kBAAkB,CAAC;AAE1B;;;;GAIG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,CAAC;AAE3B,MAAM,CAAC,MAAM,iBAAiB,GAAG,UAAU,CAAC;;;;;EAK1C,CAAC;AAEH,MAAM,CAAC,MAAM,mBAAmB,GAAG,UAAU,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;oBAyB1B,SAAS;EAC3B,cAAc;EACd,SAAS;EACT,QAAQ;EACR,gBAAgB;EAChB,iBAAiB;EACjB,WAAW;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAsKX,CAAC"}