// WebGL hero — a drifting dust field in a key light, pushed around by the pointer. // three.js is injected on mount so it never blocks first paint. const { useEffect: useEffectGL, useRef: useRefGL, useState: useStateGL } = React; const THREE_SRC = 'https://unpkg.com/three@0.160.1/build/three.min.js'; let threePromise = null; function loadThree() { if (window.THREE) return Promise.resolve(window.THREE); if (threePromise) return threePromise; threePromise = new Promise((resolve, reject) => { const s = document.createElement('script'); s.src = THREE_SRC; s.async = true; s.crossOrigin = 'anonymous'; s.onload = () => (window.THREE ? resolve(window.THREE) : reject(new Error('three missing'))); s.onerror = () => reject(new Error('three failed to load')); document.head.appendChild(s); }); return threePromise; } const VERT = ` uniform float uTime; uniform vec2 uPointer; uniform float uRadius; uniform float uStrength; uniform float uSize; uniform float uDpr; attribute float aSeed; attribute float aScale; varying float vSeed; varying float vGlow; void main() { vec3 p = position; // slow rise + lateral sway, wrapped through the field height float rise = uTime * (0.35 + aSeed * 0.55); p.y = mod(p.y + rise + 40.0, 80.0) - 40.0; p.x += sin(uTime * 0.16 + aSeed * 11.0) * 1.35; // pointer pushes motes aside vec2 d = p.xy - uPointer; float f = 1.0 - smoothstep(0.0, uRadius, length(d)); p.xy += normalize(d + vec2(0.0001)) * f * uStrength; vGlow = f; vSeed = aSeed; vec4 mv = modelViewMatrix * vec4(p, 1.0); gl_PointSize = uSize * aScale * uDpr * (30.0 / -mv.z) * (1.0 + f * 1.7); gl_Position = projectionMatrix * mv; } `; const FRAG = ` uniform vec3 uColorA; uniform vec3 uColorB; uniform float uOpacity; varying float vSeed; varying float vGlow; void main() { vec2 c = gl_PointCoord - 0.5; float d = length(c); if (d > 0.5) discard; float a = pow(smoothstep(0.5, 0.0, d), 2.1); vec3 col = mix(uColorA, uColorB, smoothstep(0.6, 1.0, vSeed)); col = mix(col, uColorB, vGlow * 0.75); gl_FragColor = vec4(col, a * uOpacity * (0.3 + vSeed * 0.7) * (1.0 + vGlow * 1.4)); } `; function HeroCanvas() { const mountRef = useRefGL(null); const [ready, setReady] = useStateGL(false); useEffectGL(() => { const mount = mountRef.current; if (!mount) return; // Honour reduced-motion by simply not running the effect at all. const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; if (reduced) return; let disposed = false; let raf = 0; let renderer, scene, camera, points, geo, mat, io; const coarse = matchMedia('(pointer: coarse)').matches; const COUNT = coarse ? 900 : 2200; // pointer state, in world units on the z=0 plane const ptr = { x: 0, y: 0, tx: 0, ty: 0, active: false }; let visibleH = 0, visibleW = 0; let idle = 0; loadThree().then((THREE) => { if (disposed) return; renderer = new THREE.WebGLRenderer({ alpha: true, antialias: false, powerPreference: 'low-power' }); renderer.setClearColor(0x000000, 0); mount.appendChild(renderer.domElement); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(55, 1, 0.1, 200); camera.position.z = 34; const pos = new Float32Array(COUNT * 3); const seed = new Float32Array(COUNT); const scale = new Float32Array(COUNT); for (let i = 0; i < COUNT; i++) { pos[i * 3 + 0] = (Math.random() - 0.5) * 110; pos[i * 3 + 1] = (Math.random() - 0.5) * 80; pos[i * 3 + 2] = -32 + Math.random() * 38; seed[i] = Math.random(); scale[i] = 0.45 + Math.random() * 1.15; } geo = new THREE.BufferGeometry(); geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); geo.setAttribute('aSeed', new THREE.BufferAttribute(seed, 1)); geo.setAttribute('aScale', new THREE.BufferAttribute(scale, 1)); mat = new THREE.ShaderMaterial({ vertexShader: VERT, fragmentShader: FRAG, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, uniforms: { uTime: { value: 0 }, uPointer: { value: new THREE.Vector2(0, 0) }, uRadius: { value: coarse ? 8.5 : 7.0 }, uStrength: { value: coarse ? 4.0 : 3.4 }, uSize: { value: coarse ? 2.1 : 1.9 }, uDpr: { value: 1 }, uOpacity: { value: 0.85 }, uColorA: { value: new THREE.Color('#f4f1ea') }, uColorB: { value: new THREE.Color('#ff7a4d') }, }, }); points = new THREE.Points(geo, mat); scene.add(points); const resize = () => { const w = mount.clientWidth || 1; const h = mount.clientHeight || 1; const dpr = Math.min(window.devicePixelRatio || 1, coarse ? 1.5 : 2); renderer.setPixelRatio(dpr); renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); mat.uniforms.uDpr.value = dpr; visibleH = 2 * Math.tan((camera.fov * Math.PI) / 360) * camera.position.z; visibleW = visibleH * camera.aspect; }; resize(); const ro = new ResizeObserver(resize); ro.observe(mount); // ---- input ---- const setFromClient = (cx, cy) => { const r = mount.getBoundingClientRect(); const nx = ((cx - r.left) / r.width) * 2 - 1; const ny = -(((cy - r.top) / r.height) * 2 - 1); ptr.tx = (nx * visibleW) / 2; ptr.ty = (ny * visibleH) / 2; ptr.active = true; idle = 0; }; const onMove = (e) => setFromClient(e.clientX, e.clientY); const onTouch = (e) => { if (!e.touches || !e.touches.length) return; setFromClient(e.touches[0].clientX, e.touches[0].clientY); }; const onTouchEnd = () => { ptr.active = false; }; // gyroscope parallax on phones that expose it const onTilt = (e) => { if (ptr.active || e.gamma == null || e.beta == null) return; const g = Math.max(-45, Math.min(45, e.gamma)) / 45; const b = Math.max(-45, Math.min(45, e.beta - 45)) / 45; ptr.tx = (g * visibleW) / 3; ptr.ty = (-b * visibleH) / 3; }; if (coarse) { window.addEventListener('touchstart', onTouch, { passive: true }); window.addEventListener('touchmove', onTouch, { passive: true }); window.addEventListener('touchend', onTouchEnd, { passive: true }); window.addEventListener('deviceorientation', onTilt); } else { window.addEventListener('mousemove', onMove, { passive: true }); } // ---- run only while the hero is on screen and the tab is visible ---- let onScreen = true; let last = performance.now(); let t = 0; io = new IntersectionObserver(([e]) => { onScreen = e.isIntersecting; }, { threshold: 0 }); io.observe(mount); // returning to the tab shouldn't dump one huge dt into the sim const onVis = () => { if (!document.hidden) last = performance.now(); }; document.addEventListener('visibilitychange', onVis); const tick = (now) => { raf = requestAnimationFrame(tick); const dt = Math.min((now - last) / 1000, 0.05); last = now; if (!onScreen || document.hidden) return; t += dt; // with no input, let the field breathe on its own if (!ptr.active) { idle += dt; if (idle > 1.2) { ptr.tx = Math.sin(t * 0.21) * visibleW * 0.22; ptr.ty = Math.cos(t * 0.17) * visibleH * 0.18; } } ptr.x += (ptr.tx - ptr.x) * Math.min(dt * 3.2, 1); ptr.y += (ptr.ty - ptr.y) * Math.min(dt * 3.2, 1); mat.uniforms.uTime.value = t; mat.uniforms.uPointer.value.set(ptr.x, ptr.y); // gentle counter-parallax for depth camera.position.x += ((ptr.x * 0.05) - camera.position.x) * Math.min(dt * 1.6, 1); camera.position.y += ((ptr.y * 0.05) - camera.position.y) * Math.min(dt * 1.6, 1); camera.lookAt(0, 0, 0); renderer.render(scene, camera); }; raf = requestAnimationFrame(tick); setReady(true); // stash teardown for the cleanup below mount._teardown = () => { cancelAnimationFrame(raf); ro.disconnect(); if (io) io.disconnect(); document.removeEventListener('visibilitychange', onVis); window.removeEventListener('mousemove', onMove); window.removeEventListener('touchstart', onTouch); window.removeEventListener('touchmove', onTouch); window.removeEventListener('touchend', onTouchEnd); window.removeEventListener('deviceorientation', onTilt); geo.dispose(); mat.dispose(); renderer.dispose(); if (renderer.domElement.parentNode) renderer.domElement.parentNode.removeChild(renderer.domElement); }; }).catch(() => { // no WebGL / CDN blocked — the gradient wash behind is the fallback }); return () => { disposed = true; cancelAnimationFrame(raf); if (mount._teardown) { mount._teardown(); mount._teardown = null; } }; }, []); return
; } Object.assign(window, { HeroCanvas });