Signature
An animated SVG signature effect that draws out text as if hand-written.
Preview supplied by Arlan Marat; live previews were recorded from Arlan's Vault when no published video file was available. Preview: platform recorded · rights cleared.
Blur glow is an animated canvas study published directly in the Vault gallery.
VSource LandVaultWhy it stands out
Blur glow is an animated canvas study published directly in the Vault gallery.
Build this: a crisp word sitting in a soft gradient-mapped GLOW on light paper — the 'blur glow' text look done live in WebGL (no assets). Rasterize the word to a white silhouette mask at a LOCKED cap-height (fit by height so every cycled word is the same size). Build the halo with a real MULTI-PASS GAUSSIAN BLOOM: blur + downsample the mask into ~4 progressively smaller framebuffers, each a separable H-then-V 9-tap Gaussian (small buffers = a huge smooth halo, no ring banding). Give the blur a VARIABLE FOCUS: keep the middle of the word sharp and blur harder toward both ends (a depth-of-field), driven by the word's measured x-extent. In the composite pass, sum the blur levels with falling weights into one grayscale glow field, apply a gamma so the coloured band hugs the letters, then GRADIENT-MAP its luminance (inverted: paper→light end, cores→dark end) through a 5-stop ramp. Draw the sharp word on top in the palette's INK colour (not plain black), letting the glow bleed into the letter edges, and blend dense monochrome film GRAIN over everything with a soft-light formula (like a 50% noise layer). Animate: the bloom BREATHES on a slow sine, the hue DRIFTS a touch, the cursor lights a local HOT-SPOT, and the word CROSS-DISSOLVES (eased) through a cycle, each settling into a different colour world. Ship several 5-stop palettes, each with its own ink + light paper colour (no dark mode). Framework-free WebGL1 + a rAF loop; DPR-capped at 1.5, pauses offscreen, one static frame under reduced motion.
The complete, self-contained implementation follows, one file per block. It is framework-agnostic core logic — wire it into your own component and mount it on an element.
### blur-glow/params.ts
```ts
export type Stop = { pos: number; color: [number, number, number] };
export interface Palette {
name: string;
stops: Stop[];
ink: [number, number, number];
paper: [number, number, number];
}
const rgb = (hex: string): [number, number, number] => [
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255,
];
export const PALETTES: Palette[] = [
{
name: "Ultraviolet",
stops: [
{ pos: 0.0, color: rgb("#0c0622") },
{ pos: 0.22, color: rgb("#5b1fd6") },
{ pos: 0.46, color: rgb("#ff3bd4") },
{ pos: 0.72, color: rgb("#ffcf4d") },
{ pos: 1.0, color: rgb("#faf7ff") },
],
ink: rgb("#2a0f66"),
paper: rgb("#faf7ff"),
},
{
name: "Molten",
stops: [
{ pos: 0.0, color: rgb("#28060a") },
{ pos: 0.22, color: rgb("#d21414") },
{ pos: 0.46, color: rgb("#ff6a1f") },
{ pos: 0.72, color: rgb("#ffcf52") },
{ pos: 1.0, color: rgb("#fff7ec") },
],
ink: rgb("#7a1410"),
paper: rgb("#fff7ec"),
},
{
name: "Bubblegum",
stops: [
{ pos: 0.0, color: rgb("#2a0718") },
{ pos: 0.22, color: rgb("#ff1e8e") },
{ pos: 0.46, color: rgb("#ff7ab0") },
{ pos: 0.72, color: rgb("#ffe0b0") },
{ pos: 1.0, color: rgb("#fff5fa") },
],
ink: rgb("#8a0e52"),
paper: rgb("#fff5fa"),
},
{
name: "Electric",
stops: [
{ pos: 0.0, color: rgb("#04102e") },
{ pos: 0.22, color: rgb("#1550ff") },
{ pos: 0.46, color: rgb("#25c8ff") },
{ pos: 0.72, color: rgb("#bff0ff") },
{ pos: 1.0, color: rgb("#f4faff") },
],
ink: rgb("#0a2b8c"),
paper: rgb("#f4faff"),
},
{
name: "Jade",
stops: [
{ pos: 0.0, color: rgb("#03170f") },
{ pos: 0.22, color: rgb("#0f7a4a") },
{ pos: 0.46, color: rgb("#1fd88a") },
{ pos: 0.72, color: rgb("#b8f5d8") },
{ pos: 1.0, color: rgb("#f3fbf6") },
],
ink: rgb("#0a3d28"),
paper: rgb("#f3fbf6"),
},
{
name: "Sunburst",
stops: [
{ pos: 0.0, color: rgb("#04161c") },
{ pos: 0.22, color: rgb("#0e7d86") },
{ pos: 0.46, color: rgb("#f2a20c") },
{ pos: 0.72, color: rgb("#ffe27a") },
{ pos: 1.0, color: rgb("#fefaf0") },
],
ink: rgb("#0a3a40"),
paper: rgb("#fefaf0"),
},
];
export function paletteUniforms(p: Palette): {
positions: number[];
colors: number[];
ink: number[];
paper: number[];
} {
const positions: number[] = [];
const colors: number[] = [];
for (let i = 0; i < 5; i++) {
const s = p.stops[Math.min(i, p.stops.length - 1)];
positions.push(s.pos);
colors.push(s.color[0], s.color[1], s.color[2]);
}
return { positions, colors, ink: p.ink, paper: p.paper };
}
export interface PaletteUniforms {
positions: number[];
colors: number[];
ink: number[];
paper: number[];
}
const LUMA: readonly [number, number, number] = [0.2126, 0.7152, 0.0722];
function satPreserveLerp(x: number[], y: number[], t: number): number[] {
const out = x.map((v, i) => v + (y[i] - v) * t);
const bell = Math.sin(Math.PI * t);
if (bell < 1e-4) return out;
const satOf = (c0: number, c1: number, c2: number) => {
const mx = Math.max(c0, c1, c2);
return mx === 0 ? 0 : (mx - Math.min(c0, c1, c2)) / mx;
};
for (let i = 0; i + 2 < out.length; i += 3) {
const r = out[i], g = out[i + 1], b = out[i + 2];
const target = Math.max(
satOf(x[i], x[i + 1], x[i + 2]),
satOf(y[i], y[i + 1], y[i + 2]),
);
const s = satOf(r, g, b);
if (s < 1e-4 || target < 1e-4) continue;
const k = (s + (target - s) * bell) / s;
const L = LUMA[0] * r + LUMA[1] * g + LUMA[2] * b;
out[i] = Math.min(1, Math.max(0, L + (r - L) * k));
out[i + 1] = Math.min(1, Math.max(0, L + (g - L) * k));
out[i + 2] = Math.min(1, Math.max(0, L + (b - L) * k));
}
return out;
}
export function lerpPaletteUniforms(a: PaletteUniforms, b: PaletteUniforms, t: number): PaletteUniforms {
const lerpArr = (x: number[], y: number[]) => x.map((v, i) => v + (y[i] - v) * t);
return {
positions: lerpArr(a.positions, b.positions),
colors: satPreserveLerp(a.colors, b.colors, t),
ink: satPreserveLerp(a.ink, b.ink, t),
paper: satPreserveLerp(a.paper, b.paper, t),
};
}
export const WORDS = ["hello", "pookie", "monkey"];
export const HOLD_SCALE = [1.45, 0.9, 1.0];
export const GRAIN = 0.4;
export const MORPH_LEAD = 0.78;
export const MORPH_LAG = 1.3;
export const CAST_DIR: readonly [number, number] = [0.38, 0.92];
export const CAST_STEP: readonly [number, number, number, number] = [0.0008, 0.0022, 0.0042, 0.0075];
export const LETTER_SPREAD = 0.45;
export const WARP_RADIUS = 0.2;
export const WARP_AMP = 0.013;
export const WARP_SWIRL = 0.6;
export const WARP_DRAG = 0.26;
export const WARP_STRETCH = 0.5;
export const BREATH = 0.22;
```
### blur-glow/text-mask.ts
```ts
export interface WordMask {
canvas: HTMLCanvasElement;
x0: number;
x1: number;
}
export function makeWordMask(
word: string,
w: number,
h: number,
fontFamily: string,
): WordMask {
const W = Math.max(1, Math.round(w));
const H = Math.max(1, Math.round(h));
const out = document.createElement("canvas");
out.width = W;
out.height = H;
const ctx = out.getContext("2d")!;
ctx.clearRect(0, 0, W, H);
const text = (word || "").trim();
if (!text) return { canvas: out, x0: 0.45, x1: 0.55 };
const cx = W / 2;
const cy = H * 0.52;
const maxW = W * 0.66;
const LOCK = H * 0.34;
let size = LOCK;
ctx.font = `600 ${size}px ${fontFamily}`;
let measured = ctx.measureText(text).width;
if (measured > maxW) {
size *= maxW / measured;
ctx.font = `600 ${size}px ${fontFamily}`;
measured = ctx.measureText(text).width;
}
ctx.textAlign = "left";
ctx.textBaseline = "middle";
const ls = size * 0.015;
const chars = [...text];
const widths = chars.map((c) => ctx.measureText(c).width);
const total = widths.reduce((a, b) => a + b, 0) + ls * (chars.length - 1);
let x = cx - total / 2;
const left = x;
const last = Math.max(1, chars.length - 1);
for (let i = 0; i < chars.length; i++) {
const idx = i / last;
ctx.fillStyle = `rgba(${Math.round(idx * 255)}, 0, 0, 1)`;
ctx.fillText(chars[i], x, cy);
x += widths[i] + ls;
}
return { canvas: out, x0: left / W, x1: (left + total) / W };
}
```
### blur-glow/shaders.ts
```ts
export const VERT = `
attribute vec2 aPos;
varying vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}`;
export const BLUR_FRAG = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
varying vec2 vUv;
uniform sampler2D uTex;
uniform vec2 uDir;
uniform float uMorph;
uniform sampler2D uTexB;
uniform float uUseMorph;
uniform float uPartOut;
uniform float uPartIn;
uniform float uLetterSpread;
uniform vec2 uFocus;
uniform float uFocusAmt;
float bhash(vec2 p){ p = fract(p*vec2(123.34,456.21)); p += dot(p, p+45.32); return fract(p.x*p.y); }
float bvnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
vec2 u = f*f*(3.0-2.0*f);
float a = bhash(i), b2 = bhash(i+vec2(1.0,0.0));
float c = bhash(i+vec2(0.0,1.0)), d = bhash(i+vec2(1.0,1.0));
return mix(mix(a,b2,u.x), mix(c,d,u.x), u.y);
}
float bfbm(vec2 p){
float v = 0.0, a = 0.5;
for (int i = 0; i < 4; i++){ v += a * bvnoise(p); p = p * 2.0 + 11.0; a *= 0.5; }
return v;
}
float bTransition(vec2 uv, float m){
vec2 q = vec2(bfbm(uv * 2.1 + 3.7), bfbm(uv * 2.1 - 1.3));
float n = bfbm(uv * 3.0 + q * 1.1);
float bias = (uv.x * 0.8 + uv.y * 0.2) * 0.30;
float field = clamp(n * 0.74 + bias, 0.0, 1.0);
const float BAND = 0.26;
float thr = mix(1.0 + BAND, -BAND, m);
return smoothstep(thr - BAND, thr + BAND, field);
}
float bLetterPhase(sampler2D tex, vec2 uv, float m, float spread){
float idx = texture2D(tex, uv).r;
return clamp((m * (1.0 + spread)) - idx * spread, 0.0, 1.0);
}
vec4 samp(vec2 uv){
if (uUseMorph > 0.5) {
vec4 aTex = texture2D(uTex, uv);
vec4 bTex = texture2D(uTexB, uv);
float outM = bTransition(uv, bLetterPhase(uTex, uv, uPartOut, uLetterSpread));
float inM = bTransition(uv, bLetterPhase(uTexB, uv, uPartIn, uLetterSpread));
return max(aTex * (1.0 - outM), bTex * inM);
}
return texture2D(uTex, uv);
}
void main(){
float mid = (uFocus.x + uFocus.y) * 0.5;
float halfW = max(0.001, (uFocus.y - uFocus.x) * 0.5);
float ends = clamp(abs(vUv.x - mid) / halfW, 0.0, 1.6);
float focus = 1.0 + uFocusAmt * ends * ends;
vec2 dir = uDir * focus;
float w0 = 0.2270270270;
float w1 = 0.1945945946;
float w2 = 0.1216216216;
float w3 = 0.0540540541;
float w4 = 0.0162162162;
vec4 c = samp(vUv) * w0;
c += samp(vUv + dir * 1.0) * w1;
c += samp(vUv - dir * 1.0) * w1;
c += samp(vUv + dir * 2.0) * w2;
c += samp(vUv - dir * 2.0) * w2;
c += samp(vUv + dir * 3.0) * w3;
c += samp(vUv - dir * 3.0) * w3;
c += samp(vUv + dir * 4.0) * w4;
c += samp(vUv - dir * 4.0) * w4;
gl_FragColor = c;
}`;
export const COMPOSITE_FRAG = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
varying vec2 vUv;
uniform sampler2D uMask;
uniform sampler2D uMaskB;
uniform sampler2D uL0;
uniform sampler2D uL1;
uniform sampler2D uL2;
uniform sampler2D uL3;
uniform vec2 uRes;
uniform float uMorph;
uniform float uPartOut;
uniform float uPartIn;
uniform float uLetterSpread;
uniform float uFront;
uniform vec2 uCursor;
uniform float uCursorOn;
uniform float uWarpRadius;
uniform float uWarpAmp;
uniform float uWarpSwirl;
uniform vec2 uWarpVel;
uniform float uWarpDrag;
uniform float uWarpStretch;
uniform float uPhase;
uniform float uBloom;
uniform vec2 uCast0;
uniform vec2 uCast1;
uniform vec2 uCast2;
uniform vec2 uCast3;
uniform float uPos[5];
uniform vec3 uCol[5];
uniform vec3 uInk;
uniform vec3 uPaper;
uniform float uGrain;
float hash(vec2 p){ p = fract(p*vec2(123.34,456.21)); p += dot(p, p+45.32); return fract(p.x*p.y); }
float vnoise(vec2 p){
vec2 i = floor(p), f = fract(p);
vec2 u = f*f*(3.0-2.0*f);
float a = hash(i), b2 = hash(i+vec2(1.0,0.0));
float c = hash(i+vec2(0.0,1.0)), d = hash(i+vec2(1.0,1.0));
return mix(mix(a,b2,u.x), mix(c,d,u.x), u.y);
}
float fbm(vec2 p){
float v = 0.0, a = 0.5;
for (int i = 0; i < 4; i++){ v += a * vnoise(p); p = p * 2.0 + 11.0; a *= 0.5; }
return v;
}
float transitionMask(vec2 uv, float m){
vec2 q = vec2(fbm(uv * 2.1 + 3.7), fbm(uv * 2.1 - 1.3));
float n = fbm(uv * 3.0 + q * 1.1);
float bias = (uv.x * 0.8 + uv.y * 0.2) * 0.30;
float field = clamp(n * 0.74 + bias, 0.0, 1.0);
const float BAND = 0.26;
float thr = mix(1.0 + BAND, -BAND, m);
return smoothstep(thr - BAND, thr + BAND, field);
}
float letterPhase(sampler2D tex, vec2 uv, float m, float spread){
float idx = texture2D(tex, uv).r;
float local = (m * (1.0 + spread)) - idx * spread;
return clamp(local, 0.0, 1.0);
}
float coverA(vec2 uv){ return texture2D(uMask, uv).a; }
float coverB(vec2 uv){ return texture2D(uMaskB, uv).a; }
float cover(vec2 uv){
float outM = transitionMask(uv, letterPhase(uMask, uv, uPartOut, uLetterSpread));
float inM = transitionMask(uv, letterPhase(uMaskB, uv, uPartIn, uLetterSpread));
return max(coverA(uv) * (1.0 - outM), coverB(uv) * inM);
}
vec3 gradientMap(float x){
vec3 c = uCol[0];
c = mix(c, uCol[1], smoothstep(uPos[0], uPos[1], x));
c = mix(c, uCol[2], smoothstep(uPos[1], uPos[2], x));
c = mix(c, uCol[3], smoothstep(uPos[2], uPos[3], x));
c = mix(c, uCol[4], smoothstep(uPos[3], uPos[4], x));
return c;
}
vec3 softLight(vec3 base, float g){
vec3 s = vec3(g);
vec3 lo = 2.0*base*s + base*base*(1.0 - 2.0*s);
vec3 hi = 2.0*base*(1.0 - s) + sqrt(base)*(2.0*s - 1.0);
return mix(lo, hi, step(0.5, s));
}
vec2 warpUV(vec2 uv){
if (uCursorOn < 0.001) return uv;
float asp = uRes.x / max(1.0, uRes.y);
vec2 d = (uv - uCursor) * vec2(asp, 1.0);
float r = length(d);
float R = uWarpRadius;
if (r > R || r < 1e-5) return uv;
float f = 1.0 - r / R;
f = f * f * (3.0 - 2.0 * f);
vec2 dir = d / r;
vec2 swirl = vec2(-dir.y, dir.x);
vec2 push = mix(dir, swirl, uWarpSwirl);
push += uWarpVel * uWarpDrag;
float speed = length(uWarpVel);
if (speed > 0.02) {
vec2 vdir = uWarpVel / speed;
float along = dot(d / r, vdir);
float stretch = 1.0 + uWarpStretch * clamp(speed, 0.0, 1.0) * (along * along - 0.35);
f *= clamp(stretch, 0.0, 2.2);
}
float ripple = 1.0 + 0.16 * sin(r / R * 3.1416 - uPhase * 5.0);
float lead = 0.5 + 0.5 * dot(normalize(d + 1e-6), normalize(uWarpVel + 1e-6));
float visc = mix(1.15, 0.85, lead);
return uv + push * (f * ripple * visc * uWarpAmp * uCursorOn) / vec2(asp, 1.0);
}
void main(){
vec2 uv = warpUV(vUv);
float cd = distance(vUv * uRes, uCursor * uRes);
float hot = uCursorOn * smoothstep(min(uRes.x, uRes.y) * uWarpRadius * 2.4, 0.0, cd);
float b = 1.0 + (uBloom - 1.0) * 1.25 + 0.14 * hot;
float glow =
1.00 * cover(uv)
+ 0.92 * texture2D(uL0, uv + uCast0).a
+ 0.78 * texture2D(uL1, vUv + uCast1).a * b
+ 0.58 * texture2D(uL2, vUv + uCast2).a * b
+ 0.40 * texture2D(uL3, vUv + uCast3).a * b;
glow = clamp(glow / 2.45, 0.0, 1.0);
float field = pow(glow, 0.6);
field = clamp(field + hot * 0.16 * field, 0.0, 1.0);
if (uFront > 0.001) {
vec2 q = vec2(fbm(uv * 2.1 + 3.7), fbm(uv * 2.1 - 1.3));
float n = fbm(uv * 3.0 + q * 1.1);
float fieldN = clamp(n * 0.74 + (uv.x * 0.8 + uv.y * 0.2) * 0.30, 0.0, 1.0);
float thrIn = mix(1.26, -0.26, uPartIn);
float edge = 1.0 - smoothstep(0.0, 0.30, abs(fieldN - thrIn));
field = clamp(field + edge * uFront * 0.30 * smoothstep(0.02, 0.45, glow), 0.0, 1.0);
}
float drift = 0.022 * sin(uPhase + uv.x * 3.0 + uv.y * 2.0);
float t = clamp(1.0 - field + drift - hot * 0.10, 0.0, 1.0);
vec3 col = gradientMap(t);
vec3 bounced = mix(uPaper, uCol[2], texture2D(uL3, vUv + uCast3).a * 0.16);
col = mix(bounced, col, smoothstep(0.0, 0.06, field));
float softCov = clamp(texture2D(uL0, uv).a * 1.05 + cover(uv) * 0.25, 0.0, 1.0);
float interior = smoothstep(0.18, 0.62, softCov);
vec3 bodyCol = mix(uInk, uCol[1], 0.5);
bodyCol = mix(bodyCol, uCol[2], (1.0 - interior) * 0.5);
float solid = smoothstep(0.35, 0.95, cover(uv));
bodyCol = mix(mix(bodyCol, uCol[2], 0.30), mix(bodyCol, uInk, 0.22), solid);
col = mix(col, bodyCol, interior * 0.9);
float g = hash(gl_FragCoord.xy);
vec3 grained = softLight(col, g);
col = mix(col, grained, uGrain * (0.5 + 0.7 * field));
gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}`;
```
### blur-glow/engine.ts
```ts
import { VERT, BLUR_FRAG, COMPOSITE_FRAG } from "./shaders";
import { makeWordMask } from "./text-mask";
import {
PALETTES,
WORDS,
GRAIN,
MORPH_LEAD,
MORPH_LAG,
CAST_DIR,
CAST_STEP,
HOLD_SCALE,
LETTER_SPREAD,
BREATH,
WARP_RADIUS,
WARP_AMP,
WARP_SWIRL,
WARP_DRAG,
WARP_STRETCH,
paletteUniforms,
lerpPaletteUniforms,
type PaletteUniforms,
} from "./params";
const FONT = "var(--font-pangram)";
const FOCUS_AMT = 1.5;
const MORPH_SEC = 0.8;
const HOLD_MS = 700;
const LEVELS = [
{ scale: 0.5, radius: 2 },
{ scale: 0.25, radius: 3 },
{ scale: 0.125, radius: 3 },
{ scale: 0.0625, radius: 3 },
];
type Target = { fb: WebGLFramebuffer; tex: WebGLTexture; w: number; h: number };
export class BlurGlow {
private host: HTMLElement;
private canvas: HTMLCanvasElement;
private gl: WebGLRenderingContext | null = null;
private blurProg: WebGLProgram | null = null;
private compProg: WebGLProgram | null = null;
private quad: WebGLBuffer | null = null;
private maskA: WebGLTexture | null = null;
private maskB: WebGLTexture | null = null;
private focusA: [number, number] = [0.35, 0.65];
private focusB: [number, number] = [0.35, 0.65];
private levels: { out: Target; tmp: Target }[] = [];
private blurU: Record<string, WebGLUniformLocation | null> = {};
private compU: Record<string, WebGLUniformLocation | null> = {};
private fontFamily = "sans-serif";
private raf = 0;
private running = false;
private destroyed = false;
private start0 = 0;
private lastT = 0;
private revealed = false;
private wordIdx = 0;
private paletteIdx = 0;
private morph = 0;
private morphEased = 0;
private morphGlow = 0;
private morphBody = 0;
private morphing = false;
private holdUntil = 0;
private paletteFrom: PaletteUniforms = paletteUniforms(PALETTES[0]);
private paletteTo: PaletteUniforms = paletteUniforms(PALETTES[0]);
private curX = 0.5;
private curY = 0.5;
private tgtX = 0.5;
private tgtY = 0.5;
private curOn = 0;
private tgtOn = 0;
private velX = 0;
private velY = 0;
private prevX = 0.5;
private prevY = 0.5;
constructor(host: HTMLElement) {
this.host = host;
this.canvas = document.createElement("canvas");
Object.assign(this.canvas.style, {
display: "block",
width: "100%",
height: "100%",
opacity: "0",
transition: "opacity 0.4s ease",
});
host.appendChild(this.canvas);
const gl = this.canvas.getContext("webgl", {
alpha: false,
antialias: false,
premultipliedAlpha: false,
powerPreference: "low-power",
}) as WebGLRenderingContext | null;
if (!gl) return;
this.gl = gl;
this.buildPrograms();
if (!this.blurProg || !this.compProg) return;
this.resolveFont();
this.resize();
host.addEventListener("pointermove", this.onMove);
host.addEventListener("pointerleave", this.onLeave);
}
private compile(type: number, src: string): WebGLShader | null {
const gl = this.gl!;
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
console.warn("[blur-glow] shader:", gl.getShaderInfoLog(s));
return null;
}
return s;
}
private link(vs: string, fs: string): WebGLProgram | null {
const gl = this.gl!;
const v = this.compile(gl.VERTEX_SHADER, vs);
const f = this.compile(gl.FRAGMENT_SHADER, fs);
if (!v || !f) return null;
const p = gl.createProgram()!;
gl.attachShader(p, v);
gl.attachShader(p, f);
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) {
console.warn("[blur-glow] link:", gl.getProgramInfoLog(p));
return null;
}
return p;
}
private buildPrograms() {
const gl = this.gl!;
this.quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
this.blurProg = this.link(VERT, BLUR_FRAG);
this.compProg = this.link(VERT, COMPOSITE_FRAG);
if (!this.blurProg || !this.compProg) return;
const BU = (n: string) => gl.getUniformLocation(this.blurProg!, n);
this.blurU = {
uTex: BU("uTex"),
uTexB: BU("uTexB"),
uDir: BU("uDir"),
uMorph: BU("uMorph"),
uUseMorph: BU("uUseMorph"),
uFocus: BU("uFocus"),
uFocusAmt: BU("uFocusAmt"),
uPartOut: BU("uPartOut"),
uPartIn: BU("uPartIn"),
uLetterSpread: BU("uLetterSpread"),
};
const CU = (n: string) => gl.getUniformLocation(this.compProg!, n);
this.compU = {
uMask: CU("uMask"),
uMaskB: CU("uMaskB"),
uL0: CU("uL0"),
uL1: CU("uL1"),
uL2: CU("uL2"),
uL3: CU("uL3"),
uRes: CU("uRes"),
uMorph: CU("uMorph"),
uCursor: CU("uCursor"),
uCursorOn: CU("uCursorOn"),
uWarpRadius: CU("uWarpRadius"),
uWarpAmp: CU("uWarpAmp"),
uWarpSwirl: CU("uWarpSwirl"),
uWarpVel: CU("uWarpVel"),
uWarpDrag: CU("uWarpDrag"),
uWarpStretch: CU("uWarpStretch"),
uPhase: CU("uPhase"),
uBloom: CU("uBloom"),
uCast0: CU("uCast0"),
uCast1: CU("uCast1"),
uCast2: CU("uCast2"),
uCast3: CU("uCast3"),
uPartOut: CU("uPartOut"),
uPartIn: CU("uPartIn"),
uLetterSpread: CU("uLetterSpread"),
uFront: CU("uFront"),
uPos: CU("uPos[0]"),
uCol: CU("uCol[0]"),
uInk: CU("uInk"),
uPaper: CU("uPaper"),
uGrain: CU("uGrain"),
};
}
private bindQuad(prog: WebGLProgram) {
const gl = this.gl!;
gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
const loc = gl.getAttribLocation(prog, "aPos");
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
}
private uploadPalette(u: { positions: number[]; colors: number[]; ink: number[]; paper: number[] }) {
const gl = this.gl;
if (!gl || !this.compProg) return;
gl.useProgram(this.compProg);
gl.uniform1fv(this.compU.uPos!, new Float32Array(u.positions));
gl.uniform3fv(this.compU.uCol!, new Float32Array(u.colors));
gl.uniform3fv(this.compU.uInk!, new Float32Array(u.ink));
gl.uniform3fv(this.compU.uPaper!, new Float32Array(u.paper));
this.canvas.style.background = `rgb(${u.paper.map((c) => Math.round(c * 255)).join(",")})`;
}
private applyPalette(i: number) {
const idx = i % PALETTES.length;
this.paletteFrom = paletteUniforms(PALETTES[idx]);
this.paletteTo = this.paletteFrom;
this.uploadPalette(this.paletteFrom);
}
private resolveFont() {
if (typeof document === "undefined") return;
try {
const probe = document.createElement("span");
probe.style.cssText = "position:absolute;visibility:hidden;font-family:" + FONT;
probe.textContent = "Ag";
document.body.appendChild(probe);
this.fontFamily =
getComputedStyle(probe).fontFamily.split(",")[0].replace(/["']/g, "").trim() || "sans-serif";
document.body.removeChild(probe);
} catch {}
}
private makeTarget(w: number, h: number): Target {
const gl = this.gl!;
const tex = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const fb = gl.createFramebuffer()!;
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, tex, 0);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
return { fb, tex, w, h };
}
private freeLevels() {
const gl = this.gl;
if (!gl) return;
for (const lv of this.levels) {
gl.deleteFramebuffer(lv.out.fb);
gl.deleteTexture(lv.out.tex);
gl.deleteFramebuffer(lv.tmp.fb);
gl.deleteTexture(lv.tmp.tex);
}
this.levels = [];
}
private allocLevels() {
this.freeLevels();
const W = this.canvas.width;
const H = this.canvas.height;
this.levels = LEVELS.map((l) => {
const w = Math.max(2, Math.round(W * l.scale));
const h = Math.max(2, Math.round(H * l.scale));
return { out: this.makeTarget(w, h), tmp: this.makeTarget(w, h) };
});
}
private uploadMask(src: HTMLCanvasElement, existing: WebGLTexture | null): WebGLTexture {
const gl = this.gl!;
const tex = existing ?? gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return tex;
}
private buildMask() {
const gl = this.gl;
if (!gl) return;
const m = makeWordMask(WORDS[this.wordIdx % WORDS.length], this.canvas.width, this.canvas.height, this.fontFamily);
this.maskA = this.uploadMask(m.canvas, this.maskA);
this.focusA = [m.x0, m.x1];
}
private buildMaskB() {
const gl = this.gl;
if (!gl) return;
const next = (this.wordIdx + 1) % WORDS.length;
const m = makeWordMask(WORDS[next], this.canvas.width, this.canvas.height, this.fontFamily);
this.maskB = this.uploadMask(m.canvas, this.maskB);
this.focusB = [m.x0, m.x1];
}
private resize() {
const gl = this.gl;
if (!gl) return;
const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
const r = this.host.getBoundingClientRect();
const w = Math.max(1, Math.round(r.width * dpr));
const h = Math.max(1, Math.round(r.height * dpr));
if (this.canvas.width === w && this.canvas.height === h && this.levels.length) return;
this.canvas.width = w;
this.canvas.height = h;
this.allocLevels();
this.buildMask();
this.buildMaskB();
if (!this.morphing) this.applyPalette(this.paletteIdx);
}
private beginMorph() {
this.morph = 0;
this.morphing = true;
this.paletteFrom = paletteUniforms(PALETTES[this.paletteIdx % PALETTES.length]);
this.paletteTo = paletteUniforms(PALETTES[(this.paletteIdx + 1) % PALETTES.length]);
}
private finishMorph() {
this.wordIdx = (this.wordIdx + 1) % WORDS.length;
const t = this.maskA;
this.maskA = this.maskB;
this.maskB = t;
this.focusA = this.focusB;
this.morph = 0;
this.morphing = false;
this.buildMaskB();
this.paletteIdx = (this.paletteIdx + 1) % PALETTES.length;
this.paletteFrom = this.paletteTo;
this.uploadPalette(this.paletteTo);
}
private render(bloom: number, phase: number) {
const gl = this.gl;
if (!gl || !this.blurProg || !this.compProg || !this.levels.length) return;
gl.useProgram(this.blurProg);
this.bindQuad(this.blurProg);
gl.disable(gl.BLEND);
const m = this.morph;
const em = m * m * m * (m * (m * 6 - 15) + 10);
this.morphGlow = Math.pow(em, MORPH_LEAD);
this.morphBody = Math.pow(em, MORPH_LAG);
this.morphEased = em;
const clocks = (base: number): [number, number] => [
Math.min(1, base * (1 + BREATH)),
Math.max(0, Math.min(1, base * (1 + BREATH) - BREATH)),
];
const [glowOut, glowIn] = clocks(this.morphGlow);
const [bodyOut, bodyIn] = clocks(this.morphBody);
const front = this.morphing ? Math.sin(Math.PI * em) : 0;
const fx0 = this.focusA[0] + (this.focusB[0] - this.focusA[0]) * em;
const fx1 = this.focusA[1] + (this.focusB[1] - this.focusA[1]) * em;
gl.uniform2f(this.blurU.uFocus!, fx0, fx1);
gl.uniform1f(this.blurU.uFocusAmt!, FOCUS_AMT);
gl.uniform1f(this.blurU.uMorph!, this.morphGlow);
gl.uniform1f(this.blurU.uPartOut!, glowOut);
gl.uniform1f(this.blurU.uPartIn!, glowIn);
gl.uniform1f(this.blurU.uLetterSpread!, LETTER_SPREAD);
for (let i = 0; i < this.levels.length; i++) {
const lv = this.levels[i];
const r = LEVELS[i].radius;
const srcTex = i === 0 ? this.maskA! : this.levels[i - 1].out.tex;
const useMorph = i === 0 && this.morphing ? 1 : 0;
gl.bindFramebuffer(gl.FRAMEBUFFER, lv.tmp.fb);
gl.viewport(0, 0, lv.tmp.w, lv.tmp.h);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, srcTex);
gl.uniform1i(this.blurU.uTex!, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.maskB ?? this.maskA!);
gl.uniform1i(this.blurU.uTexB!, 1);
gl.uniform1f(this.blurU.uUseMorph!, useMorph);
gl.uniform2f(this.blurU.uDir!, r / lv.tmp.w, 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
gl.bindFramebuffer(gl.FRAMEBUFFER, lv.out.fb);
gl.viewport(0, 0, lv.out.w, lv.out.h);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, lv.tmp.tex);
gl.uniform1i(this.blurU.uTex!, 0);
gl.uniform1f(this.blurU.uUseMorph!, 0);
gl.uniform2f(this.blurU.uDir!, 0, r / lv.out.h);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, this.canvas.width, this.canvas.height);
gl.useProgram(this.compProg);
this.bindQuad(this.compProg);
if (this.morphing) {
this.uploadPalette(lerpPaletteUniforms(this.paletteFrom, this.paletteTo, this.morphEased));
}
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, this.maskA!);
gl.uniform1i(this.compU.uMask!, 0);
gl.activeTexture(gl.TEXTURE1);
gl.bindTexture(gl.TEXTURE_2D, this.maskB ?? this.maskA!);
gl.uniform1i(this.compU.uMaskB!, 1);
const levelUnits = ["uL0", "uL1", "uL2", "uL3"];
for (let i = 0; i < this.levels.length; i++) {
gl.activeTexture(gl.TEXTURE2 + i);
gl.bindTexture(gl.TEXTURE_2D, this.levels[i].out.tex);
gl.uniform1i(this.compU[levelUnits[i]]!, 2 + i);
}
gl.uniform2f(this.compU.uRes!, this.canvas.width, this.canvas.height);
gl.uniform1f(this.compU.uMorph!, this.morphBody);
gl.uniform1f(this.compU.uPartOut!, bodyOut);
gl.uniform1f(this.compU.uPartIn!, bodyIn);
gl.uniform1f(this.compU.uLetterSpread!, LETTER_SPREAD);
gl.uniform1f(this.compU.uFront!, front);
gl.uniform2f(this.compU.uCursor!, this.curX, this.curY);
gl.uniform1f(this.compU.uCursorOn!, this.curOn);
gl.uniform1f(this.compU.uWarpRadius!, WARP_RADIUS);
gl.uniform1f(this.compU.uWarpAmp!, WARP_AMP);
gl.uniform1f(this.compU.uWarpSwirl!, WARP_SWIRL);
gl.uniform2f(this.compU.uWarpVel!, this.velX, this.velY);
gl.uniform1f(this.compU.uWarpDrag!, WARP_DRAG);
gl.uniform1f(this.compU.uWarpStretch!, WARP_STRETCH);
gl.uniform1f(this.compU.uPhase!, phase);
gl.uniform1f(this.compU.uBloom!, bloom);
const aspect = this.canvas.width / Math.max(1, this.canvas.height);
const casts = [this.compU.uCast0, this.compU.uCast1, this.compU.uCast2, this.compU.uCast3];
for (let i = 0; i < casts.length; i++) {
const s = CAST_STEP[i];
gl.uniform2f(casts[i]!, (CAST_DIR[0] * s) / aspect, CAST_DIR[1] * s);
}
gl.uniform1f(this.compU.uGrain!, GRAIN);
gl.drawArrays(gl.TRIANGLES, 0, 3);
this.reveal();
}
private holdFor(i: number) {
return HOLD_MS * (HOLD_SCALE[i % WORDS.length] ?? 1);
}
private frame = (now: number) => {
if (!this.running || this.destroyed) return;
if (!this.start0) {
this.start0 = now;
this.lastT = now;
this.holdUntil = now + this.holdFor(this.wordIdx);
}
const t = (now - this.start0) / 1000;
const dt = Math.min(0.05, Math.max(0.001, (now - this.lastT) / 1000));
this.lastT = now;
this.curX = this.tgtX;
this.curY = this.tgtY;
this.curOn += (this.tgtOn - this.curOn) * (1 - Math.pow(1 - 0.42, dt * 60));
const vx = (this.curX - this.prevX) / dt;
const vy = (this.curY - this.prevY) / dt;
this.prevX = this.curX;
this.prevY = this.curY;
const vk = 1 - Math.pow(1 - 0.5, dt * 60);
this.velX += (vx - this.velX) * vk;
this.velY += (vy - this.velY) * vk;
const vmag = Math.hypot(this.velX, this.velY);
const VMAX = 2.2;
if (vmag > VMAX) {
this.velX = (this.velX / vmag) * VMAX;
this.velY = (this.velY / vmag) * VMAX;
}
if (!this.morphing && now >= this.holdUntil) this.beginMorph();
if (this.morphing) {
this.morph = Math.min(1, this.morph + dt / MORPH_SEC);
if (this.morph >= 1) {
this.finishMorph();
this.holdUntil = now + this.holdFor(this.wordIdx);
}
}
const bloom = 1.0 + 0.16 * Math.sin(t * 0.6);
const phase = t * 0.5;
this.render(bloom, phase);
this.raf = requestAnimationFrame(this.frame);
};
private reveal() {
if (this.revealed) return;
this.revealed = true;
this.canvas.style.opacity = "1";
}
private onMove = (e: PointerEvent) => {
const r = this.host.getBoundingClientRect();
this.tgtX = (e.clientX - r.left) / r.width;
this.tgtY = 1 - (e.clientY - r.top) / r.height;
this.tgtOn = 1;
};
private onLeave = () => {
this.tgtOn = 0;
};
start() {
if (this.running || !this.gl || !this.compProg) return;
this.running = true;
this.start0 = 0;
this.raf = requestAnimationFrame(this.frame);
}
stop() {
this.running = false;
if (this.raf) cancelAnimationFrame(this.raf);
this.raf = 0;
}
renderStill(instant = false) {
if (!this.gl || !this.compProg) return;
if (instant && !this.revealed) {
this.canvas.style.transition = "none";
this.revealed = true;
this.canvas.style.opacity = "1";
}
this.render(1.0, 0);
}
refreshFont() {
this.resolveFont();
this.buildMask();
this.buildMaskB();
if (!this.running) this.renderStill();
}
onResize() {
this.resize();
if (!this.running) this.renderStill();
}
destroy() {
this.destroyed = true;
this.stop();
this.host.removeEventListener("pointermove", this.onMove);
this.host.removeEventListener("pointerleave", this.onLeave);
const gl = this.gl;
if (gl) {
this.freeLevels();
if (this.maskA) gl.deleteTexture(this.maskA);
if (this.maskB) gl.deleteTexture(this.maskB);
if (this.quad) gl.deleteBuffer(this.quad);
if (this.blurProg) gl.deleteProgram(this.blurProg);
if (this.compProg) gl.deleteProgram(this.compProg);
gl.getExtension("WEBGL_lose_context")?.loseContext();
}
this.canvas.remove();
}
}
```Discovery vocabulary
Related by governed terms
An animated SVG signature effect that draws out text as if hand-written.
More from Vault