"use client"; import { useEffect, useRef } from "react"; import { useReducedMotion } from "motion/react"; type DepthParallaxProps = { src: string; /** Grey picture of the same shape. White is near, black is far. Blur it a little. */ depth: string; alt: string; /** How far near parts travel, as a part of the picture width. */ strength?: 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.);}`; // The depth is read three times, each from the last place found, so near edges do not tear. const FRAGMENT = `precision highp float; varying vec2 v; uniform sampler2D t,z; uniform vec2 fit,m; void main(){ vec2 start=(v-.5)*fit+.5; vec2 p=start; for(int i=0;i<3;i++)p=start-m*(texture2D(z,p).r-.45); gl_FragColor=texture2D(t,p); }`; 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 load(url: string) { return new Promise((resolve, reject) => { const image = new Image(); image.crossOrigin = "anonymous"; image.onload = () => resolve(image); image.onerror = reject; image.src = url; }); } /* DepthParallax: one flat picture plus a depth map looks like a small window into a scene. Near parts move more than far parts as the pointer moves. */ export function DepthParallax({ src, depth, alt, strength = 0.04, className = "aspect-video" }: DepthParallaxProps) { 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.uniform1i(at("t"), 0); gl.uniform1i(at("z"), 1); let raf = 0; let last = 0; let ratio = 1; let ready = false; let cancelled = false; let [x, y, tx, ty] = [0, 0, 0, 0]; const draw = () => { gl.uniform2f(at("m"), x * strength, y * 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 / ratio; const spare = 1 - strength * 1.6; gl.uniform2f(at("fit"), Math.min(1 / shape, 1) * spare, Math.min(shape, 1) * spare); draw(); }; const frame = (now: number) => { const ease = 1 - Math.pow(0.9, Math.min((now - last) / 16.7, 3)); last = now; x += (tx - x) * ease; y += (ty - y) * ease; draw(); raf = Math.hypot(tx - x, ty - y) > 0.002 ? requestAnimationFrame(frame) : 0; }; const wake = () => { if (!ready || raf) return; last = performance.now(); raf = requestAnimationFrame(frame); }; const place = (e: PointerEvent) => { const r = box.getBoundingClientRect(); tx = ((e.clientX - r.left) / r.width) * 2 - 1; ty = 1 - ((e.clientY - r.top) / r.height) * 2; wake(); }; const leave = () => { tx = 0; ty = 0; wake(); }; Promise.all([load(src), load(depth)]) .then(([picture, map]) => { if (cancelled) return; gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); [picture, map].forEach((image, unit) => { gl.activeTexture(gl.TEXTURE0 + unit); 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); }); ratio = picture.naturalWidth / picture.naturalHeight; ready = true; fit(); box.append(canvas); }) .catch(() => canvas.remove()); 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 () => { cancelled = true; cancelAnimationFrame(raf); sized.disconnect(); 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, depth, strength, reduce]); return (
{/* eslint-disable-next-line @next/next/no-img-element */} {alt}
); }