mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-25 10:39:37 +00:00
website: hero wordmark lighting — organic sizes, cursor halo, eclipse dark mode
- Ball sizes now vary organically: smooth spatial value noise drifts size clusters across the word, white noise roughens per ball; the neighbor cap keeps every draw collision-safe - Radial halo billboard tracks the cursor BEHIND the text plane, so balls silhouette against the glow (additive in dark, soft accent wash in light) - Cursor point light switched to physical inverse-square falloff (decay 2): balls near the cursor are genuinely brighter; the light dims to 30% when the pointer leaves so the resting state stays moody - Dark mode is an eclipse rig: near-black lacquer balls (clearcoat physical material), almost no front light, cool key + counter rim from behind, and no environment map — scene.environment floods instanced materials at full strength regardless of envMapIntensity, which was washing the whole word to gray; light mode keeps the procedural room reflections
This commit is contained in:
@@ -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<THREE.InstancedMesh>(null);
|
||||
const lightRef = useRef<THREE.PointLight>(null);
|
||||
const lightPos = useRef(new THREE.Vector3(0, 120, 170));
|
||||
const haloRef = useRef<THREE.Mesh>(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 (
|
||||
<>
|
||||
<ambientLight intensity={dark ? 0.24 : 0.6} />
|
||||
<directionalLight
|
||||
position={[-260, 340, 420]}
|
||||
intensity={dark ? 0.35 : 0.8}
|
||||
/>
|
||||
{/* 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 && <StudioEnvironment />}
|
||||
|
||||
{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.
|
||||
<>
|
||||
<ambientLight intensity={0.05} />
|
||||
<directionalLight position={[-160, 340, -460]} intensity={2.2} color="#e9f1ff" />
|
||||
<directionalLight position={[430, -180, -380]} intensity={0.7} color="#7fa8e8" />
|
||||
<directionalLight position={[0, -60, 520]} intensity={0.06} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ambientLight intensity={0.55} />
|
||||
<directionalLight position={[-260, 340, 420]} intensity={0.75} />
|
||||
<directionalLight position={[180, 260, -420]} intensity={0.5} color="#eef3ff" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<pointLight
|
||||
ref={lightRef}
|
||||
position={[0, 120, 170]}
|
||||
intensity={dark ? 4.2 : 2.8}
|
||||
decay={0}
|
||||
color={dark ? "#dfe8ff" : "#ffffff"}
|
||||
position={[0, 120, 130]}
|
||||
intensity={dark ? 42000 : 30000}
|
||||
decay={2}
|
||||
distance={900}
|
||||
color={dark ? "#d6e4ff" : "#ffffff"}
|
||||
/>
|
||||
|
||||
<mesh ref={haloRef} position={[0, 120, -90]} material={haloMaterial}>
|
||||
<planeGeometry args={[720, 720]} />
|
||||
</mesh>
|
||||
|
||||
<instancedMesh
|
||||
ref={meshRef}
|
||||
args={[undefined, undefined, sim.n]}
|
||||
frustumCulled={false}
|
||||
>
|
||||
<sphereGeometry args={[1, 20, 14]} />
|
||||
<meshStandardMaterial roughness={0.26} metalness={0.08} />
|
||||
<sphereGeometry args={[1, 24, 16]} />
|
||||
{dark ? (
|
||||
<meshPhysicalMaterial
|
||||
roughness={0.42}
|
||||
metalness={0.5}
|
||||
clearcoat={0.9}
|
||||
clearcoatRoughness={0.22}
|
||||
envMapIntensity={0.05}
|
||||
/>
|
||||
) : (
|
||||
<meshStandardMaterial
|
||||
roughness={0.3}
|
||||
metalness={0.15}
|
||||
envMapIntensity={0.55}
|
||||
/>
|
||||
)}
|
||||
</instancedMesh>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user