Drops of ink sink through dim water and unfurl into curling, dark edged tendrils at different depths. More tokens per second drop more ink and make every bloom unfurl faster.
Also known as: ink in water shader, ai thinking ink animation, ink drop bloom glsl.
A canvas and a module script, nothing to install. Set canvas.dataset.tps from your stream, or call
canvas.token() once per token.
<canvas id="shader-ink-bloom" data-tps="10" data-color="#3d7bff" style="display:block;width:100%;height:240px;border-radius:12px"></canvas>
<script type="module">
// Set canvas.dataset.tps from your stream, or call canvas.token() per token.
const canvas = document.getElementById("shader-ink-bloom");
const gl = canvas.getContext("webgl2", { alpha: false, antialias: false });
const fragment = `#version 300 es
precision highp float;
uniform vec3 iResolution;
uniform float iTime;
uniform float uTokens;
uniform float uTps;
uniform vec3 uColor;
out vec4 outColor;
// Gradient noise on a lattice that repeats every PER cells. Moving offsets are
// wrapped to whole periods, so the motion loops without a seam.
const float PER = 32.0;
// Octave turn and scale: an integer matrix keeps the lattice periodic.
const mat2 OCT = mat2(2.0, -1.0, 1.0, 2.0);
vec2 grad(vec2 i) {
i = mod(i, PER);
vec3 q = fract(vec3(i.xyx) * vec3(0.1031, 0.1030, 0.0973));
q += dot(q, q.yzx + 33.33);
return fract((q.xx + q.yz) * q.zy) * 2.0 - 1.0;
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float a = dot(grad(i), f);
float b = dot(grad(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));
float c = dot(grad(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0));
float d = dot(grad(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
// A phase that climbs with x but wraps to the noise period.
float wrapP(float x) { return mod(x, PER); }
// Rotate a colour around the grey axis: hue shifts that follow uColor.
vec3 hue(vec3 c, float a) {
const vec3 k = vec3(0.57735);
float ca = cos(a);
return c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca);
}
float hash12(vec2 p) {
vec3 q = fract(vec3(p.xyx) * 0.1031);
q += dot(q, q.yzx + 33.33);
return fract((q.x + q.y) * q.z);
}
// Soft shoulder tone map plus a one step dither against banding.
vec3 finish(vec3 c, vec2 fc) {
c = 1.0 - exp(-max(c, 0.0));
return c + (hash12(fc) - 0.5) / 255.0;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
float e = 1.0 - exp(-uTps / 80.0);
// six drop slots; each runs its own clock and a fresh drop is born every whole
// cycle, so a faster stream means more births and quicker unfurling. Clocks
// wrap by 512 whole cycles and drop seeds use the cycle mod 512: no seam.
float D = 0.0, F = 0.0;
for (int i = 0; i < 6; i++) {
float fi = float(i);
float ph = mod(uTokens * 0.0021 * (0.82 + 0.08 * fi), 512.0)
+ mod(iTime * (0.016 + 0.003 * fi), 512.0) + fi * 0.37;
float n = mod(floor(ph), 512.0);
float age = fract(ph);
vec2 sd = vec2(n, fi * 7.0);
float h1 = hash12(sd + 0.5), h2 = hash12(sd + 3.7), h3 = hash12(sd + 9.1);
// depth: far drops are smaller, paler and lower in contrast
float z = fract(h3 + fi * 0.618);
float sz = mix(1.0, 0.5, z);
float grow = pow(age, 0.55);
vec2 pos = vec2(-0.85 + 1.7 * (fi + 0.1 + 0.8 * h1) / 6.0,
0.3 - 0.22 * h2 - 0.45 * pow(age, 0.8) * sz);
float R = (0.03 + (0.3 + 0.08 * e) * grow) * sz;
vec2 lp = (uv - pos) / R;
vec2 off = vec2(h1, h2) * 13.0 + fi * 3.1;
// tendrils churn with the drop's age and with every token
float tw = age * 1.8 + wrapP(uTokens * 0.006 + fi * 5.0);
// the plume curls: a soft warp that keeps turning as the drop ages
vec2 w = vec2(noise(lp * 1.1 + off + vec2(0.0, tw)), noise(lp * 1.1 + off.yx + vec2(tw, 5.0)));
vec2 q = lp + w * (0.7 + 1.1 * grow);
// a rolling cap below with a wake trailing up behind it
float r = length((q - vec2(0.0, 0.35 * grow)) * vec2(1.0, 0.75));
float env = smoothstep(1.3, 0.25, r) * smoothstep(0.0, 0.1, age) * smoothstep(1.0, 0.62, age);
float n2 = noise(q * 2.4 + off + vec2(0.0, -tw));
float fil = exp(-abs(n2) / (0.035 + fwidth(n2)));
float n3 = noise(q * 4.8 + off.yx - vec2(tw, 0.0));
fil += 0.6 * exp(-abs(n3) / (0.04 + fwidth(n3))) * smoothstep(0.2, 0.9, grow);
float body = smoothstep(1.05, 0.15, r);
float amt = env * mix(1.0, 0.45, z) * (1.15 - 0.45 * age);
D += amt * (body * 0.6 + fil * 0.35);
F += amt * fil * mix(1.0, 0.3, z);
}
// deep water lit from above, falling off to the dark stage at the edges
float light = exp(-length((uv - vec2(0.0, 0.3)) * vec2(0.42, 0.85)) * 1.3);
vec3 water = mix(vec3(0.5, 0.58, 0.66), uColor, 0.15);
vec3 col = water * (0.025 + 0.3 * light);
// the dye catches the light: thin ink glows in the accent, dense ink deepens
float cover = 1.0 - exp(-D * 3.0);
vec3 dye = mix(mix(uColor, vec3(1.0), 0.12) * 1.5, uColor * 0.6, smoothstep(0.45, 1.0, cover));
dye *= (0.5 + 0.9 * light) * (0.85 + 0.4 * e);
col = mix(col, dye, cover * 0.92);
// the tendril edges stay dark: crisp, high contrast filaments
col = mix(col, uColor * 0.05, clamp(F * 0.85, 0.0, 0.88));
// a little extra saturation so the dye stays vivid through the tone map
col = max(mix(vec3(dot(col, vec3(0.3, 0.55, 0.15))), col, 1.35), 0.0);
col *= 1.0 - 0.35 * dot(uv * vec2(0.55, 1.0), uv * vec2(0.55, 1.0));
fragColor = vec4(finish(col, fragCoord), 1.0);
}
void main() {
mainImage(outColor, gl_FragCoord.xy);
outColor.a = 1.0;
}
`;
const vertex = `#version 300 es
void main() {
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
}
`;
const compile = (type, src) => {
const s = gl.createShader(type);
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s));
return s;
};
const program = gl.createProgram();
gl.attachShader(program, compile(gl.VERTEX_SHADER, vertex));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fragment));
gl.linkProgram(program);
gl.useProgram(program);
const u = (n) => gl.getUniformLocation(program, n);
const [res, time, tokens, rate, color] = ["iResolution", "iTime", "uTokens", "uTps", "uColor"].map(u);
const hex = parseInt((canvas.dataset.color || "#7c5cff").slice(1), 16);
gl.uniform3f(color, ((hex >> 16) & 255) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255);
// canvas.token(): count real tokens and derive the rate from the last second.
const stamps = [];
canvas.token = () => {
stamps.push(performance.now());
canvas.dataset.tps = "auto";
};
const still = matchMedia("(prefers-reduced-motion: reduce)").matches;
let count = 0, last = performance.now(), visible = true, frame = 0;
new IntersectionObserver(([e]) => {
visible = e.isIntersecting;
if (visible && !frame) frame = requestAnimationFrame(draw);
}).observe(canvas);
function draw(now) {
frame = 0;
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
while (stamps.length && now - stamps[0] > 1000) stamps.shift();
const wobble = 1 + 0.25 * Math.sin(now / 588) + 0.15 * Math.sin(now / 233 + 1) + 0.08 * Math.sin(now / 91 + 2);
const tps = canvas.dataset.tps === "auto" ? stamps.length : Number(canvas.dataset.tps) * wobble;
count += tps * dt;
const dpr = Math.min(devicePixelRatio || 1, 2);
const w = Math.round(canvas.clientWidth * dpr), h = Math.round(canvas.clientHeight * dpr);
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
gl.viewport(0, 0, w, h);
gl.uniform3f(res, w, h, 1);
gl.uniform1f(time, still ? 2 : now / 1000);
gl.uniform1f(tokens, still ? Number(canvas.dataset.tps) * 2 : count);
gl.uniform1f(rate, tps);
gl.drawArrays(gl.TRIANGLES, 0, 3);
if (visible && !still) frame = requestAnimationFrame(draw);
}
frame = requestAnimationFrame(draw);
</script>// Inputs the site passes in, as constants for Shadertoy.
// uTps: tokens per second. uTokens: tokens so far. uColor: the accent.
#define uTps 10.0
#define uTokens (iTime * uTps)
#define uColor vec3(0.239, 0.482, 1.000)
// Gradient noise on a lattice that repeats every PER cells. Moving offsets are
// wrapped to whole periods, so the motion loops without a seam.
const float PER = 32.0;
// Octave turn and scale: an integer matrix keeps the lattice periodic.
const mat2 OCT = mat2(2.0, -1.0, 1.0, 2.0);
vec2 grad(vec2 i) {
i = mod(i, PER);
vec3 q = fract(vec3(i.xyx) * vec3(0.1031, 0.1030, 0.0973));
q += dot(q, q.yzx + 33.33);
return fract((q.xx + q.yz) * q.zy) * 2.0 - 1.0;
}
float noise(vec2 p) {
vec2 i = floor(p), f = fract(p);
vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);
float a = dot(grad(i), f);
float b = dot(grad(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0));
float c = dot(grad(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0));
float d = dot(grad(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0));
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
// A phase that climbs with x but wraps to the noise period.
float wrapP(float x) { return mod(x, PER); }
// Rotate a colour around the grey axis: hue shifts that follow uColor.
vec3 hue(vec3 c, float a) {
const vec3 k = vec3(0.57735);
float ca = cos(a);
return c * ca + cross(k, c) * sin(a) + k * dot(k, c) * (1.0 - ca);
}
float hash12(vec2 p) {
vec3 q = fract(vec3(p.xyx) * 0.1031);
q += dot(q, q.yzx + 33.33);
return fract((q.x + q.y) * q.z);
}
// Soft shoulder tone map plus a one step dither against banding.
vec3 finish(vec3 c, vec2 fc) {
c = 1.0 - exp(-max(c, 0.0));
return c + (hash12(fc) - 0.5) / 255.0;
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = (fragCoord - 0.5 * iResolution.xy) / iResolution.y;
float e = 1.0 - exp(-uTps / 80.0);
// six drop slots; each runs its own clock and a fresh drop is born every whole
// cycle, so a faster stream means more births and quicker unfurling. Clocks
// wrap by 512 whole cycles and drop seeds use the cycle mod 512: no seam.
float D = 0.0, F = 0.0;
for (int i = 0; i < 6; i++) {
float fi = float(i);
float ph = mod(uTokens * 0.0021 * (0.82 + 0.08 * fi), 512.0)
+ mod(iTime * (0.016 + 0.003 * fi), 512.0) + fi * 0.37;
float n = mod(floor(ph), 512.0);
float age = fract(ph);
vec2 sd = vec2(n, fi * 7.0);
float h1 = hash12(sd + 0.5), h2 = hash12(sd + 3.7), h3 = hash12(sd + 9.1);
// depth: far drops are smaller, paler and lower in contrast
float z = fract(h3 + fi * 0.618);
float sz = mix(1.0, 0.5, z);
float grow = pow(age, 0.55);
vec2 pos = vec2(-0.85 + 1.7 * (fi + 0.1 + 0.8 * h1) / 6.0,
0.3 - 0.22 * h2 - 0.45 * pow(age, 0.8) * sz);
float R = (0.03 + (0.3 + 0.08 * e) * grow) * sz;
vec2 lp = (uv - pos) / R;
vec2 off = vec2(h1, h2) * 13.0 + fi * 3.1;
// tendrils churn with the drop's age and with every token
float tw = age * 1.8 + wrapP(uTokens * 0.006 + fi * 5.0);
// the plume curls: a soft warp that keeps turning as the drop ages
vec2 w = vec2(noise(lp * 1.1 + off + vec2(0.0, tw)), noise(lp * 1.1 + off.yx + vec2(tw, 5.0)));
vec2 q = lp + w * (0.7 + 1.1 * grow);
// a rolling cap below with a wake trailing up behind it
float r = length((q - vec2(0.0, 0.35 * grow)) * vec2(1.0, 0.75));
float env = smoothstep(1.3, 0.25, r) * smoothstep(0.0, 0.1, age) * smoothstep(1.0, 0.62, age);
float n2 = noise(q * 2.4 + off + vec2(0.0, -tw));
float fil = exp(-abs(n2) / (0.035 + fwidth(n2)));
float n3 = noise(q * 4.8 + off.yx - vec2(tw, 0.0));
fil += 0.6 * exp(-abs(n3) / (0.04 + fwidth(n3))) * smoothstep(0.2, 0.9, grow);
float body = smoothstep(1.05, 0.15, r);
float amt = env * mix(1.0, 0.45, z) * (1.15 - 0.45 * age);
D += amt * (body * 0.6 + fil * 0.35);
F += amt * fil * mix(1.0, 0.3, z);
}
// deep water lit from above, falling off to the dark stage at the edges
float light = exp(-length((uv - vec2(0.0, 0.3)) * vec2(0.42, 0.85)) * 1.3);
vec3 water = mix(vec3(0.5, 0.58, 0.66), uColor, 0.15);
vec3 col = water * (0.025 + 0.3 * light);
// the dye catches the light: thin ink glows in the accent, dense ink deepens
float cover = 1.0 - exp(-D * 3.0);
vec3 dye = mix(mix(uColor, vec3(1.0), 0.12) * 1.5, uColor * 0.6, smoothstep(0.45, 1.0, cover));
dye *= (0.5 + 0.9 * light) * (0.85 + 0.4 * e);
col = mix(col, dye, cover * 0.92);
// the tendril edges stay dark: crisp, high contrast filaments
col = mix(col, uColor * 0.05, clamp(F * 0.85, 0.0, 0.88));
// a little extra saturation so the dye stays vivid through the tone map
col = max(mix(vec3(dot(col, vec3(0.3, 0.55, 0.15))), col, 1.35), 0.0);
col *= 1.0 - 0.35 * dot(uv * vec2(0.55, 1.0), uv * vec2(0.55, 1.0));
fragColor = vec4(finish(col, fragCoord), 1.0);
}
uTps is the token rate and uTokens the running count. The standalone runner adds a natural wobble to a fixed rate, the way real streams speed up and slow down.data-color on the canvas, or change uColor in the Shadertoy version. Currently previewing #3d7bff.