Thin wisps of smoke rise and curl from three quiet sources into the dark. Faster token output lifts the smoke quicker, twists it into tighter curls and thickens the plume.
Also known as: smoke shader animation, ai thinking smoke, rising smoke 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-smoke-wisps" data-tps="6" data-color="#9fb3d9" 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-smoke-wisps");
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 / 50.0);
float y01 = uv.y + 0.5;
// the rise: one wrapped offset per speed, so each layer climbs on its own clock
float rise1 = wrapP(uTokens * 0.006) + wrapP(iTime * 0.07);
float rise2 = wrapP(uTokens * 0.009) + wrapP(iTime * 0.1);
float rise3 = wrapP(uTokens * 0.004) + wrapP(iTime * 0.05);
vec2 p = vec2(uv.x * 1.7, uv.y * 1.1);
// a slow curl that bends the filaments, stronger higher up and when busy
vec2 c = vec2(noise(p * 1.0 + vec2(1.3, -rise3)), noise(p * 1.0 + vec2(7.9, -rise3)));
float curl = (0.35 + 0.55 * e) * (0.3 + y01);
vec2 s = p + c * curl;
float f1 = noise(vec2(s.x * 3.6, s.y * 1.3) + vec2(0.0, -rise1));
float f2 = noise(vec2(s.x * 6.0, s.y * 2.1) + vec2(4.0, -rise2) + c * 0.6);
float w1 = fwidth(f1) + 0.004;
float w2 = fwidth(f2) + 0.004;
float th = 0.035 + 0.03 * y01;
float l1 = exp(-abs(f1) / (th + w1));
float l2 = exp(-abs(f2) / (th * 0.7 + w2)) * (0.35 + 0.55 * e);
float haze = smoothstep(-0.1, 0.45, noise(s * 2.0 + vec2(2.0, -rise3))) * 0.25;
// three plumes, each widening as it rises and swaying a little
float mask = 0.0;
for (int i = 0; i < 3; i++) {
float fi = float(i);
float x0 = (fi - 1.0) * 0.62 + 0.12 * c.x * y01;
float wdt = 0.035 + 0.22 * y01 + 0.06 * e;
float amp = i == 1 ? 1.0 : 0.7;
mask += amp * exp(-pow((uv.x - x0) / wdt, 2.0));
}
mask *= smoothstep(-0.02, 0.18, y01) * smoothstep(1.15, 0.35, y01);
float smoke = (l1 + l2 + haze) * mask;
vec3 tint = mix(uColor, vec3(1.0), 0.35);
tint = mix(tint, hue(uColor, 0.5), smoothstep(0.2, 0.9, y01) * 0.4);
vec3 col = vec3(0.006, 0.007, 0.01) + uColor * 0.05 * exp(-y01 * 3.0);
col += tint * smoke * (0.35 + 0.45 * e);
// glowing tips where the smoke leaves its source
for (int i = 0; i < 3; i++) {
vec2 d = uv - vec2((float(i) - 1.0) * 0.62, -0.5);
col += mix(uColor, vec3(1.0), 0.5) * exp(-dot(d, d) * 260.0) * (0.25 + 0.3 * e);
}
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 6.0
#define uTokens (iTime * uTps)
#define uColor vec3(0.624, 0.702, 0.851)
// 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 / 50.0);
float y01 = uv.y + 0.5;
// the rise: one wrapped offset per speed, so each layer climbs on its own clock
float rise1 = wrapP(uTokens * 0.006) + wrapP(iTime * 0.07);
float rise2 = wrapP(uTokens * 0.009) + wrapP(iTime * 0.1);
float rise3 = wrapP(uTokens * 0.004) + wrapP(iTime * 0.05);
vec2 p = vec2(uv.x * 1.7, uv.y * 1.1);
// a slow curl that bends the filaments, stronger higher up and when busy
vec2 c = vec2(noise(p * 1.0 + vec2(1.3, -rise3)), noise(p * 1.0 + vec2(7.9, -rise3)));
float curl = (0.35 + 0.55 * e) * (0.3 + y01);
vec2 s = p + c * curl;
float f1 = noise(vec2(s.x * 3.6, s.y * 1.3) + vec2(0.0, -rise1));
float f2 = noise(vec2(s.x * 6.0, s.y * 2.1) + vec2(4.0, -rise2) + c * 0.6);
float w1 = fwidth(f1) + 0.004;
float w2 = fwidth(f2) + 0.004;
float th = 0.035 + 0.03 * y01;
float l1 = exp(-abs(f1) / (th + w1));
float l2 = exp(-abs(f2) / (th * 0.7 + w2)) * (0.35 + 0.55 * e);
float haze = smoothstep(-0.1, 0.45, noise(s * 2.0 + vec2(2.0, -rise3))) * 0.25;
// three plumes, each widening as it rises and swaying a little
float mask = 0.0;
for (int i = 0; i < 3; i++) {
float fi = float(i);
float x0 = (fi - 1.0) * 0.62 + 0.12 * c.x * y01;
float wdt = 0.035 + 0.22 * y01 + 0.06 * e;
float amp = i == 1 ? 1.0 : 0.7;
mask += amp * exp(-pow((uv.x - x0) / wdt, 2.0));
}
mask *= smoothstep(-0.02, 0.18, y01) * smoothstep(1.15, 0.35, y01);
float smoke = (l1 + l2 + haze) * mask;
vec3 tint = mix(uColor, vec3(1.0), 0.35);
tint = mix(tint, hue(uColor, 0.5), smoothstep(0.2, 0.9, y01) * 0.4);
vec3 col = vec3(0.006, 0.007, 0.01) + uColor * 0.05 * exp(-y01 * 3.0);
col += tint * smoke * (0.35 + 0.45 * e);
// glowing tips where the smoke leaves its source
for (int i = 0; i < 3; i++) {
vec2 d = uv - vec2((float(i) - 1.0) * 0.62, -0.5);
col += mix(uColor, vec3(1.0), 0.5) * exp(-dot(d, d) * 260.0) * (0.25 + 0.3 * e);
}
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 #9fb3d9.