diff --git a/src/app/globals.css b/src/app/globals.css index 908d077..76c1d1f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -79,14 +79,19 @@ body { position: absolute; display: block; border-radius: 50%; - filter: blur(82px); mix-blend-mode: multiply; - background: radial-gradient(circle at 50% 50%, var(--accent), transparent 66%); + /* Softness lives in the gradient falloff itself — a `filter: blur()` here + forces the GPU to re-filter a ~52vw texture every animation frame. */ + background: radial-gradient( + circle at 50% 50%, + var(--accent), + color-mix(in oklab, var(--accent) 45%, transparent) 34%, + transparent 72% + ); will-change: transform; } .dark .aurora b { mix-blend-mode: screen; - filter: blur(72px); } .aurora .a1 { width: 52vw; @@ -124,11 +129,19 @@ body { inset: 0; z-index: 0; pointer-events: none; + overflow: hidden; + mask-image: radial-gradient(120% 100% at 50% 0%, #000 30%, transparent 78%); +} +/* Grid lives on an oversized child so drift can animate `transform` (composited) + instead of `background-position` (repaints every frame). */ +.blueprint::before { + content: ""; + position: absolute; + inset: -46px; background-image: linear-gradient(var(--grid-line) 1px, transparent 1px), linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); background-size: 46px 46px; - mask-image: radial-gradient(120% 100% at 50% 0%, #000 30%, transparent 78%); } .hero-net { position: absolute; @@ -149,9 +162,13 @@ body { position: absolute; display: block; border-radius: 50%; - filter: blur(90px); mix-blend-mode: multiply; - background: radial-gradient(circle at 50% 50%, var(--accent), transparent 64%); + background: radial-gradient( + circle at 50% 50%, + var(--accent), + color-mix(in oklab, var(--accent) 45%, transparent) 32%, + transparent 70% + ); will-change: transform; } .dark .section-glow b { @@ -196,7 +213,7 @@ body { .aurora .a3 { animation: bloom-3 15s ease-in-out infinite alternate; } - .blueprint { + .blueprint::before { animation: grid-drift 36s linear infinite; } } @@ -226,9 +243,7 @@ body { } @keyframes grid-drift { to { - background-position: - 46px 46px, - 46px 46px; + transform: translate(46px, 46px); } } diff --git a/src/components/hero-net.tsx b/src/components/hero-net.tsx index 250225e..9853bc2 100644 --- a/src/components/hero-net.tsx +++ b/src/components/hero-net.tsx @@ -35,6 +35,8 @@ export function HeroNet() { let W = 0; let H = 0; + let heroLeft = 0; + let heroTopDoc = 0; let nodes: Node[] = []; let rgb: [number, number, number] = [31, 157, 84]; let raf: number | null = null; @@ -51,6 +53,8 @@ export function HeroNet() { const r = hero.getBoundingClientRect(); W = Math.max(1, r.width); H = Math.max(1, r.height); + heroLeft = r.left; + heroTopDoc = r.top + window.scrollY; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); canvas.style.width = `${W}px`; @@ -70,8 +74,31 @@ export function HeroNet() { } }; + // Segments are grouped into a few alpha buckets so each frame issues one + // stroke() per bucket instead of one per link (~300 at full density). + const BUCKETS = 8; + const linkBuckets: number[][] = Array.from({ length: BUCKETS }, () => []); + + const bucketFor = (t: number) => Math.min(BUCKETS - 1, Math.floor(t * BUCKETS)); + + const strokeBuckets = (maxAlpha: number) => { + for (let k = 0; k < BUCKETS; k++) { + const seg = linkBuckets[k]; + if (seg.length === 0) continue; + ctx.strokeStyle = rgba(((k + 0.5) / BUCKETS) * maxAlpha); + ctx.beginPath(); + for (let s = 0; s < seg.length; s += 4) { + ctx.moveTo(seg[s], seg[s + 1]); + ctx.lineTo(seg[s + 2], seg[s + 3]); + } + ctx.stroke(); + seg.length = 0; + } + }; + const draw = () => { ctx.clearRect(0, 0, W, H); + ctx.lineWidth = 1; for (let i = 0; i < nodes.length; i++) { const a = nodes[i]; @@ -81,16 +108,11 @@ export function HeroNet() { const dy = a.y - b.y; const d2 = dx * dx + dy * dy; if (d2 < LINK * LINK) { - const d = Math.sqrt(d2); - ctx.strokeStyle = rgba((1 - d / LINK) * 0.22); - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(a.x, a.y); - ctx.lineTo(b.x, b.y); - ctx.stroke(); + linkBuckets[bucketFor(1 - Math.sqrt(d2) / LINK)].push(a.x, a.y, b.x, b.y); } } } + strokeBuckets(0.22); if (mouse.on) { for (const a of nodes) { @@ -98,25 +120,22 @@ export function HeroNet() { const dy = a.y - mouse.y; const d2 = dx * dx + dy * dy; if (d2 < MOUSE_LINK * MOUSE_LINK) { - const d = Math.sqrt(d2); - ctx.strokeStyle = rgba((1 - d / MOUSE_LINK) * 0.5); - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(a.x, a.y); - ctx.lineTo(mouse.x, mouse.y); - ctx.stroke(); - a.vx += (dx > 0 ? -1 : 1) * 0.0009 * (1 - d / MOUSE_LINK); - a.vy += (dy > 0 ? -1 : 1) * 0.0009 * (1 - d / MOUSE_LINK); + const t = 1 - Math.sqrt(d2) / MOUSE_LINK; + linkBuckets[bucketFor(t)].push(a.x, a.y, mouse.x, mouse.y); + a.vx += (dx > 0 ? -1 : 1) * 0.0009 * t; + a.vy += (dy > 0 ? -1 : 1) * 0.0009 * t; } } + strokeBuckets(0.5); } ctx.fillStyle = rgba(0.55); + ctx.beginPath(); for (const a of nodes) { - ctx.beginPath(); + ctx.moveTo(a.x + a.r, a.y); ctx.arc(a.x, a.y, a.r, 0, Math.PI * 2); - ctx.fill(); } + ctx.fill(); }; const step = () => { @@ -155,10 +174,11 @@ export function HeroNet() { draw(); }, 150); }; + // Uses offsets cached in build() — hero sits at a fixed document position, + // so a getBoundingClientRect() here would force layout on every move. const onMove = (e: PointerEvent) => { - const r = hero.getBoundingClientRect(); - mouse.x = e.clientX - r.left; - mouse.y = e.clientY - r.top; + mouse.x = e.clientX - heroLeft; + mouse.y = e.clientY - (heroTopDoc - window.scrollY); mouse.on = true; }; const onLeave = () => {