import * as faceapi from 'face-api.js'; import { BloomEffect, ChromaticAberrationEffect, EffectComposer, EffectPass, RenderPass, } from 'postprocessing'; import { useEffect, useRef, useState } from 'react'; import * as THREE from 'three'; import './GridScan.css'; const vert = `varying vec2 vUv; void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`; const frag = `precision highp float; uniform vec3 iResolution; uniform float iTime; uniform vec2 uSkew; uniform float uTilt; uniform float uYaw; uniform float uLineThickness; uniform vec3 uLinesColor; uniform vec3 uScanColor; uniform float uGridScale; uniform float uLineStyle; uniform float uLineJitter; uniform float uScanOpacity; uniform float uScanDirection; uniform float uNoise; uniform float uBloomOpacity; uniform float uScanGlow; uniform float uScanSoftness; uniform float uPhaseTaper; uniform float uScanDuration; uniform float uScanDelay; varying vec2 vUv; uniform float uScanStarts[8]; uniform float uScanCount; const int MAX_SCANS = 8; float smoother01(float a, float b, float x){ float t = clamp((x - a) / max(1e-5, (b - a)), 0.0, 1.0); return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); } void mainImage(out vec4 fragColor, in vec2 fragCoord){ vec2 p = (2.0 * fragCoord - iResolution.xy) / iResolution.y; vec3 ro = vec3(0.0); vec3 rd = normalize(vec3(p, 2.0)); float cR = cos(uTilt), sR = sin(uTilt); rd.xy = mat2(cR, -sR, sR, cR) * rd.xy; float cY = cos(uYaw), sY = sin(uYaw); rd.xz = mat2(cY, -sY, sY, cY) * rd.xz; vec2 skew = clamp(uSkew, vec2(-0.7), vec2(0.7)); rd.xy += skew * rd.z; vec3 color = vec3(0.0); float minT = 1e20; float gridScale = max(1e-5, uGridScale); float fadeStrength = 2.0; vec2 gridUV = vec2(0.0); float hitIsY = 1.0; for (int i = 0; i < 4; i++){ float isY = float(i < 2); float pos = mix(-0.2, 0.2, float(i)) * isY + mix(-0.5, 0.5, float(i - 2)) * (1.0 - isY); float num = pos - (isY * ro.y + (1.0 - isY) * ro.x); float den = isY * rd.y + (1.0 - isY) * rd.x; float t = num / den; vec3 h = ro + rd * t; float depthBoost = smoothstep(0.0, 3.0, h.z); h.xy += skew * 0.15 * depthBoost; bool use = t > 0.0 && t < minT; gridUV = use ? mix(h.zy, h.xz, isY) / gridScale : gridUV; minT = use ? t : minT; hitIsY = use ? isY : hitIsY; } vec3 hit = ro + rd * minT; float dist = length(hit - ro); float jitterAmt = clamp(uLineJitter, 0.0, 1.0); if (jitterAmt > 0.0) { vec2 j = vec2( sin(gridUV.y * 2.7 + iTime * 1.8), cos(gridUV.x * 2.3 - iTime * 1.6) ) * (0.15 * jitterAmt); gridUV += j; } float fx = fract(gridUV.x); float fy = fract(gridUV.y); float ax = min(fx, 1.0 - fx); float ay = min(fy, 1.0 - fy); float wx = fwidth(gridUV.x); float wy = fwidth(gridUV.y); float halfPx = max(0.0, uLineThickness) * 0.5; float tx = halfPx * wx; float ty = halfPx * wy; float aax = wx; float aay = wy; float lineX = 1.0 - smoothstep(tx, tx + aax, ax); float lineY = 1.0 - smoothstep(ty, ty + aay, ay); if (uLineStyle > 0.5) { float dashRepeat = 4.0; float dashDuty = 0.5; float vy = fract(gridUV.y * dashRepeat); float vx = fract(gridUV.x * dashRepeat); float dashMaskY = step(vy, dashDuty); float dashMaskX = step(vx, dashDuty); if (uLineStyle < 1.5) { lineX *= dashMaskY; lineY *= dashMaskX; } else { float dotRepeat = 6.0; float dotWidth = 0.18; float cy = abs(fract(gridUV.y * dotRepeat) - 0.5); float cx = abs(fract(gridUV.x * dotRepeat) - 0.5); float dotMaskY = 1.0 - smoothstep(dotWidth, dotWidth + fwidth(gridUV.y * dotRepeat), cy); float dotMaskX = 1.0 - smoothstep(dotWidth, dotWidth + fwidth(gridUV.x * dotRepeat), cx); lineX *= dotMaskY; lineY *= dotMaskX; } } float primaryMask = max(lineX, lineY); vec2 gridUV2 = (hitIsY > 0.5 ? hit.xz : hit.zy) / gridScale; if (jitterAmt > 0.0) { vec2 j2 = vec2( cos(gridUV2.y * 2.1 - iTime * 1.4), sin(gridUV2.x * 2.5 + iTime * 1.7) ) * (0.15 * jitterAmt); gridUV2 += j2; } float fx2 = fract(gridUV2.x); float fy2 = fract(gridUV2.y); float ax2 = min(fx2, 1.0 - fx2); float ay2 = min(fy2, 1.0 - fy2); float wx2 = fwidth(gridUV2.x); float wy2 = fwidth(gridUV2.y); float tx2 = halfPx * wx2; float ty2 = halfPx * wy2; float aax2 = wx2; float aay2 = wy2; float lineX2 = 1.0 - smoothstep(tx2, tx2 + aax2, ax2); float lineY2 = 1.0 - smoothstep(ty2, ty2 + aay2, ay2); if (uLineStyle > 0.5) { float dashRepeat2 = 4.0; float dashDuty2 = 0.5; float vy2m = fract(gridUV2.y * dashRepeat2); float vx2m = fract(gridUV2.x * dashRepeat2); float dashMaskY2 = step(vy2m, dashDuty2); float dashMaskX2 = step(vx2m, dashDuty2); if (uLineStyle < 1.5) { lineX2 *= dashMaskY2; lineY2 *= dashMaskX2; } else { float dotRepeat2 = 6.0; float dotWidth2 = 0.18; float cy2 = abs(fract(gridUV2.y * dotRepeat2) - 0.5); float cx2 = abs(fract(gridUV2.x * dotRepeat2) - 0.5); float dotMaskY2 = 1.0 - smoothstep(dotWidth2, dotWidth2 + fwidth(gridUV2.y * dotRepeat2), cy2); float dotMaskX2 = 1.0 - smoothstep(dotWidth2, dotWidth2 + fwidth(gridUV2.x * dotRepeat2), cx2); lineX2 *= dotMaskY2; lineY2 *= dotMaskX2; } } float altMask = max(lineX2, lineY2); float edgeDistX = min(abs(hit.x - (-0.5)), abs(hit.x - 0.5)); float edgeDistY = min(abs(hit.y - (-0.2)), abs(hit.y - 0.2)); float edgeDist = mix(edgeDistY, edgeDistX, hitIsY); float edgeGate = 1.0 - smoothstep(gridScale * 0.5, gridScale * 2.0, edgeDist); altMask *= edgeGate; float lineMask = max(primaryMask, altMask); float fade = exp(-dist * fadeStrength); float dur = max(0.05, uScanDuration); float del = max(0.0, uScanDelay); float scanZMax = 2.0; float widthScale = max(0.1, uScanGlow); float sigma = max(0.001, 0.18 * widthScale * uScanSoftness); float sigmaA = sigma * 2.0; float combinedPulse = 0.0; float combinedAura = 0.0; float cycle = dur + del; float tCycle = mod(iTime, cycle); float scanPhase = clamp((tCycle - del) / dur, 0.0, 1.0); float phase = scanPhase; if (uScanDirection > 0.5 && uScanDirection < 1.5) { phase = 1.0 - phase; } else if (uScanDirection > 1.5) { float t2 = mod(max(0.0, iTime - del), 2.0 * dur); phase = (t2 < dur) ? (t2 / dur) : (1.0 - (t2 - dur) / dur); } float scanZ = phase * scanZMax; float dz = abs(hit.z - scanZ); float lineBand = exp(-0.5 * (dz * dz) / (sigma * sigma)); float taper = clamp(uPhaseTaper, 0.0, 0.49); float headW = taper; float tailW = taper; float headFade = smoother01(0.0, headW, phase); float tailFade = 1.0 - smoother01(1.0 - tailW, 1.0, phase); float phaseWindow = headFade * tailFade; float pulseBase = lineBand * phaseWindow; combinedPulse += pulseBase * clamp(uScanOpacity, 0.0, 1.0); float auraBand = exp(-0.5 * (dz * dz) / (sigmaA * sigmaA)); combinedAura += (auraBand * 0.25) * phaseWindow * clamp(uScanOpacity, 0.0, 1.0); for (int i = 0; i < MAX_SCANS; i++) { if (float(i) >= uScanCount) break; float tActiveI = iTime - uScanStarts[i]; float phaseI = clamp(tActiveI / dur, 0.0, 1.0); if (uScanDirection > 0.5 && uScanDirection < 1.5) { phaseI = 1.0 - phaseI; } else if (uScanDirection > 1.5) { phaseI = (phaseI < 0.5) ? (phaseI * 2.0) : (1.0 - (phaseI - 0.5) * 2.0); } float scanZI = phaseI * scanZMax; float dzI = abs(hit.z - scanZI); float lineBandI = exp(-0.5 * (dzI * dzI) / (sigma * sigma)); float headFadeI = smoother01(0.0, headW, phaseI); float tailFadeI = 1.0 - smoother01(1.0 - tailW, 1.0, phaseI); float phaseWindowI = headFadeI * tailFadeI; combinedPulse += lineBandI * phaseWindowI * clamp(uScanOpacity, 0.0, 1.0); float auraBandI = exp(-0.5 * (dzI * dzI) / (sigmaA * sigmaA)); combinedAura += (auraBandI * 0.25) * phaseWindowI * clamp(uScanOpacity, 0.0, 1.0); } float lineVis = lineMask; vec3 gridCol = uLinesColor * lineVis * fade; vec3 scanCol = uScanColor * combinedPulse; vec3 scanAura = uScanColor * combinedAura; color = gridCol + scanCol + scanAura; float n = fract(sin(dot(gl_FragCoord.xy + vec2(iTime * 123.4), vec2(12.9898,78.233))) * 43758.5453123); color += (n - 0.5) * uNoise; color = clamp(color, 0.0, 1.0); float alpha = clamp(max(lineVis, combinedPulse), 0.0, 1.0); float gx = 1.0 - smoothstep(tx * 2.0, tx * 2.0 + aax * 2.0, ax); float gy = 1.0 - smoothstep(ty * 2.0, ty * 2.0 + aay * 2.0, ay); float halo = max(gx, gy) * fade; alpha = max(alpha, halo * clamp(uBloomOpacity, 0.0, 1.0)); fragColor = vec4(color, alpha); } void main(){ vec4 c; mainImage(c, vUv * iResolution.xy); gl_FragColor = c; }`; // ── Helpers ────────────────────────────────────────────────────────────────── function srgbColor(hex) { return new THREE.Color(hex).convertSRGBToLinear(); } function smoothDampVec2(current, target, currentVelocity, smoothTime, maxSpeed, deltaTime) { const out = current.clone(); smoothTime = Math.max(0.0001, smoothTime); const omega = 2 / smoothTime; const x = omega * deltaTime; const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x); let change = current.clone().sub(target); const originalTo = target.clone(); const maxChange = maxSpeed * smoothTime; if (change.length() > maxChange) change.setLength(maxChange); target = current.clone().sub(change); const temp = currentVelocity.clone().addScaledVector(change, omega).multiplyScalar(deltaTime); currentVelocity.sub(temp.clone().multiplyScalar(omega)); currentVelocity.multiplyScalar(exp); out.copy(target.clone().add(change.add(temp).multiplyScalar(exp))); const origMinusCurrent = originalTo.clone().sub(current); const outMinusOrig = out.clone().sub(originalTo); if (origMinusCurrent.dot(outMinusOrig) > 0) { out.copy(originalTo); currentVelocity.set(0, 0); } return out; } function smoothDampFloat(current, target, velRef, smoothTime, maxSpeed, deltaTime) { smoothTime = Math.max(0.0001, smoothTime); const omega = 2 / smoothTime; const x = omega * deltaTime; const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x); let change = current - target; const originalTo = target; const maxChange = maxSpeed * smoothTime; change = Math.sign(change) * Math.min(Math.abs(change), maxChange); target = current - change; const temp = (velRef.v + omega * change) * deltaTime; velRef.v = (velRef.v - omega * temp) * exp; let out = target + (change + temp) * exp; if ((originalTo - current) * (out - originalTo) > 0) { out = originalTo; velRef.v = 0; } return { value: out, v: velRef.v }; } function medianPush(buf, v, maxLen) { buf.push(v); if (buf.length > maxLen) buf.shift(); } function median(buf) { if (buf.length === 0) return 0; const a = [...buf].sort((x, y) => x - y); const mid = Math.floor(a.length / 2); return a.length % 2 ? a[mid] : (a[mid - 1] + a[mid]) * 0.5; } function centroid(points) { let x = 0, y = 0; const n = points.length || 1; for (const p of points) { x += p.x; y += p.y; } return { x: x / n, y: y / n }; } function dist2(a, b) { return Math.hypot(a.x - b.x, a.y - b.y); } // ── Component ───────────────────────────────────────────────────────────────── const MAX_SCANS = 8; export const GridScan = ({ enableWebcam = false, showPreview = false, modelsPath = 'https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@0.22.2/weights', sensitivity = 0.55, lineThickness = 1, linesColor = '#2F293A', scanColor = '#FF9FFC', scanOpacity = 0.4, gridScale = 0.1, lineStyle = 'solid', lineJitter = 0.1, scanDirection = 'pingpong', enablePost = true, bloomIntensity = 0, bloomThreshold = 0, bloomSmoothing = 0, chromaticAberration = 0.002, noiseIntensity = 0.01, scanGlow = 0.5, scanSoftness = 2, scanPhaseTaper = 0.9, scanDuration = 2.0, scanDelay = 2.0, enableGyro = false, scanOnClick = false, snapBackDelay = 250, className, style, }) => { const containerRef = useRef(null); const videoRef = useRef(null); const rendererRef = useRef(null); const materialRef = useRef(null); const composerRef = useRef(null); const bloomRef = useRef(null); const chromaRef = useRef(null); const rafRef = useRef(null); const [modelsReady, setModelsReady] = useState(false); const [uiFaceActive, setUiFaceActive] = useState(false); const lookTarget = useRef(new THREE.Vector2(0, 0)); const tiltTarget = useRef(0); const yawTarget = useRef(0); const lookCurrent = useRef(new THREE.Vector2(0, 0)); const lookVel = useRef(new THREE.Vector2(0, 0)); const tiltCurrent = useRef(0); const tiltVel = useRef(0); const yawCurrent = useRef(0); const yawVel = useRef(0); const scanStartsRef = useRef([]); const pushScan = (t) => { const arr = scanStartsRef.current.slice(); if (arr.length >= MAX_SCANS) arr.shift(); arr.push(t); scanStartsRef.current = arr; if (materialRef.current) { const u = materialRef.current.uniforms; const buf = new Array(MAX_SCANS).fill(0); for (let i = 0; i < arr.length && i < MAX_SCANS; i++) buf[i] = arr[i]; u.uScanStarts.value = buf; u.uScanCount.value = arr.length; } }; const bufX = useRef([]); const bufY = useRef([]); const bufT = useRef([]); const bufYaw = useRef([]); const s = THREE.MathUtils.clamp(sensitivity, 0, 1); const skewScale = THREE.MathUtils.lerp(0.06, 0.2, s); const tiltScale = THREE.MathUtils.lerp(0.12, 0.3, s); const yawScale = THREE.MathUtils.lerp(0.1, 0.28, s); const depthResponse = THREE.MathUtils.lerp(0.25, 0.45, s); const smoothTime = THREE.MathUtils.lerp(0.45, 0.12, s); const maxSpeed = Infinity; const yBoost = THREE.MathUtils.lerp(1.2, 1.6, s); // Mouse interaction useEffect(() => { const el = containerRef.current; if (!el) return; let leaveTimer = null; const onMove = (e) => { if (uiFaceActive) return; if (leaveTimer) { clearTimeout(leaveTimer); leaveTimer = null; } const rect = el.getBoundingClientRect(); const nx = ((e.clientX - rect.left) / rect.width) * 2 - 1; const ny = -(((e.clientY - rect.top) / rect.height) * 2 - 1); lookTarget.current.set(nx, ny); }; const onClick = async () => { const nowSec = performance.now() / 1000; if (scanOnClick) pushScan(nowSec); if (enableGyro && typeof window !== 'undefined' && window.DeviceOrientationEvent && DeviceOrientationEvent.requestPermission) { try { await DeviceOrientationEvent.requestPermission(); } catch { /* noop */ } } }; const onLeave = () => { if (uiFaceActive) return; if (leaveTimer) clearTimeout(leaveTimer); leaveTimer = window.setTimeout(() => { lookTarget.current.set(0, 0); tiltTarget.current = 0; yawTarget.current = 0; }, Math.max(0, snapBackDelay || 0)); }; el.addEventListener('mousemove', onMove); el.addEventListener('mouseenter', () => { if (leaveTimer) { clearTimeout(leaveTimer); leaveTimer = null; } }); if (scanOnClick) el.addEventListener('click', onClick); el.addEventListener('mouseleave', onLeave); return () => { el.removeEventListener('mousemove', onMove); el.removeEventListener('mouseleave', onLeave); if (scanOnClick) el.removeEventListener('click', onClick); if (leaveTimer) clearTimeout(leaveTimer); }; }, [uiFaceActive, snapBackDelay, scanOnClick, enableGyro]); // Three.js renderer setup useEffect(() => { const container = containerRef.current; if (!container) return; const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); rendererRef.current = renderer; renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(container.clientWidth, container.clientHeight); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.NoToneMapping; renderer.autoClear = false; renderer.setClearColor(0x000000, 0); container.appendChild(renderer.domElement); const uniforms = { iResolution: { value: new THREE.Vector3(container.clientWidth, container.clientHeight, renderer.getPixelRatio()) }, iTime: { value: 0 }, uSkew: { value: new THREE.Vector2(0, 0) }, uTilt: { value: 0 }, uYaw: { value: 0 }, uLineThickness: { value: lineThickness }, uLinesColor: { value: srgbColor(linesColor) }, uScanColor: { value: srgbColor(scanColor) }, uGridScale: { value: gridScale }, uLineStyle: { value: lineStyle === 'dashed' ? 1 : lineStyle === 'dotted' ? 2 : 0 }, uLineJitter: { value: Math.max(0, Math.min(1, lineJitter || 0)) }, uScanOpacity: { value: scanOpacity }, uNoise: { value: noiseIntensity }, uBloomOpacity: { value: bloomIntensity }, uScanGlow: { value: scanGlow }, uScanSoftness: { value: scanSoftness }, uPhaseTaper: { value: scanPhaseTaper }, uScanDuration: { value: scanDuration }, uScanDelay: { value: scanDelay }, uScanDirection: { value: scanDirection === 'backward' ? 1 : scanDirection === 'pingpong' ? 2 : 0 }, uScanStarts: { value: new Array(MAX_SCANS).fill(0) }, uScanCount: { value: 0 }, }; const material = new THREE.ShaderMaterial({ uniforms, vertexShader: vert, fragmentShader: frag, transparent: true, depthWrite: false, depthTest: false, }); materialRef.current = material; const scene = new THREE.Scene(); const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); const quad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material); scene.add(quad); let composer = null; if (enablePost) { composer = new EffectComposer(renderer); composerRef.current = composer; composer.addPass(new RenderPass(scene, camera)); const bloom = new BloomEffect({ intensity: 1.0, luminanceThreshold: bloomThreshold, luminanceSmoothing: bloomSmoothing }); bloom.blendMode.opacity.value = Math.max(0, bloomIntensity); bloomRef.current = bloom; const chroma = new ChromaticAberrationEffect({ offset: new THREE.Vector2(chromaticAberration, chromaticAberration), radialModulation: true, modulationOffset: 0.0, }); chromaRef.current = chroma; const effectPass = new EffectPass(camera, bloom, chroma); effectPass.renderToScreen = true; composer.addPass(effectPass); } const onResize = () => { renderer.setSize(container.clientWidth, container.clientHeight); material.uniforms.iResolution.value.set(container.clientWidth, container.clientHeight, renderer.getPixelRatio()); if (composerRef.current) composerRef.current.setSize(container.clientWidth, container.clientHeight); }; window.addEventListener('resize', onResize); let last = performance.now(); const tick = () => { const now = performance.now(); const dt = Math.max(0, Math.min(0.1, (now - last) / 1000)); last = now; lookCurrent.current.copy( smoothDampVec2(lookCurrent.current, lookTarget.current, lookVel.current, smoothTime, maxSpeed, dt) ); const tiltSm = smoothDampFloat(tiltCurrent.current, tiltTarget.current, { v: tiltVel.current }, smoothTime, maxSpeed, dt); tiltCurrent.current = tiltSm.value; tiltVel.current = tiltSm.v; const yawSm = smoothDampFloat(yawCurrent.current, yawTarget.current, { v: yawVel.current }, smoothTime, maxSpeed, dt); yawCurrent.current = yawSm.value; yawVel.current = yawSm.v; const skew = new THREE.Vector2(lookCurrent.current.x * skewScale, -lookCurrent.current.y * yBoost * skewScale); material.uniforms.uSkew.value.set(skew.x, skew.y); material.uniforms.uTilt.value = tiltCurrent.current * tiltScale; material.uniforms.uYaw.value = THREE.MathUtils.clamp(yawCurrent.current * yawScale, -0.6, 0.6); material.uniforms.iTime.value = now / 1000; renderer.clear(true, true, true); if (composerRef.current) composerRef.current.render(dt); else renderer.render(scene, camera); rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); window.removeEventListener('resize', onResize); material.dispose(); quad.geometry.dispose(); if (composerRef.current) { composerRef.current.dispose(); composerRef.current = null; } renderer.dispose(); renderer.forceContextLoss(); if (container.contains(renderer.domElement)) container.removeChild(renderer.domElement); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [sensitivity, lineThickness, linesColor, scanColor, scanOpacity, gridScale, lineStyle, lineJitter, scanDirection, enablePost, noiseIntensity, bloomIntensity, scanGlow, scanSoftness, scanPhaseTaper, scanDuration, scanDelay, bloomThreshold, bloomSmoothing, chromaticAberration]); // Prop updates without full remount useEffect(() => { const m = materialRef.current; if (!m) return; const u = m.uniforms; u.uLineThickness.value = lineThickness; u.uLinesColor.value.copy(srgbColor(linesColor)); u.uScanColor.value.copy(srgbColor(scanColor)); u.uGridScale.value = gridScale; u.uLineStyle.value = lineStyle === 'dashed' ? 1 : lineStyle === 'dotted' ? 2 : 0; u.uLineJitter.value = Math.max(0, Math.min(1, lineJitter || 0)); u.uBloomOpacity.value = Math.max(0, bloomIntensity); u.uNoise.value = Math.max(0, noiseIntensity); u.uScanGlow.value = scanGlow; u.uScanOpacity.value = Math.max(0, Math.min(1, scanOpacity)); u.uScanDirection.value = scanDirection === 'backward' ? 1 : scanDirection === 'pingpong' ? 2 : 0; u.uScanSoftness.value = scanSoftness; u.uPhaseTaper.value = scanPhaseTaper; u.uScanDuration.value = Math.max(0.05, scanDuration); u.uScanDelay.value = Math.max(0.0, scanDelay); if (bloomRef.current) { bloomRef.current.blendMode.opacity.value = Math.max(0, bloomIntensity); bloomRef.current.luminanceMaterial.threshold = bloomThreshold; bloomRef.current.luminanceMaterial.smoothing = bloomSmoothing; } if (chromaRef.current) chromaRef.current.offset.set(chromaticAberration, chromaticAberration); }, [lineThickness, linesColor, scanColor, gridScale, lineStyle, lineJitter, bloomIntensity, bloomThreshold, bloomSmoothing, chromaticAberration, noiseIntensity, scanGlow, scanOpacity, scanDirection, scanSoftness, scanPhaseTaper, scanDuration, scanDelay]); // Gyro useEffect(() => { if (!enableGyro) return; const handler = (e) => { if (uiFaceActive) return; const gamma = e.gamma ?? 0; const beta = e.beta ?? 0; lookTarget.current.set(THREE.MathUtils.clamp(gamma / 45, -1, 1), THREE.MathUtils.clamp(-beta / 30, -1, 1)); tiltTarget.current = THREE.MathUtils.degToRad(gamma) * 0.4; }; window.addEventListener('deviceorientation', handler); return () => window.removeEventListener('deviceorientation', handler); }, [enableGyro, uiFaceActive]); // Face-api models useEffect(() => { let canceled = false; const load = async () => { try { await Promise.all([ faceapi.nets.tinyFaceDetector.loadFromUri(modelsPath), faceapi.nets.faceLandmark68TinyNet.loadFromUri(modelsPath), ]); if (!canceled) setModelsReady(true); } catch { if (!canceled) setModelsReady(false); } }; load(); return () => { canceled = true; }; }, [modelsPath]); // Webcam face tracking useEffect(() => { let stop = false; let lastDetect = 0; const video = videoRef.current; const start = async () => { if (!enableWebcam || !modelsReady || !video) return; try { const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false }); video.srcObject = stream; await video.play(); } catch { return; } const opts = new faceapi.TinyFaceDetectorOptions({ inputSize: 320, scoreThreshold: 0.5 }); const detect = async (ts) => { if (stop) return; if (ts - lastDetect >= 33) { lastDetect = ts; try { const res = await faceapi.detectSingleFace(video, opts).withFaceLandmarks(true); if (res?.detection) { const { box } = res.detection; const vw = video.videoWidth || 1, vh = video.videoHeight || 1; const nx = (box.x + box.width * 0.5) / vw * 2 - 1; const ny = (box.y + box.height * 0.5) / vh * 2 - 1; medianPush(bufX.current, nx, 5); medianPush(bufY.current, ny, 5); const look = new THREE.Vector2(Math.tanh(median(bufX.current)), Math.tanh(median(bufY.current))); const faceSize = Math.min(1, Math.hypot(box.width / vw, box.height / vh)); lookTarget.current.copy(look.multiplyScalar(1 + depthResponse * (faceSize - 0.25))); const lc = centroid(res.landmarks.getLeftEye()); const rc = centroid(res.landmarks.getRightEye()); medianPush(bufT.current, Math.atan2(rc.y - lc.y, rc.x - lc.x), 5); tiltTarget.current = median(bufT.current); const nose = res.landmarks.getNose(); const tip = nose[nose.length - 1] || nose[Math.floor(nose.length / 2)]; const jaw = res.landmarks.getJawOutline(); const eyeDist = Math.hypot(rc.x - lc.x, rc.y - lc.y) + 1e-6; const yawSignal = Math.tanh(THREE.MathUtils.clamp((dist2(tip, jaw[13] || jaw[14]) - dist2(tip, jaw[3] || jaw[2])) / (eyeDist * 1.6), -1, 1)); medianPush(bufYaw.current, yawSignal, 5); yawTarget.current = median(bufYaw.current); setUiFaceActive(true); } else { setUiFaceActive(false); } } catch { setUiFaceActive(false); } } if ('requestVideoFrameCallback' in HTMLVideoElement.prototype) { video.requestVideoFrameCallback(() => detect(performance.now())); } else { requestAnimationFrame(detect); } }; requestAnimationFrame(detect); }; start(); return () => { stop = true; if (video) { const stream = video.srcObject; if (stream) stream.getTracks().forEach(t => t.stop()); video.pause(); video.srcObject = null; } }; }, [enableWebcam, modelsReady, depthResponse]); return (