// Three.js hero scene. A 3D knowledge graph with colored nodes + faint edges. // Rotates slowly, parallaxes on mouse, and the camera dollies + tilts as the // user scrolls down the landing page. window.HeroScene = function HeroScene({ density = 1, accent = '#7c7cff' }) { const mountRef = React.useRef(null); React.useEffect(() => { const THREE = window.THREE; const mount = mountRef.current; if (!THREE || !mount) return; const w = () => mount.clientWidth; const h = () => mount.clientHeight; const scene = new THREE.Scene(); scene.fog = new THREE.FogExp2(0x07070a, 0.04); const camera = new THREE.PerspectiveCamera(55, w() / h(), 0.1, 1000); camera.position.set(0, 0, 26); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.setSize(w(), h()); renderer.setClearColor(0x000000, 0); mount.appendChild(renderer.domElement); const root = new THREE.Group(); scene.add(root); // entity palette mirrors the financial graph legend const palette = [ new THREE.Color('#7c7cff'), // company new THREE.Color('#ff6d6d'), // person new THREE.Color('#ffd66d'), // event new THREE.Color('#ff9a5b'), // product new THREE.Color('#ff6dc0'), // regulation new THREE.Color('#5ed694'), // patent ]; // Weighted so blue dominates like the screenshot const weights = [0.62, 0.12, 0.06, 0.10, 0.06, 0.04]; function pickColor(r) { let acc = 0; for (let i = 0; i < weights.length; i++) { acc += weights[i]; if (r < acc) return palette[i]; } return palette[0]; } // ---- Nodes ---- const NODE_COUNT = Math.round(2200 * density); const positions = new Float32Array(NODE_COUNT * 3); const colors = new Float32Array(NODE_COUNT * 3); const sizes = new Float32Array(NODE_COUNT); const nodePos = []; // keep for edges for (let i = 0; i < NODE_COUNT; i++) { // dense core + sparse halo (like the screenshot) const isCore = Math.random() < 0.55; const r = isCore ? Math.pow(Math.random(), 0.6) * 6 : 6 + Math.pow(Math.random(), 1.3) * 12; const t = Math.random() * Math.PI * 2; const p = Math.acos(2 * Math.random() - 1); const x = r * Math.sin(p) * Math.cos(t); const y = r * Math.sin(p) * Math.sin(t); const z = r * Math.cos(p); positions[i * 3] = x; positions[i * 3 + 1] = y; positions[i * 3 + 2] = z; nodePos.push([x, y, z]); const c = pickColor(Math.random()); colors[i * 3] = c.r; colors[i * 3 + 1] = c.g; colors[i * 3 + 2] = c.b; sizes[i] = isCore ? 0.04 + Math.random() * 0.04 : 0.025 + Math.random() * 0.03; } const nodeGeom = new THREE.BufferGeometry(); nodeGeom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); nodeGeom.setAttribute('color', new THREE.BufferAttribute(colors, 3)); nodeGeom.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); // Round soft point sprite — drawn as a circle in the fragment shader const nodeMat = new THREE.ShaderMaterial({ uniforms: { uPx: { value: renderer.getPixelRatio() } }, vertexShader: ` attribute float size; varying vec3 vColor; uniform float uPx; void main() { vColor = color; vec4 mv = modelViewMatrix * vec4(position, 1.0); gl_PointSize = size * (320.0 / -mv.z) * uPx; gl_Position = projectionMatrix * mv; } `, fragmentShader: ` varying vec3 vColor; void main() { vec2 c = gl_PointCoord - 0.5; float d = length(c); float a = smoothstep(0.5, 0.15, d); if (a < 0.02) discard; // small inner brightness boost float core = smoothstep(0.35, 0.0, d); vec3 col = vColor + core * 0.45; gl_FragColor = vec4(col, a); } `, vertexColors: true, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, }); const points = new THREE.Points(nodeGeom, nodeMat); root.add(points); // ---- Edges ---- // Connect every Nth node to a few nearby neighbours for that faint web const EDGE_BUDGET = Math.round(900 * density); const edgePositions = []; const edgeColors = []; const baseEdge = new THREE.Color(0x4a4a66); for (let i = 0; i < EDGE_BUDGET; i++) { const a = Math.floor(Math.random() * NODE_COUNT); // find a moderately close neighbour let bestB = -1; let bestD = 999; for (let k = 0; k < 8; k++) { const b = Math.floor(Math.random() * NODE_COUNT); if (b === a) continue; const dx = nodePos[a][0] - nodePos[b][0]; const dy = nodePos[a][1] - nodePos[b][1]; const dz = nodePos[a][2] - nodePos[b][2]; const d = dx * dx + dy * dy + dz * dz; if (d < bestD && d > 0.4) { bestD = d; bestB = b; } } if (bestB < 0 || bestD > 18) continue; edgePositions.push(...nodePos[a], ...nodePos[bestB]); edgeColors.push(baseEdge.r, baseEdge.g, baseEdge.b, baseEdge.r, baseEdge.g, baseEdge.b); } const edgeGeom = new THREE.BufferGeometry(); edgeGeom.setAttribute('position', new THREE.Float32BufferAttribute(edgePositions, 3)); edgeGeom.setAttribute('color', new THREE.Float32BufferAttribute(edgeColors, 3)); const edgeMat = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.18, blending: THREE.AdditiveBlending, depthWrite: false, }); const lines = new THREE.LineSegments(edgeGeom, edgeMat); root.add(lines); // ---- Pointer parallax + scroll-driven camera ---- const mouse = { x: 0, y: 0, tx: 0, ty: 0 }; function onMove(e) { const rect = mount.getBoundingClientRect(); mouse.tx = ((e.clientX - rect.left) / rect.width - 0.5) * 2; mouse.ty = ((e.clientY - rect.top) / rect.height - 0.5) * 2; } window.addEventListener('mousemove', onMove, { passive: true }); let scrollT = 0; function onScroll() { const y = window.scrollY || 0; // softer, longer ramp so the scene keeps drifting gently through ALL // landing sections rather than slamming to the end after one viewport scrollT = Math.min(3.0, y / (window.innerHeight * 0.8)); } window.addEventListener('scroll', onScroll, { passive: true }); // ---- Animate ---- let raf; const start = performance.now(); function tick() { const t = (performance.now() - start) / 1000; // gentle pointer easing mouse.x += (mouse.tx - mouse.x) * 0.04; mouse.y += (mouse.ty - mouse.y) * 0.04; // slow base rotation that keeps going regardless of scroll root.rotation.y = t * 0.04 + mouse.x * 0.25 + scrollT * 0.3; root.rotation.x = Math.sin(t * 0.05) * 0.12 + mouse.y * 0.15 - scrollT * 0.08; // camera stays embedded in the cloud — dollies less, drifts more, // so the network keeps filling the frame across every section. const eased = scrollT < 1 ? scrollT : 1 + Math.sin((scrollT - 1) * 0.9) * 0.35; camera.position.z = 24 - eased * 5; camera.position.y = Math.sin(scrollT * 0.5) * 3.5; camera.position.x = Math.sin(scrollT * 0.35) * 2.5; camera.lookAt(0, Math.sin(scrollT * 0.3) * 0.6, 0); root.position.y = 0; renderer.render(scene, camera); raf = requestAnimationFrame(tick); } tick(); // ---- Resize ---- function onResize() { camera.aspect = w() / h(); camera.updateProjectionMatrix(); renderer.setSize(w(), h()); } const ro = new ResizeObserver(onResize); ro.observe(mount); return () => { cancelAnimationFrame(raf); window.removeEventListener('mousemove', onMove); window.removeEventListener('scroll', onScroll); ro.disconnect(); renderer.dispose(); nodeGeom.dispose(); edgeGeom.dispose(); nodeMat.dispose(); edgeMat.dispose(); if (mount.contains(renderer.domElement)) mount.removeChild(renderer.domElement); }; }, [density]); return
; };