"use client"; import { useEffect, useRef } from "react"; import { useReducedMotion } from "motion/react"; type Typography3DProps = { /** The word on the wall. One line. */ text?: string; /** Six-digit hex colour of the enamel letter faces. */ color?: string; /** Six-digit hex colour of the metal edges. */ metal?: string; /** Six-digit hex colour of the wall. */ wall?: string; /** CSS font family. A heavy font works best. */ font?: string; /** 1 is the default pace; 2 is twice as fast. */ speed?: number; /** Set the size here. */ className?: string; }; const MAX_PIXELS = 1200000; const REACH = 28; const PAD = 34; const VERTEX = `attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}`; /* The word is baked to a distance map, then cut as real solid letters: flat face, one chamfer, straight sides, mounted on a wall. */ const FRAGMENT = `precision highp float; uniform sampler2D map2; uniform vec2 res,span,eye; uniform vec3 lamp,face,edge,back; uniform float reach,tall,bevel,step2; float flat2(vec2 p){return (texture2D(map2,p/span+.5).r-.5)*reach;} float solid(vec3 p,float d){return max(max(d,p.z-tall),(d+p.z-tall+bevel)*.7071);} float scene(vec3 p){return min(solid(p,flat2(p.xy)),p.z);} float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);} float noise(vec2 p){ vec2 i=floor(p),f=fract(p); f=f*f*(3.-2.*f); return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y); } float plaster(vec2 p){return noise(p*7.)*.5+noise(p*19.)*.3+noise(p*53.)*.2;} void main(){ vec2 uv=(gl_FragCoord.xy-.5*res)/(.5*res.y); vec3 o=vec3(eye,3.4),d=normalize(vec3(uv-eye,-3.4)); float t=(tall+.002-o.z)/d.z; vec3 p=o+d*t; for(int i=0;i<40;i++){ float h=scene(p); if(h<.0012)break; t+=h*.9; p=o+d*t; } float dist=flat2(p.xy); vec3 n=vec3(0.,0.,1.); float kind=0.; if(p.z>.004){ vec2 g=vec2(flat2(p.xy+vec2(step2,0.))-flat2(p.xy-vec2(step2,0.)),flat2(p.xy+vec2(0.,step2))-flat2(p.xy-vec2(0.,step2))); g/=max(length(g),1e-4); float cham=(dist+p.z-tall+bevel)*.7071; if(dist>=p.z-tall&&dist>=cham){kind=2.;n=vec3(g,0.);} else if(cham>=p.z-tall){kind=2.;n=normalize(vec3(g,1.));} else kind=1.; } vec3 toLamp=lamp-p; float gap=length(toLamp); vec3 L=toLamp/gap; float light=1.,s=.02; vec3 from=p+n*.006; for(int i=0;i<20;i++){ float h=scene(from+L*s); light=min(light,9.*h/s); if(h<.0008||s>gap)break; s+=clamp(h,.012,.1); } light=clamp(light,0.,1.); float fall=1.25/(1.+gap*gap*.5); vec3 H=normalize(L-d); vec3 c; if(kind<.5){ float grain=plaster(p.xy); vec2 slope=vec2(plaster(p.xy+vec2(.004,0.))-grain,plaster(p.xy+vec2(0.,.004))-grain); vec3 m=normalize(vec3(-slope*.8,1.)); float near=smoothstep(0.,reach*.2,dist); c=back*(.55+.9*grain)*(max(dot(m,L),0.)*light*fall*2.1+.05)*(.5+.5*near); }else if(kind<1.5){ float rough=noise(p.xy*90.)*.04; c=face*(max(dot(n,L),0.)*light*fall*2.2+.13); c+=vec3(1.,.95,.86)*(pow(max(dot(n,H),0.),70.)*.5+rough*.1)*light*fall; }else{ vec3 r=reflect(d,n); float sky=.5+.5*r.y; c=edge*(.14+.32*sky*sky+.3*pow(1.-max(dot(-d,n),0.),3.)); c+=edge*max(dot(n,L),0.)*light*fall*1.3; c+=(edge+.35)*pow(max(dot(n,H),0.),26.)*light*fall*2.8; } c=1.-exp(-c*1.5); gl_FragColor=vec4(pow(c,vec3(.72)),1.); }`; function program(gl: WebGLRenderingContext) { const made = gl.createProgram(); for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) { const shader = gl.createShader(type); if (!made || !shader) return null; gl.shaderSource(shader, text); gl.compileShader(shader); gl.attachShader(made, shader); } if (!made) return null; gl.linkProgram(made); return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null; } function sweep(grid: Float64Array, start: number, stride: number, size: number, f: Float64Array, v: Uint16Array, z: Float64Array) { v[0] = 0; z[0] = -1e20; z[1] = 1e20; f[0] = grid[start]; for (let q = 1, k = 0; q < size; q++) { f[q] = grid[start + q * stride]; const q2 = q * q; let s = 0; do { const r = v[k]; s = (f[q] - f[r] + q2 - r * r) / (q - r) / 2; } while (s <= z[k] && --k > -1); k++; v[k] = q; z[k] = s; z[k + 1] = 1e20; } for (let q = 0, k = 0; q < size; q++) { while (z[k + 1] < q) k++; const r = v[k]; grid[start + q * stride] = f[r] + (q - r) * (q - r); } } function distance(grid: Float64Array, w: number, h: number) { const size = Math.max(w, h); const f = new Float64Array(size); const v = new Uint16Array(size); const z = new Float64Array(size + 1); for (let x = 0; x < w; x++) sweep(grid, x, w, h, f, v, z); for (let y = 0; y < h; y++) sweep(grid, y * w, 1, w, f, v, z); } /* Draws the word on a hidden canvas, then keeps each pixel's distance to the nearest letter edge in one byte. */ function bake(text: string, font: string) { const paper = document.createElement("canvas").getContext("2d", { willReadFrequently: true }); if (!paper) return null; paper.font = `900 200px ${font}`; const size = Math.min(320, (200 * (1024 - PAD * 2)) / paper.measureText(text).width); paper.font = `900 ${size}px ${font}`; const w = Math.ceil(paper.measureText(text).width) + PAD * 2; const h = Math.ceil(size * 1.05) + PAD * 2; paper.canvas.width = w; paper.canvas.height = h; paper.font = `900 ${size}px ${font}`; paper.textAlign = "center"; paper.textBaseline = "middle"; paper.fillText(text, w / 2, h / 2 + size * 0.05); const ink = paper.getImageData(0, 0, w, h).data; const outer = new Float64Array(w * h); const inner = new Float64Array(w * h); for (let i = 0; i < w * h; i++) { const a = ink[i * 4 + 3] / 255; outer[i] = a === 1 ? 0 : a === 0 ? 1e20 : Math.max(0, 0.5 - a) ** 2; inner[i] = a === 1 ? 1e20 : a === 0 ? 0 : Math.max(0, a - 0.5) ** 2; } distance(outer, w, h); distance(inner, w, h); const bytes = new Uint8Array(w * h); for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const i = y * w + x; const d = Math.sqrt(outer[i]) - Math.sqrt(inner[i]); bytes[(h - 1 - y) * w + x] = Math.max(0, Math.min(255, Math.round((d / (REACH * 2) + 0.5) * 255))); } } return { bytes, w, h, size }; } const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); /* Typography3D: heavy enamel letters with metal edges fixed to a plaster wall. One lamp walks past and the shadows swing with it; the mouse or arrow keys carry the lamp. Runs only while on screen. */ export function Typography3D({ text = "FORGE", color = "#efe3c8", metal = "#c8933c", wall = "#143a33", font = "'Arial Black', Impact, system-ui, sans-serif", speed = 1, className = "aspect-[16/9] w-[28rem]", }: Typography3DProps) { const reduce = useReducedMotion(); const boxRef = useRef(null); const plainRef = useRef(null); const hues = [color, metal, wall].join(); useEffect(() => { const box = boxRef.current; const plain = plainRef.current; if (!box || !plain) return; const canvas = document.createElement("canvas"); canvas.setAttribute("aria-hidden", "true"); canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%"; const gl = canvas.getContext("webgl", { alpha: false, antialias: false }); const made = gl && program(gl); if (!gl || !made) return; gl.useProgram(made); gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const corner = gl.getAttribLocation(made, "p"); gl.enableVertexAttribArray(corner); gl.vertexAttribPointer(corner, 2, gl.FLOAT, false, 0, 0); const at = (name: string) => gl.getUniformLocation(made, name); const [one, two, three] = hues.split(",").map(rgb); gl.uniform3fv(at("face"), one); gl.uniform3fv(at("edge"), two); gl.uniform3fv(at("back"), three); gl.bindTexture(gl.TEXTURE_2D, gl.createTexture()); 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); gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); let baked = bake(text, font); const upload = () => { if (baked) gl.texImage2D(gl.TEXTURE_2D, 0, gl.LUMINANCE, baked.w, baked.h, 0, gl.LUMINANCE, gl.UNSIGNED_BYTE, baked.bytes); }; upload(); let raf = 0; let last = 0; let inView = false; let time = 0; let aspect = 1; let lampX = -0.9; let lampY = 0.55; let eyeX = 0; let eyeY = 0; let goalX = 0; let goalY = 0; let pointed = false; const draw = () => { gl.uniform3f(at("lamp"), lampX, lampY, 0.62); gl.uniform2f(at("eye"), eyeX, eyeY - 0.16); gl.drawArrays(gl.TRIANGLES, 0, 3); }; const scale = () => { if (!baked) return; const px = Math.min((2 * aspect * 0.9) / (baked.w - PAD * 2), (2 * 0.68) / (baked.h - PAD * 2)); const tall = baked.size * px * 0.15; gl.uniform2f(at("span"), baked.w * px, baked.h * px); gl.uniform1f(at("reach"), REACH * 2 * px); gl.uniform1f(at("tall"), tall); gl.uniform1f(at("bevel"), tall * 0.42); gl.uniform1f(at("step2"), px * 3); }; const frame = (now: number) => { const dt = Math.min(0.05, (now - last) / 1000 || 0); last = now; time += dt * speed; if (!pointed) { goalX = Math.sin(time * 0.42) * aspect * 0.95; goalY = 0.5 + Math.cos(time * 0.27) * 0.42; } lampX += (goalX - lampX) * Math.min(1, dt * 3.6); lampY += (goalY - lampY) * Math.min(1, dt * 3.6); eyeX += (lampX * 0.1 - eyeX) * Math.min(1, dt * 2); eyeY += (lampY * 0.06 - eyeY) * Math.min(1, dt * 2); draw(); raf = inView ? requestAnimationFrame(frame) : 0; }; const start = () => { if (inView && !raf && !reduce) raf = requestAnimationFrame(frame); }; const fit = () => { const { width, height } = box.getBoundingClientRect(); if (!width || !height) return; const ratio = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (width * height))); canvas.width = Math.round(width * ratio); canvas.height = Math.round(height * ratio); aspect = width / height; gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(at("res"), canvas.width, canvas.height); scale(); draw(); }; const place = (x: number, y: number) => { goalX = Math.max(-aspect * 1.15, Math.min(aspect * 1.15, x)); goalY = Math.max(-1.15, Math.min(1.15, y)); if (reduce) { lampX = goalX; lampY = goalY; draw(); } }; const point = (e: PointerEvent) => { if (e.pointerType !== "mouse") return; const r = box.getBoundingClientRect(); pointed = true; place(((e.clientX - r.left) / r.width - 0.5) * 2 * aspect, (0.5 - (e.clientY - r.top) / r.height) * 2); }; const leave = () => { pointed = false; }; const key = (e: KeyboardEvent) => { const x = { ArrowLeft: -1, ArrowRight: 1 }[e.key] ?? 0; const y = { ArrowDown: -1, ArrowUp: 1 }[e.key] ?? 0; if (!x && !y) return; e.preventDefault(); pointed = true; place(goalX + x * 0.22, goalY + y * 0.22); }; fit(); box.append(canvas); plain.hidden = true; let alive = true; document.fonts?.ready.then(() => { if (!alive) return; baked = bake(text, font); upload(); scale(); draw(); }); const sized = new ResizeObserver(fit); sized.observe(box); const watch = new IntersectionObserver(([entry]) => { inView = entry.isIntersecting; start(); }); watch.observe(box); const lost = () => { canvas.remove(); plain.hidden = false; }; canvas.addEventListener("webglcontextlost", lost); box.addEventListener("pointermove", point); box.addEventListener("pointerleave", leave); box.addEventListener("blur", leave); box.addEventListener("keydown", key); return () => { alive = false; cancelAnimationFrame(raf); sized.disconnect(); watch.disconnect(); canvas.removeEventListener("webglcontextlost", lost); box.removeEventListener("pointermove", point); box.removeEventListener("pointerleave", leave); box.removeEventListener("blur", leave); box.removeEventListener("keydown", key); gl.getExtension("WEBGL_lose_context")?.loseContext(); canvas.remove(); plain.hidden = false; }; }, [text, font, hues, speed, reduce]); return (
); }