diff --git a/frontend/apps/artcraft-website-next/src/components/landing/hero-wordmark.tsx b/frontend/apps/artcraft-website-next/src/components/landing/hero-wordmark.tsx index 88bd63d33f..875b2586a1 100644 --- a/frontend/apps/artcraft-website-next/src/components/landing/hero-wordmark.tsx +++ b/frontend/apps/artcraft-website-next/src/components/landing/hero-wordmark.tsx @@ -10,6 +10,7 @@ import { } from "react"; import { Canvas, useFrame, useThree } from "@react-three/fiber"; import * as THREE from "three"; +import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import { watchThemeColors, type ThemeColors } from "@/lib/theme-colors"; import { sampleWordmark, @@ -189,6 +190,44 @@ function FittedCamera() { return null; } +// Studio reflections without any network fetch: three's procedural room, +// prefiltered once per WebGL context. This is what gives the dark "car +// paint" balls something to mirror. +function StudioEnvironment() { + const gl = useThree((s) => s.gl); + const scene = useThree((s) => s.scene); + useEffect(() => { + const pmrem = new THREE.PMREMGenerator(gl); + const env = pmrem.fromScene(new RoomEnvironment(), 0.04).texture; + scene.environment = env; + return () => { + scene.environment = null; + env.dispose(); + pmrem.dispose(); + }; + }, [gl, scene]); + return null; +} + +const HALO_VERTEX = /* glsl */ ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; + +const HALO_FRAGMENT = /* glsl */ ` + varying vec2 vUv; + uniform vec3 uColor; + uniform float uAlpha; + void main() { + float d = distance(vUv, vec2(0.5)) * 2.0; + float a = pow(clamp(1.0 - d, 0.0, 1.0), 2.6); + gl_FragColor = vec4(uColor, a * uAlpha); + } +`; + function BallField({ sample, colors, @@ -200,7 +239,8 @@ function BallField({ }) { const meshRef = useRef(null); const lightRef = useRef(null); - const lightPos = useRef(new THREE.Vector3(0, 120, 170)); + const haloRef = useRef(null); + const lightPos = useRef(new THREE.Vector3(0, 120, 0)); const sim = useMemo(() => new WordmarkSim(sample), [sample]); const dummy = useMemo(() => new THREE.Object3D(), []); @@ -209,26 +249,52 @@ function BallField({ return c.r + c.g + c.b < 1.5; }, [colors.bg]); - // Per-ball colors: ink with slight tonal variety, plus sparse brand-accent - // balls scattered through the crowd. + // The halo sits behind the text plane, so the balls eclipse it: bodies go + // to silhouette against the glow while their rims catch the back light. + const haloMaterial = useMemo( + () => + new THREE.ShaderMaterial({ + vertexShader: HALO_VERTEX, + fragmentShader: HALO_FRAGMENT, + uniforms: { + uColor: { + value: new THREE.Color(dark ? "#a8c4ff" : colors.accent), + }, + uAlpha: { value: dark ? 0.5 : 0.14 }, + }, + transparent: true, + depthWrite: false, + blending: dark ? THREE.AdditiveBlending : THREE.NormalBlending, + }), + [dark, colors.accent], + ); + useEffect(() => () => haloMaterial.dispose(), [haloMaterial]); + + // Per-ball colors. Light theme: ink with slight tonal variety. Dark theme: + // near-black lacquer, so shape comes from reflections and the back rim + // instead of albedo. Both keep sparse brand-accent balls. useEffect(() => { const mesh = meshRef.current; if (!mesh) return; - const ink = new THREE.Color(colors.ink); const bg = new THREE.Color(colors.bg); + const ink = new THREE.Color(colors.ink); const accent = new THREE.Color(colors.accent); + const base = dark + ? bg.clone().lerp(ink, 0.10) + : ink.clone(); const c = new THREE.Color(); const rand = seeded(4242); for (let i = 0; i < sim.n; i++) { - if (rand() < 0.055) { + if (rand() < 0.05) { c.copy(accent); + if (dark) c.multiplyScalar(0.8); } else { - c.copy(ink).lerp(bg, rand() * 0.16); + c.copy(base).lerp(ink, rand() * (dark ? 0.08 : 0.16)); } mesh.setColorAt(i, c); } if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; - }, [colors, sim]); + }, [colors, sim, dark]); useFrame((state, delta) => { const mesh = meshRef.current; @@ -245,38 +311,98 @@ function BallField({ } mesh.instanceMatrix.needsUpdate = true; - // The light chases the cursor; unattended it wanders a slow Lissajous - // path so the relief never goes flat. + // The cursor light (and its halo) chase the pointer; unattended they + // wander a slow Lissajous path so the relief never goes flat. const t = state.clock.elapsedTime; const targetX = p.active ? p.x : Math.sin(t * 0.22) * 320; const targetY = p.active ? p.y : Math.cos(t * 0.17) * 130; const k = 1 - Math.exp(-6 * delta); lightPos.current.x += (targetX - lightPos.current.x) * k; lightPos.current.y += (targetY - lightPos.current.y) * k; - lightRef.current?.position.copy(lightPos.current); + lightRef.current?.position.set( + lightPos.current.x, + lightPos.current.y, + 130, + ); + haloRef.current?.position.set( + lightPos.current.x, + lightPos.current.y, + -90, + ); + + // At rest the word sits in near-eclipse; approaching with the cursor + // brings the light up to full. + const light = lightRef.current; + if (light) { + const full = dark ? 58000 : 30000; + const target = p.active ? full : full * 0.3; + light.intensity += (target - light.intensity) * k; + } }); return ( <> - - + {/* No environment in dark mode: three applies scene.environment at + effectively full strength here regardless of envMapIntensity, and + any env flood washes the eclipse out. The rim lights are the only + broad sources; the cursor light does the revealing. */} + {!dark && } + + {dark ? ( + // Eclipse rig: almost no front light. A cool key from behind-above + // rims the top edges, a faint counter-rim catches the lower-right, + // and the cursor light does the actual revealing. + <> + + + + + + ) : ( + <> + + + + + )} + + {/* Cursor light: physical inverse-square falloff, so nearby balls are + genuinely brighter than distant ones. Intensity is candela-scale + because the scene is measured in pixels. */} + + + + + - - + + {dark ? ( + + ) : ( + + )} ); diff --git a/frontend/apps/artcraft-website-next/src/components/landing/wordmark-sim.ts b/frontend/apps/artcraft-website-next/src/components/landing/wordmark-sim.ts index 6bbb986a17..26b01dfbd4 100644 --- a/frontend/apps/artcraft-website-next/src/components/landing/wordmark-sim.ts +++ b/frontend/apps/artcraft-website-next/src/components/landing/wordmark-sim.ts @@ -80,6 +80,17 @@ export function sampleWordmark(opts: { const rowH = spacing * (Math.SQRT2 / 2) * 1.22; // hex-ish row pitch const balls: Ball[] = []; + // Organic sizing: a smooth spatial noise drifts the ball size across the + // word (clusters of big and small), white noise roughens it per ball. The + // neighbor cap below keeps every draw collision-safe, so variance only + // ever shrinks balls — which is exactly what reads as hand-made. + const sizeNoise = makeValueNoise(41); + const desiredRadius = (x: number, y: number) => + spacing * + (0.24 + + 0.36 * Math.pow(sizeNoise(x / 130, y / 130), 1.15) + + rand() * 0.16); + for (let layer = 0; layer < layers; layer++) { const z = (layer - (layers - 1) / 2) * spacing * 0.92; // Alternate layers shift half a cell both ways so depth neighbors nest @@ -96,7 +107,7 @@ export function sampleWordmark(opts: { x: jx, y: jy, z: z + (rand() - 0.5) * jitter * 2, - r: spacing * (0.42 + rand() * 0.2), // desired; capped below + r: desiredRadius(jx, jy), // desired; capped below }); } } @@ -146,10 +157,31 @@ function capRadiiToNeighbors(balls: Ball[], spacing: number) { } for (let i = 0; i < balls.length; i++) { const fit = nearest[i] * 0.5 - 0.4; - balls[i].r = Math.max(spacing * 0.22, Math.min(balls[i].r, fit)); + balls[i].r = Math.max(spacing * 0.15, Math.min(balls[i].r, fit)); } } +// Deterministic bilinear value noise, 0..1 — smooth variation across space. +function makeValueNoise(seed: number): (x: number, y: number) => number { + const hash = (ix: number, iy: number) => { + let h = ix * 374761393 + iy * 668265263 + seed * 2246822519; + h = (h ^ (h >>> 13)) * 1274126177; + return ((h ^ (h >>> 16)) >>> 0) / 4294967295; + }; + const smooth = (t: number) => t * t * (3 - 2 * t); + return (x, y) => { + const ix = Math.floor(x); + const iy = Math.floor(y); + const fx = smooth(x - ix); + const fy = smooth(y - iy); + const a = hash(ix, iy); + const b = hash(ix + 1, iy); + const c = hash(ix, iy + 1); + const d = hash(ix + 1, iy + 1); + return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy; + }; +} + function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => {