import * as THREE from 'three'; import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js'; import { OutputPass } from 'three/examples/jsm/postprocessing/OutputPass.js'; // ============ DEVICE DETECTION ============ const isMobile = /Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || (navigator.maxTouchPoints > 1 && window.innerWidth < 1024); const gpuTier = (() => { const gl = document.createElement('canvas').getContext('webgl'); if (!gl) return 'low'; const ext = gl.getExtension('WEBGL_debug_renderer_info'); const gpu = ext ? gl.getParameter(ext.UNMASKED_RENDERER_WEBGL).toLowerCase() : ''; // Apple GPU = Apple Silicon (M1/M2/M3/M4) — these are high-tier if (/apple gpu|apple m/.test(gpu)) return 'high'; // Low-tier: old Intel integrated, software renderers, old mobile chips if (/swiftshader|llvmpipe|mali-4|adreno 3/.test(gpu)) return 'low'; if (/intel(?!.*(iris|uhd|arc))/.test(gpu)) return 'low'; // Mid-tier: mid-range mobile, Intel Iris/UHD, older discrete if (/mali-g[567]|adreno [45]|intel (iris|uhd)|geforce (mx|gt)|radeon (rx )?(5[0-4]|vega 8)/.test(gpu)) return 'mid'; // Everything else (RTX, RX 6000+, Arc, etc.) = high return 'high'; })(); const qualityPresets = { low: { pixelRatio: 1.0, marchSteps: 48, aoSteps: 2, dotSize: 6.0, dotGap: 3.0, scanlines: 0.55, bloomEnabled: false }, mid: { pixelRatio: 1.25, marchSteps: 64, aoSteps: 3, dotSize: 5.0, dotGap: 2.5, scanlines: 0.75, bloomEnabled: true }, high: { pixelRatio: 1.5, marchSteps: 80, aoSteps: 3, dotSize: 5.0, dotGap: 2.5, scanlines: 0.75, bloomEnabled: true }, }; let currentTier = isMobile ? 'low' : gpuTier; let quality = { ...qualityPresets[currentTier] }; // ============ SETTINGS ============ const settings = { dither: { enabled: true, dotSize: quality.dotSize, dotGap: quality.dotGap, brightness: 0.85, contrast: 0.60, threshold: 0.03, dotColor: [1.0, 1.0, 1.0], bgColor: [0.00784, 0.00784, 0.01176] }, crosshatch: { enabled: false, intensity: 0.95, angle: 0.4363 }, bloom: { enabled: quality.bloomEnabled, intensity: 0.55, size: 1.50 }, crt: { enabled: true, curvature: 0.0, scanlines: quality.scanlines, vignette: 2.00, chroma: 0.0 }, scene: { gooeyness: 1.20, speed: 0.85 } }; // ============ MOUSE TRACKING ============ const mouse = new THREE.Vector2(0, 0); let mouseInScene = false; let mousePressed = false; let mouseSphereRadius = 0.0; const mouseSphereTargetRadius = 0.55; const mouseSphereClickRadius = 0.95; const mouseWorld = new THREE.Vector3(0, 0, 0); const mouseWorldTarget = new THREE.Vector3(0, 0, 0); const mouseDamping = 0.15; // ============ THREE.JS SETUP ============ const scene = new THREE.Scene(); scene.background = new THREE.Color(0x020203); const crtFrame = document.getElementById('crt-frame'); const getSize = () => { return { width: window.innerWidth, height: window.innerHeight }; }; let size = getSize(); const camera = new THREE.PerspectiveCamera(60, size.width / size.height, 0.1, 100); camera.position.set(0, 0, 5); const renderer = new THREE.WebGLRenderer({ antialias: false, powerPreference: 'high-performance' }); renderer.setSize(size.width, size.height); renderer.setPixelRatio(Math.min(window.devicePixelRatio, quality.pixelRatio)); crtFrame.appendChild(renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = 0.05; controls.enableZoom = false; controls.enablePan = false; controls.enableRotate = false; controls.enabled = false; // prevent OrbitControls from capturing scroll/pointer events // ============ INPUT EVENTS ============ let pageVisible = true; // Render only while the hero canvas is actually on screen. When the chat view scrolls into // place the background pauses itself (below), freeing the GPU for the WebGPU model so decode // throughput isn't dragged down by the WebGL background. let heroOnScreen = true; const onPointerMove = (e) => { mouseInScene = true; const x = e.clientX ?? (e.touches && e.touches[0] ? e.touches[0].clientX : 0); const y = e.clientY ?? (e.touches && e.touches[0] ? e.touches[0].clientY : 0); mouse.x = (x / window.innerWidth) * 2 - 1; mouse.y = -(y / window.innerHeight) * 2 + 1; }; document.addEventListener('mousemove', onPointerMove, { passive: true }); document.addEventListener('touchmove', onPointerMove, { passive: true }); document.addEventListener('mouseenter', () => { mouseInScene = true; }, { passive: true }); document.addEventListener('mouseleave', () => { mouseInScene = false; }, { passive: true }); document.addEventListener('touchstart', (e) => { mouseInScene = true; mousePressed = true; onPointerMove(e); }, { passive: true }); document.addEventListener('touchend', () => { mousePressed = false; mouseInScene = false; }, { passive: true }); document.addEventListener('visibilitychange', () => { pageVisible = !document.hidden; if (document.hidden) mouseInScene = false; }); // Pause the WebGL background as soon as the chat view dominates the screen, so it never competes // with the WebGPU model for the GPU. Resume when scrolling back to the landing. Falls back to the // hero canvas's own visibility if there is no #chat section on the page. const chatSection = document.getElementById('chat'); if (chatSection) { new IntersectionObserver(([entry]) => { heroOnScreen = entry.intersectionRatio < 0.4; }, { threshold: [0, 0.4, 1] }).observe(chatSection); } else { new IntersectionObserver(([entry]) => { heroOnScreen = entry.isIntersecting; }, { threshold: 0 }).observe(crtFrame); } document.addEventListener('mousedown', () => { mousePressed = true; }, { passive: true }); document.addEventListener('mouseup', () => { mousePressed = false; }, { passive: true }); // ============ RAYMARCHING QUAD ============ const quadGeometry = new THREE.PlaneGeometry(2, 2); const quadMaterial = new THREE.ShaderMaterial({ uniforms: { uTime: { value: 0 }, uResolution: { value: new THREE.Vector2(size.width, size.height) }, uCameraPos: { value: camera.position.clone() }, uCameraTarget: { value: new THREE.Vector3(0, 0, 0) }, uPixelRatio: { value: Math.min(window.devicePixelRatio, 1.5) }, uGooeyness: { value: settings.scene.gooeyness }, uSpeed: { value: settings.scene.speed }, uMouseSpherePos: { value: new THREE.Vector3(0, 0, 0) }, uMouseSphereRadius: { value: 0.0 }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); } `, fragmentShader: ` precision highp float; #define MARCH_STEPS ${quality.marchSteps} #define AO_STEPS ${quality.aoSteps} uniform float uTime; uniform vec2 uResolution; uniform vec3 uCameraPos; uniform vec3 uCameraTarget; uniform float uPixelRatio; uniform float uGooeyness; uniform float uSpeed; uniform vec3 uMouseSpherePos; uniform float uMouseSphereRadius; varying vec2 vUv; float smin(float a, float b, float k) { float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0); return mix(b, a, h) - k * h * (1.0 - h); } float sdSphere(vec3 p, vec3 center, float radius) { return length(p - center) - radius; } float sceneCompound(vec3 p, float t, float k) { // Two large primary blobs float angle1 = t * 0.5; float angle2 = t * 0.5 + 3.14159; vec3 c1 = vec3( cos(angle1) * 2.4 + sin(t * 0.25) * 0.3, sin(angle1 * 0.6) * 0.8 + cos(t * 0.4) * 0.2, sin(angle1 * 0.35) * 0.6 ); vec3 c2 = vec3( cos(angle2) * 2.4 + sin(t * 0.3) * 0.3, sin(angle2 * 0.6) * 0.8 - cos(t * 0.35) * 0.2, sin(angle2 * 0.35) * 0.6 ); float s1 = sdSphere(p, c1, 1.2 + 0.07 * sin(t * 2.5)); float s2 = sdSphere(p, c2, 1.05 + 0.07 * cos(t * 2.0)); // Medium satellites vec3 c3 = c1 + vec3(sin(t * 1.8) * 0.9, cos(t * 2.2) * 0.9, sin(t * 1.5) * 0.6); vec3 c4 = c2 + vec3(-cos(t * 1.5) * 0.8, sin(t * 1.9) * 0.8, -cos(t * 1.7) * 0.5); float s3 = sdSphere(p, c3, 0.55); float s4 = sdSphere(p, c4, 0.5); // Free-floating blobs vec3 c5 = vec3(sin(t * 0.7) * 3.0, cos(t * 0.55) * 1.2, cos(t * 0.45) * 0.7); vec3 c6 = vec3(-cos(t * 0.65) * 2.8, sin(t * 0.75) * 1.0, sin(t * 0.5) * 0.8); float s5 = sdSphere(p, c5, 0.6); float s6 = sdSphere(p, c6, 0.55); float d = smin(s1, s2, k); d = smin(d, s3, k * 0.7); d = smin(d, s4, k * 0.7); d = smin(d, s5, k * 0.8); d = smin(d, s6, k * 0.8); return d; } float sceneSDF(vec3 p) { float t = uTime * uSpeed; float k = uGooeyness; float d = sceneCompound(p, t, k); if (uMouseSphereRadius > 0.001) { float ms = sdSphere(p, uMouseSpherePos, uMouseSphereRadius); d = smin(d, ms, k * 0.8); } return d; } vec3 calcNormal(vec3 p) { const float eps = 0.001; vec2 h = vec2(eps, 0.0); return normalize(vec3( sceneSDF(p + h.xyy) - sceneSDF(p - h.xyy), sceneSDF(p + h.yxy) - sceneSDF(p - h.yxy), sceneSDF(p + h.yyx) - sceneSDF(p - h.yyx) )); } float calcAO(vec3 pos, vec3 nor) { float occ = 0.0; float sca = 1.0; for (int i = 0; i < AO_STEPS; i++) { float h = 0.02 + 0.15 * float(i); float d = sceneSDF(pos + h * nor); occ += (h - d) * sca; sca *= 0.9; } return clamp(1.0 - 3.0 * occ, 0.0, 1.0); } float fresnel(vec3 viewDir, vec3 normal, float power) { return pow(1.0 - max(dot(viewDir, normal), 0.0), power); } // Fake directional shadow via downward AO probe float cheapShadow(vec3 pos, vec3 lightDir) { float d1 = sceneSDF(pos + lightDir * 0.15); float d2 = sceneSDF(pos + lightDir * 0.4); float d3 = sceneSDF(pos + lightDir * 0.8); return clamp(0.3 + 0.7 * smoothstep(0.0, 0.3, min(min(d1, d2), d3)), 0.0, 1.0); } mat3 setCamera(vec3 ro, vec3 ta, float cr) { vec3 cw = normalize(ta - ro); vec3 cp = vec3(sin(cr), cos(cr), 0.0); vec3 cu = normalize(cross(cw, cp)); vec3 cv = normalize(cross(cu, cw)); return mat3(cu, cv, cw); } void main() { vec2 fragCoord = vUv * uResolution; vec2 uv = (2.0 * fragCoord - uResolution) / uResolution.y; vec3 ro = uCameraPos; vec3 ta = uCameraTarget; mat3 ca = setCamera(ro, ta, 0.0); vec3 rd = ca * normalize(vec3(uv, 1.8)); float t = 0.0; float d; vec3 p; bool hit = false; for (int i = 0; i < MARCH_STEPS; i++) { p = ro + rd * t; d = sceneSDF(p); if (d < 0.002) { hit = true; break; } t += d * 0.9; if (t > 15.0) break; } vec3 col = vec3(0.02, 0.02, 0.04); col += vec3(0.03, 0.01, 0.06) * (1.0 - uv.y * 0.5); if (hit) { vec3 nor = calcNormal(p); vec3 viewDir = normalize(ro - p); vec3 lightPos1 = vec3(3.0, 4.0, 5.0); vec3 lightPos2 = vec3(-4.0, 2.0, -3.0); vec3 lightDir1 = normalize(lightPos1 - p); vec3 lightDir2 = normalize(lightPos2 - p); float diff1 = max(dot(nor, lightDir1), 0.0); float diff2 = max(dot(nor, lightDir2), 0.0); vec3 halfDir1 = normalize(lightDir1 + viewDir); vec3 halfDir2 = normalize(lightDir2 + viewDir); float spec1 = pow(max(dot(nor, halfDir1), 0.0), 64.0); float spec2 = pow(max(dot(nor, halfDir2), 0.0), 32.0); float sha1 = cheapShadow(p + nor * 0.01, lightDir1); float sha2 = cheapShadow(p + nor * 0.01, lightDir2); float ao = calcAO(p, nor); float fres = fresnel(viewDir, nor, 3.0); float sss = max(0.0, dot(viewDir, -lightDir1)) * 0.3; vec3 baseColor1 = vec3(0.8, 0.15, 0.3); vec3 baseColor2 = vec3(0.15, 0.4, 0.9); vec3 baseColor3 = vec3(0.95, 0.5, 0.1); float colorMix = sin(p.x * 1.5 + uTime * 0.5) * 0.5 + 0.5; float colorMix2 = cos(p.y * 2.0 - uTime * 0.3) * 0.5 + 0.5; vec3 baseColor = mix(baseColor1, baseColor2, colorMix); baseColor = mix(baseColor, baseColor3, colorMix2 * 0.3); vec3 diffuse = baseColor * (diff1 * sha1 * vec3(1.0, 0.95, 0.9) * 0.8 + diff2 * sha2 * vec3(0.4, 0.5, 0.9) * 0.4); vec3 specular = vec3(1.0, 0.95, 0.9) * spec1 * sha1 * 0.7 + vec3(0.5, 0.6, 1.0) * spec2 * sha2 * 0.3; vec3 ambient = baseColor * vec3(0.08, 0.06, 0.12) * ao; vec3 rim = mix(vec3(0.4, 0.6, 1.0), vec3(1.0, 0.4, 0.6), colorMix) * fres * 0.6; vec3 subsurface = baseColor * sss * vec3(1.0, 0.3, 0.2); col = ambient + diffuse + specular + rim + subsurface; float iridescence = fres * 0.4; vec3 iriColor = vec3( sin(dot(nor, vec3(1.0, 0.0, 0.0)) * 6.0 + uTime) * 0.5 + 0.5, sin(dot(nor, vec3(0.0, 1.0, 0.0)) * 6.0 + uTime * 1.3) * 0.5 + 0.5, sin(dot(nor, vec3(0.0, 0.0, 1.0)) * 6.0 + uTime * 0.7) * 0.5 + 0.5 ); col += iriColor * iridescence; vec3 ref = reflect(-viewDir, nor); float envRefl = smoothstep(-0.2, 1.0, ref.y) * 0.15; col += vec3(0.3, 0.4, 0.8) * envRefl * fres; } col = col / (col + vec3(1.0)); col = pow(col, vec3(1.0 / 2.2)); float vig = 1.0 - 0.3 * dot(uv * 0.5, uv * 0.5); col *= vig; gl_FragColor = vec4(col, 1.0); } `, depthWrite: false, depthTest: false }); const quad = new THREE.Mesh(quadGeometry, quadMaterial); quad.name = 'raymarchQuad'; quad.frustumCulled = false; const quadScene = new THREE.Scene(); const quadCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); quadScene.add(quad); // ============ DOT MATRIX SHADER ============ const DotMatrixShader = { uniforms: { tDiffuse: { value: null }, uResolution: { value: new THREE.Vector2(size.width, size.height) }, uDotSize: { value: 5.0 }, uDotGap: { value: 2.5 }, uBrightness: { value: 0.85 }, uContrast: { value: 0.60 }, uThreshold: { value: 0.03 }, uDotColor: { value: new THREE.Vector3(1.0, 1.0, 1.0) }, uBgColor: { value: new THREE.Vector3(0.00784, 0.00784, 0.01176) }, uCrossEnabled: { value: 0.0 }, uCrossIntensity: { value: 0.95 }, uCrossAngle: { value: 0.4363 }, uBloomEnabled: { value: 1.0 }, uBloomIntensity: { value: 0.55 }, uBloomSize: { value: 1.50 }, uCrtEnabled: { value: 1.0 }, uCrtCurvature: { value: 0.0 }, uCrtScanlines: { value: 0.75 }, uCrtVignette: { value: 2.00 }, uCrtChroma: { value: 0.0 }, uDitherEnabled: { value: 1.0 }, uTime: { value: 0 } }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position, 1.0); } `, fragmentShader: ` precision highp float; uniform sampler2D tDiffuse; uniform vec2 uResolution; uniform float uDotSize; uniform float uDotGap; uniform float uBrightness; uniform float uContrast; uniform float uThreshold; uniform vec3 uDotColor; uniform vec3 uBgColor; uniform float uCrossEnabled; uniform float uCrossIntensity; uniform float uCrossAngle; uniform float uBloomEnabled; uniform float uBloomIntensity; uniform float uBloomSize; uniform float uCrtEnabled; uniform float uCrtCurvature; uniform float uCrtScanlines; uniform float uCrtVignette; uniform float uCrtChroma; uniform float uDitherEnabled; uniform float uTime; varying vec2 vUv; vec2 crtDistort(vec2 uv, float k) { vec2 cc = uv - 0.5; float r2 = dot(cc, cc); float f = 1.0 + r2 * k * 0.01; return cc * f + 0.5; } void main() { vec2 uv = vUv; if (uCrtEnabled > 0.5) { uv = crtDistort(uv, uCrtCurvature); if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { gl_FragColor = vec4(0.0, 0.0, 0.0, 1.0); return; } } vec3 col; if (uCrtEnabled > 0.5 && uCrtChroma > 0.01) { vec2 dir = (uv - 0.5) * uCrtChroma * 0.002; col.r = texture2D(tDiffuse, uv + dir).r; col.g = texture2D(tDiffuse, uv).g; col.b = texture2D(tDiffuse, uv - dir).b; } else { col = texture2D(tDiffuse, uv).rgb; } if (uDitherEnabled < 0.5) { if (uCrtEnabled > 0.5 && uCrtScanlines > 0.001) { float scanline = sin(uv.y * uResolution.y * 0.8) * 0.5 + 0.5; col *= 1.0 - uCrtScanlines * (1.0 - scanline); } if (uCrtEnabled > 0.5 && uCrtVignette > 0.001) { vec2 vig = uv * (1.0 - uv); float vigMask = pow(vig.x * vig.y * 16.0, uCrtVignette * 0.3); col *= vigMask; } gl_FragColor = vec4(col, 1.0); return; } vec2 pixelCoord = uv * uResolution; float spacing = uDotSize + uDotGap; vec2 cell = floor(pixelCoord / spacing); vec2 cellCenter = (cell + 0.5) * spacing; vec2 sampleUV = cellCenter / uResolution; vec3 cellCol; if (uCrtEnabled > 0.5 && uCrtChroma > 0.01) { vec2 dir = (sampleUV - 0.5) * uCrtChroma * 0.002; cellCol.r = texture2D(tDiffuse, sampleUV + dir).r; cellCol.g = texture2D(tDiffuse, sampleUV).g; cellCol.b = texture2D(tDiffuse, sampleUV - dir).b; } else { cellCol = texture2D(tDiffuse, sampleUV).rgb; } float lum = dot(cellCol, vec3(0.299, 0.587, 0.114)); lum *= uBrightness; lum = (lum - 0.5) * (1.0 / uContrast) + 0.5; lum = clamp(lum, 0.0, 1.0); vec3 bgColor = uBgColor; if (lum < uThreshold) { vec3 result = bgColor; if (uCrtEnabled > 0.5 && uCrtScanlines > 0.001) { float scanline = sin(uv.y * uResolution.y * 0.8) * 0.5 + 0.5; result *= 1.0 - uCrtScanlines * (1.0 - scanline) * 0.3; } if (uCrtEnabled > 0.5 && uCrtVignette > 0.001) { vec2 vig = uv * (1.0 - uv); float vigMask = pow(vig.x * vig.y * 16.0, uCrtVignette * 0.3); result *= vigMask; } gl_FragColor = vec4(result, 1.0); return; } float maxRadius = uDotSize * 0.5; float minRadius = 0.4; float lumCurve = pow(lum, uContrast); float dotRadius = mix(minRadius, maxRadius, lumCurve); vec2 d = pixelCoord - cellCenter; vec2 absD = abs(d); float cornerRadius = mix(0.8, 0.2, lumCurve); float squareness = smoothstep(0.15, 0.7, lumCurve); float circleDist = length(d); float circleEdge = 1.0 - smoothstep(dotRadius - 0.5, dotRadius + 0.5, circleDist); vec2 qd = absD - vec2(dotRadius - cornerRadius); float rsDist = length(max(qd, 0.0)) + min(max(qd.x, qd.y), 0.0) - cornerRadius; float squareEdge = 1.0 - smoothstep(-0.5, 0.5, rsDist); float dotMask = mix(circleEdge, squareEdge, squareness); vec3 brightColor = uDotColor; vec3 midColor = uDotColor * 0.7; vec3 dimColor = uDotColor * 0.25; vec3 colNorm = cellCol / (max(max(cellCol.r, cellCol.g), cellCol.b) + 0.001); vec3 dotColor; if (lum > 0.65) { dotColor = mix(brightColor, brightColor * colNorm * 1.5, 0.2); dotColor *= 1.0 + (lum - 0.65) * 1.5; } else if (lum > 0.25) { float mt = (lum - 0.25) / 0.4; dotColor = mix(midColor, brightColor, mt); dotColor = mix(dotColor, dotColor * colNorm * 1.2, 0.15); } else { float st = lum / 0.25; dotColor = mix(dimColor * 0.5, dimColor, st); } float shadowCrosshatch = 0.0; if (uCrossEnabled > 0.5 && lum < 0.35) { float crossSpacing = spacing * 0.7; float ca = cos(uCrossAngle); float sa = sin(uCrossAngle); vec2 rotP = vec2(ca * pixelCoord.x - sa * pixelCoord.y, sa * pixelCoord.x + ca * pixelCoord.y); vec2 crossCell = floor(rotP / crossSpacing); vec2 crossCenter = (crossCell + 0.5) * crossSpacing; float crossDist = length(rotP - crossCenter); float shadowIntensity = smoothstep(0.35, 0.05, lum); float crossRadius = mix(0.3, maxRadius * 0.35, shadowIntensity); shadowCrosshatch = 1.0 - smoothstep(crossRadius - 0.4, crossRadius + 0.4, crossDist); shadowCrosshatch *= shadowIntensity * uCrossIntensity; } float bloomMask = 0.0; if (uBloomEnabled > 0.5 && lum > 0.6) { float bloomRadius = dotRadius * uBloomSize; float bloomDist = length(d); bloomMask = (1.0 - smoothstep(bloomRadius - 1.0, bloomRadius + 1.0, bloomDist)); bloomMask *= smoothstep(0.6, 1.0, lum) * uBloomIntensity * 0.5; } vec3 result = bgColor; vec3 crossColor = dimColor * 0.4; result = mix(result, crossColor, shadowCrosshatch); result += brightColor * bloomMask; result = mix(result, dotColor, dotMask); float innerMask = 0.0; if (squareness > 0.5) { vec2 qd2 = absD - vec2(dotRadius * 0.85 - cornerRadius); float rsDist2 = length(max(qd2, 0.0)) + min(max(qd2.x, qd2.y), 0.0) - cornerRadius; innerMask = 1.0 - smoothstep(-0.5, 0.5, rsDist2); } else { innerMask = 1.0 - smoothstep(dotRadius * 0.7 - 0.5, dotRadius * 0.7 + 0.5, circleDist); } float phosphorEdge = max(dotMask - innerMask, 0.0); result += brightColor * phosphorEdge * 0.15 * lum; if (uCrtEnabled > 0.5 && uCrtScanlines > 0.001) { float scanline = sin(uv.y * uResolution.y * 0.8) * 0.5 + 0.5; result *= 1.0 - uCrtScanlines * (1.0 - scanline); } if (uCrtEnabled > 0.5 && uCrtVignette > 0.001) { vec2 vig = uv * (1.0 - uv); float vigMask = pow(vig.x * vig.y * 16.0, uCrtVignette * 0.3); result *= vigMask; } if (uCrtEnabled > 0.5) { result = result; } gl_FragColor = vec4(result, 1.0); } ` }; // ============ POST PROCESSING ============ // Use a standard RenderPass for the quad scene — the raymarching resolution // is controlled by uResolution on the quad material (set to half-res), // while the composer and dot-matrix shader run at full native resolution. const composer = new EffectComposer(renderer); composer.setSize(size.width, size.height); const renderPass = new RenderPass(quadScene, quadCamera); composer.addPass(renderPass); const dotMatrixPass = new ShaderPass(DotMatrixShader); composer.addPass(dotMatrixPass); const outputPass = new OutputPass(); composer.addPass(outputPass); // Dot matrix runs at full resolution for crisp patterns dotMatrixPass.uniforms.uResolution.value.set(size.width, size.height); // ============ SCROLL PARALLAX ============ let scrollY = 0; let smoothScrollY = 0; window.addEventListener('scroll', () => { scrollY = window.scrollY; }, { passive: true }); // ============ ANIMATION LOOP ============ const clock = new THREE.Clock(); const raycaster = new THREE.Raycaster(); const _forward = new THREE.Vector3(); let resizeTimeout = null; // ============ FPS WATCHDOG — auto-downgrade if <30fps ============ const tierOrder = ['high', 'mid', 'low']; let fpsFrames = 0; let fpsStartTime = performance.now(); let fpsWatchdogActive = true; const FPS_SAMPLE_WINDOW = 2000; // 2 seconds const FPS_THRESHOLD = 30; function downgradeQuality() { const currentIndex = tierOrder.indexOf(currentTier); if (currentIndex >= tierOrder.length - 1) { // Already at lowest tier fpsWatchdogActive = false; return; } const nextTier = tierOrder[currentIndex + 1]; currentTier = nextTier; quality = { ...qualityPresets[currentTier] }; // Apply new quality — update pixel ratio const pr = Math.min(window.devicePixelRatio, quality.pixelRatio); renderer.setPixelRatio(pr); renderer.setSize(size.width, size.height); composer.setSize(size.width, size.height); // Update dot matrix uniforms dotMatrixPass.uniforms.uDotSize.value = quality.dotSize; dotMatrixPass.uniforms.uDotGap.value = quality.dotGap; dotMatrixPass.uniforms.uCrtScanlines.value = quality.scanlines; dotMatrixPass.uniforms.uBloomEnabled.value = quality.bloomEnabled ? 1.0 : 0.0; // Reset watchdog for another sample window fpsFrames = 0; fpsStartTime = performance.now(); // If we hit the lowest tier, stop watching if (currentIndex + 1 >= tierOrder.length - 1) { fpsWatchdogActive = false; } // Update tier display const tierEl = document.getElementById('settings-tier'); if (tierEl) tierEl.textContent = `GPU Tier: ${currentTier} (auto)`; } function animate() { // Skip rendering when the tab is hidden or the hero has scrolled off screen (see heroOnScreen). if (!pageVisible || !heroOnScreen) return; const dt = Math.min(clock.getDelta(), 0.05); // Clamp delta to avoid huge jumps const elapsed = clock.elapsedTime; controls.update(); const targetR = mouseInScene ? (mousePressed ? mouseSphereClickRadius : mouseSphereTargetRadius) : 0.0; const fadeSpeed = mouseInScene ? (mousePressed ? 10.0 : 6.0) : 3.0; const step = Math.min(1.0, fadeSpeed * dt); mouseSphereRadius += (targetR - mouseSphereRadius) * step; if (mouseSphereRadius < 0.005 && !mouseInScene) mouseSphereRadius = 0.0; raycaster.setFromCamera(mouse, camera); const rayDir = raycaster.ray.direction; const rayOrigin = raycaster.ray.origin; _forward.subVectors(controls.target, camera.position).normalize(); const dist = camera.position.distanceTo(controls.target); const t = dist / rayDir.dot(_forward); mouseWorldTarget.copy(rayOrigin).addScaledVector(rayDir, t); mouseWorld.lerp(mouseWorldTarget, mouseDamping); // Smooth scroll interpolation for parallax smoothScrollY += (scrollY - smoothScrollY) * 0.1; const vh = window.innerHeight; const scrollProgress = Math.min(smoothScrollY / vh, 1.0); // Parallax: shift camera subtly as user scrolls (3D depth effect) const baseZ = 5; const baseCamY = 0; camera.position.y = baseCamY + scrollProgress * 1.5; camera.position.z = baseZ + scrollProgress * 0.8; controls.target.y = scrollProgress * 0.8; quadMaterial.uniforms.uMouseSpherePos.value.copy(mouseWorld); quadMaterial.uniforms.uMouseSphereRadius.value = mouseSphereRadius; quadMaterial.uniforms.uTime.value = elapsed; quadMaterial.uniforms.uCameraPos.value.copy(camera.position); quadMaterial.uniforms.uCameraTarget.value.copy(controls.target); dotMatrixPass.uniforms.uTime.value = elapsed; // Composer renders the quad scene via RenderPass, then applies dot matrix + CRT composer.render(); // FPS watchdog — sample for 2s windows, downgrade if below threshold if (fpsWatchdogActive) { fpsFrames++; const elapsed_ms = performance.now() - fpsStartTime; if (elapsed_ms >= FPS_SAMPLE_WINDOW) { const avgFps = (fpsFrames / elapsed_ms) * 1000; if (avgFps < FPS_THRESHOLD) { downgradeQuality(); } else { // Performance is fine, stop watching fpsWatchdogActive = false; } } } } renderer.setAnimationLoop(animate); // ============ RESIZE (debounced — render loop never pauses) ============ function handleResize() { size = getSize(); camera.aspect = size.width / size.height; camera.updateProjectionMatrix(); const pr = Math.min(window.devicePixelRatio, quality.pixelRatio); renderer.setPixelRatio(pr); renderer.setSize(size.width, size.height); quadMaterial.uniforms.uResolution.value.set(size.width, size.height); quadMaterial.uniforms.uPixelRatio.value = pr; dotMatrixPass.uniforms.uResolution.value.set(size.width, size.height); composer.setSize(size.width, size.height); } window.addEventListener('resize', () => { clearTimeout(resizeTimeout); // Immediately update the canvas CSS size so it fills the window (no black gaps) // but defer the expensive framebuffer reallocation renderer.domElement.style.width = window.innerWidth + 'px'; renderer.domElement.style.height = window.innerHeight + 'px'; resizeTimeout = setTimeout(handleResize, 200); }, { passive: true }); // ============ DEFERRED INIT — pre-warm the shader after first paint ============ // Force one render so the first real frame doesn't stutter on shader compile. if ('requestIdleCallback' in window) { requestIdleCallback(() => composer.render(), { timeout: 1000 }); } else { setTimeout(() => composer.render(), 200); // Safari fallback }