"use client"; import { useEffect, useRef } from "react"; import { useReducedMotion } from "motion/react"; type ImageDistortProps = { src: string; alt: string; /** How hard the picture bends, 0 to 1. */ strength?: number; /** Size of the bent area, as a part of the picture height. */ radius?: number; /** Set the size here. */ className?: string; }; const VERTEX = `attribute vec2 p;varying vec2 v;void main(){v=p*.5+.5;gl_Position=vec4(p,0.,1.);}`; const FRAGMENT = `precision highp float; varying vec2 v; uniform sampler2D t; uniform vec2 fit,m,d; uniform float s,r,box; vec4 pick(vec2 p){return texture2D(t,(p-.5)*fit+.5);} void main(){ float f=(1.-smoothstep(0.,r,length((v-m)*vec2(box,1.))))*s; vec2 uv=v-(v-m)*f*.35-d*f*2.; vec2 split=d*f*.6+(v-m)*f*.015; gl_FragColor=vec4(pick(uv+split).r,pick(uv).g,pick(uv-split).b,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; } /* ImageDistort: the picture bulges under the pointer and smears with its speed, with a little colour split. The plain stays underneath. */ export function ImageDistort({ src, alt, strength = 1, radius = 0.4, className = "aspect-video" }: ImageDistortProps) { const reduce = useReducedMotion(); const boxRef = useRef(null); useEffect(() => { const box = boxRef.current; if (!box || reduce) 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); gl.uniform1f(at("r"), radius); const state = { x: 0.5, y: 0.5, tx: 0.5, ty: 0.5, dx: 0, dy: 0, s: 0, ts: 0, inside: false }; let raf = 0; let last = 0; let ready = false; const image = new Image(); image.crossOrigin = "anonymous"; const draw = () => { gl.uniform2f(at("m"), state.x, state.y); gl.uniform2f(at("d"), state.dx, state.dy); gl.uniform1f(at("s"), state.s * strength); gl.drawArrays(gl.TRIANGLES, 0, 3); }; const fit = () => { const { width, height } = box.getBoundingClientRect(); if (!width || !height) return; const dpr = Math.min(window.devicePixelRatio, 2); canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); gl.viewport(0, 0, canvas.width, canvas.height); if (!ready) return; const shape = width / height / (image.naturalWidth / image.naturalHeight); gl.uniform2f(at("fit"), Math.min(1 / shape, 1), Math.min(shape, 1)); gl.uniform1f(at("box"), width / height); draw(); }; const frame = (now: number) => { const step = Math.min((now - last) / 16.7, 3); last = now; const ease = 1 - Math.pow(0.82, step); state.x += (state.tx - state.x) * ease; state.y += (state.ty - state.y) * ease; state.s += (state.ts - state.s) * (1 - Math.pow(0.88, step)); const decay = Math.pow(0.9, step); state.dx *= decay; state.dy *= decay; draw(); const busy = Math.abs(state.ts - state.s) > 0.002 || Math.hypot(state.dx, state.dy) > 0.0004 || Math.hypot(state.tx - state.x, state.ty - state.y) > 0.001; raf = busy ? requestAnimationFrame(frame) : 0; }; const wake = () => { if (!ready || raf) return; last = performance.now(); raf = requestAnimationFrame(frame); }; const place = (e: PointerEvent) => { const r = box.getBoundingClientRect(); const x = (e.clientX - r.left) / r.width; const y = 1 - (e.clientY - r.top) / r.height; if (state.inside) { state.dx = Math.max(-0.08, Math.min(0.08, state.dx + (x - state.tx) * 0.5)); state.dy = Math.max(-0.08, Math.min(0.08, state.dy + (y - state.ty) * 0.5)); } else { state.x = x; state.y = y; state.inside = true; } state.tx = x; state.ty = y; state.ts = 1; wake(); }; const leave = () => { state.ts = 0; state.inside = false; wake(); }; image.onload = () => { gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); gl.bindTexture(gl.TEXTURE_2D, gl.createTexture()); for (const wrap of [gl.TEXTURE_WRAP_S, gl.TEXTURE_WRAP_T]) gl.texParameteri(gl.TEXTURE_2D, wrap, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, image); ready = true; fit(); box.append(canvas); }; image.src = src; const sized = new ResizeObserver(fit); sized.observe(box); const lost = () => canvas.remove(); canvas.addEventListener("webglcontextlost", lost); box.addEventListener("pointerdown", place); box.addEventListener("pointermove", place); box.addEventListener("pointerleave", leave); box.addEventListener("pointercancel", leave); return () => { cancelAnimationFrame(raf); sized.disconnect(); image.onload = null; canvas.removeEventListener("webglcontextlost", lost); box.removeEventListener("pointerdown", place); box.removeEventListener("pointermove", place); box.removeEventListener("pointerleave", leave); box.removeEventListener("pointercancel", leave); gl.getExtension("WEBGL_lose_context")?.loseContext(); canvas.remove(); }; }, [src, strength, radius, reduce]); return (
{/* eslint-disable-next-line @next/next/no-img-element */} {alt}
); }