'use client'; /** * ParticleField — GPGPU particle simulation backing the marketing hero. * * Lean Three.js, no @react-three/fiber. Renders a 256×256 (or 128×128 * on lower-end devices) float texture of positions, ping-ponged each * frame through a sim shader, then drawn as gl_Points with an * anti-aliased disc SDF and additive blending. * * Callers MUST gate this behind the capability checks in `index.tsx` — * this component assumes WebGL2 + float-render-target support exists * and will throw if they don't. */ import { useEffect, useRef } from 'react'; import * as THREE from 'three'; import { initFragment, renderFragment, renderVertex, simFragment, simVertex, } from './shaders'; export interface ParticleFieldProps { /** Sqrt of particle count. 256 → 65,536 particles, 128 → 16,384. */ textureSize: 128 | 256; /** * Global multiplier on drift + ring-push velocity. 1.0 default; 0.5 * for reduced-motion users. Pointer position is NOT scaled — the ring * still tracks the cursor at full fidelity, only the ambient motion * and the gradient-push are damped. */ motionScale?: number; } export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldProps) { const containerRef = useRef(null); useEffect(() => { const container = containerRef.current; if (!container) return; // ----- Renderer --------------------------------------------------- // alpha:true so the hero gradient/border behind the canvas shows // through where particles are sparse. premultipliedAlpha pairs with // the premultiplied output of the render fragment shader. const canvas = document.createElement('canvas'); canvas.style.display = 'block'; canvas.style.width = '100%'; canvas.style.height = '100%'; container.appendChild(canvas); const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, alpha: true, premultipliedAlpha: true, powerPreference: 'high-performance', }); renderer.setClearColor(0x000000, 0); // Clamp DPR — going above 2 on a 65k-particle field burns laptop GPUs // with negligible visual gain. const dpr = Math.min(window.devicePixelRatio || 1, 2); renderer.setPixelRatio(dpr); const initialRect = container.getBoundingClientRect(); renderer.setSize(Math.max(1, initialRect.width), Math.max(1, initialRect.height), false); // ----- Float-texture support check -------------------------------- // EXT_color_buffer_float is required to render INTO a float target // on WebGL2. Without it, ping-pong won't work — bail out and let the // wrapper fall back to the static gradient. const gl = renderer.getContext() as WebGL2RenderingContext; const floatExt = gl.getExtension('EXT_color_buffer_float'); if (!floatExt) { // Tear down what we built and signal failure via the canvas // remaining empty. The wrapper checks this synchronously before // we even mount, so this is a belt-and-braces guard. canvas.remove(); renderer.dispose(); return; } // ----- Scenes & camera -------------------------------------------- // Two scenes: one for the simulation pass (fullscreen quad), one // for the actual particle render. Both use the same OrthographicCamera // because everything is already in clip space. const simScene = new THREE.Scene(); const renderScene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); // ----- Ping-pong render targets ------------------------------------ const rtParams: THREE.RenderTargetOptions = { minFilter: THREE.NearestFilter, magFilter: THREE.NearestFilter, format: THREE.RGBAFormat, type: THREE.FloatType, depthBuffer: false, stencilBuffer: false, generateMipmaps: false, }; let rtA = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams); let rtB = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams); // ----- Init pass: seed both targets with the starting field ------- const initMaterial = new THREE.ShaderMaterial({ vertexShader: simVertex, fragmentShader: initFragment, }); const fsQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), initMaterial); simScene.add(fsQuad); renderer.setRenderTarget(rtA); renderer.render(simScene, camera); renderer.setRenderTarget(rtB); renderer.render(simScene, camera); renderer.setRenderTarget(null); // Swap in the actual sim material on the same quad. const simUniforms = { uPrev: { value: rtA.texture }, uTime: { value: 0 }, uDelta: { value: 1 / 60 }, uRingPos: { value: new THREE.Vector2(0, 0) }, uRingRadius: { value: 0.22 }, uRingWidth: { value: 0.05 }, uRingActive: { value: 0 }, uMotionScale: { value: motionScale }, }; const simMaterial = new THREE.ShaderMaterial({ vertexShader: simVertex, fragmentShader: simFragment, uniforms: simUniforms, }); fsQuad.material = simMaterial; initMaterial.dispose(); // ----- Particle geometry: one vertex per texel -------------------- const count = textureSize * textureSize; const indexUvs = new Float32Array(count * 2); const positionsAttr = new Float32Array(count * 3); // unused but required for (let y = 0; y < textureSize; y++) { for (let x = 0; x < textureSize; x++) { const i = y * textureSize + x; // Sample texels at their centers, not corners. indexUvs[i * 2 + 0] = (x + 0.5) / textureSize; indexUvs[i * 2 + 1] = (y + 0.5) / textureSize; } } const particleGeo = new THREE.BufferGeometry(); particleGeo.setAttribute('position', new THREE.BufferAttribute(positionsAttr, 3)); particleGeo.setAttribute('aIndexUv', new THREE.BufferAttribute(indexUvs, 2)); // Tell Three.js never to frustum-cull this — positions live in // the texture, not the bounding box of the buffer geometry. particleGeo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 10); // Brand colors — read from --color-accent (#6366f1) and // --color-success (#22c55e). Kept as constants here rather than // reading from CSS variables: those resolve to oklch in modern // Tailwind builds, which needs parsing. Hardcode the hex values // the design system already commits to. const colorCalm = new THREE.Color('#6366f1'); const colorHot = new THREE.Color('#22c55e'); const renderUniforms = { uPositions: { value: rtB.texture }, // Bigger dots + higher base alpha = more volumetric "calm field" // read at the load-in (was 1.8 / 0.42 — read as too thin, looked // stuttery because individual particles were hard to track between // frames). With these values the field has a denser cumulative // glow without any change to the simulation itself. uPointSize: { value: textureSize === 256 ? 2.8 : 3.6 }, uDpr: { value: dpr }, uColorCalm: { value: colorCalm }, uColorHot: { value: colorHot }, uBaseAlpha: { value: 0.6 }, }; const particleMat = new THREE.ShaderMaterial({ vertexShader: renderVertex, fragmentShader: renderFragment, uniforms: renderUniforms, transparent: true, depthTest: false, depthWrite: false, blending: THREE.AdditiveBlending, }); const particles = new THREE.Points(particleGeo, particleMat); renderScene.add(particles); // ----- Pointer tracking ------------------------------------------ // Raw target (last pointer event), smoothed via EMA into the // uniform each frame so the ring tracks fluidly even if events // are sparse (touch / pen / throttled mouse). const target = new THREE.Vector2(0, 0); const smoothed = new THREE.Vector2(0, 0); let hasPointer = false; const updatePointerFromClient = (clientX: number, clientY: number) => { const rect = container.getBoundingClientRect(); const x = ((clientX - rect.left) / rect.width) * 2 - 1; // Flip Y so up is positive — matches clip space. const y = -(((clientY - rect.top) / rect.height) * 2 - 1); target.set(x, y); hasPointer = true; }; const onPointerMove = (e: PointerEvent) => { updatePointerFromClient(e.clientX, e.clientY); }; const onPointerLeave = () => { hasPointer = false; }; // Listen on window so the ring tracks even when the cursor is over // the codeblocks/CTAs that sit above the canvas. The container is // pointer-events:none-friendly because we read clientX/clientY. window.addEventListener('pointermove', onPointerMove, { passive: true }); container.addEventListener('pointerleave', onPointerLeave, { passive: true }); // ----- Resize handling ------------------------------------------- const onResize = () => { const rect = container.getBoundingClientRect(); if (rect.width < 1 || rect.height < 1) return; renderer.setSize(rect.width, rect.height, false); }; const ro = new ResizeObserver(onResize); ro.observe(container); // ----- Animation loop -------------------------------------------- const clock = new THREE.Clock(); let raf = 0; let running = true; // Defer the first frame to idle to keep LCP clean — the hero text // is the LCP element and must paint before we start eating GPU. const startLoop = () => { const tick = () => { if (!running) return; raf = requestAnimationFrame(tick); const delta = Math.min(clock.getDelta(), 1 / 30); // tab-switch guard const t = clock.elapsedTime; // Smooth the pointer position (EMA, alpha=0.15). smoothed.x = smoothed.x * 0.85 + target.x * 0.15; smoothed.y = smoothed.y * 0.85 + target.y * 0.15; // Fade ring in/out when the pointer enters/leaves. const targetActive = hasPointer ? 1 : 0; simUniforms.uRingActive.value = simUniforms.uRingActive.value * 0.92 + targetActive * 0.08; simUniforms.uTime.value = t; simUniforms.uDelta.value = delta; simUniforms.uRingPos.value.copy(smoothed); // Sim pass: read rtA, write rtB. simUniforms.uPrev.value = rtA.texture; renderer.setRenderTarget(rtB); renderer.render(simScene, camera); renderer.setRenderTarget(null); // Render pass: draw particles sampling rtB. renderUniforms.uPositions.value = rtB.texture; renderer.render(renderScene, camera); // Swap. const tmp = rtA; rtA = rtB; rtB = tmp; }; tick(); }; let idleHandle: number | null = null; const w = window as Window & { requestIdleCallback?: (cb: IdleRequestCallback) => number; cancelIdleCallback?: (h: number) => void; }; if (typeof w.requestIdleCallback === 'function') { idleHandle = w.requestIdleCallback(() => startLoop()); } else { idleHandle = window.setTimeout(startLoop, 80); } // ----- Cleanup ---------------------------------------------------- // Three.js leaks GPU memory aggressively if we skip any of this. return () => { running = false; if (raf) cancelAnimationFrame(raf); if (idleHandle !== null) { if (typeof w.cancelIdleCallback === 'function') { w.cancelIdleCallback(idleHandle); } else { window.clearTimeout(idleHandle); } } window.removeEventListener('pointermove', onPointerMove); container.removeEventListener('pointerleave', onPointerLeave); ro.disconnect(); particleGeo.dispose(); particleMat.dispose(); simMaterial.dispose(); (fsQuad.geometry as THREE.BufferGeometry).dispose(); rtA.dispose(); rtB.dispose(); renderer.dispose(); if (canvas.parentNode) canvas.parentNode.removeChild(canvas); }; }, [textureSize, motionScale]); // The container is the surface that receives pointer events. // Visually transparent — the canvas it owns paints the field. return (