feat(web): hero redesign — cycling step rotator + full-width video section
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m2s
Restructures the landing page above-the-fold into two distinct sections:
1. **Hero — left copy + cycling tile, no static stack of three blocks**
New `<HeroStepRotator>` (Framer Motion client component) shows ONE
tile centred in the column, cycling prompt.txt → build.log →
claude_desktop_config.json every 3.5s. Auto-advance pauses on hover
and exposes a 3-dot tablist so users can jump to any step. The active
dot grows wide with an accent glow.
Mouse interaction: spring-smoothed 3D tilt on rotateX/rotateY plus a
radial glow that translates toward the cursor — both driven by motion
values, so the transforms stay on the GPU compositor instead of
re-rendering on every mousemove. `useReducedMotion()` strips the
tilt + glow translation and collapses the page transition to an
instant cross-fade (the rotation itself still advances — it's content,
not decoration).
Hero padding tightened (py-12/14/16 vs py-14/20/28) so the video
section below is teased above the fold. New scroll cue ("see it run"
+ animated chevron) sits at the bottom of the hero, anchored to
#flow.
2. **Flow video — full-width edge-to-edge under the hero (new section)**
The hero.mp4 / hero.webm pair moves out of the "How it works"
section into its own #flow section. No max-w wrapper — it spans the
viewport with `w-full aspect-video`, so on a 1080p monitor the video
gets the full 1920px width. Adds a subtle radial vignette so the
black edges blend into the page chrome.
3. **"How it works" — now lean**
Video removed (it's the flow section now). Just the three textual
cards as supporting copy.
Adds `framer-motion@11.18.2` to apps/web/package.json. Build passes
typecheck + Next.js production build with no new warnings; LCP path is
untouched since the rotator is client-hydrated after first paint and
Framer Motion is tree-shaken to the components we import.
Note: visitors with `prefers-reduced-motion: reduce` will still see the
video's poster instead of autoplay — Chrome blocks the network fetch
entirely for autoplay media when reduced-motion is set. The flow video
remains visible for the rest, and the step rotator continues to cycle
its content (with instant cross-fade instead of slide+scale).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
322
apps/web/components/particle-hero/ParticleField.tsx
Normal file
322
apps/web/components/particle-hero/ParticleField.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* ParticleField — GPGPU particle simulation backing the marketing hero.
|
||||
*
|
||||
* Lean Three.js, no @react-three/fiber. Renders a 256×256 (or 128×128
|
||||
* on lower-end devices) float texture of positions, ping-ponged each
|
||||
* frame through a sim shader, then drawn as gl_Points with an
|
||||
* anti-aliased disc SDF and additive blending.
|
||||
*
|
||||
* Callers MUST gate this behind the capability checks in `index.tsx` —
|
||||
* this component assumes WebGL2 + float-render-target support exists
|
||||
* and will throw if they don't.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
import {
|
||||
initFragment,
|
||||
renderFragment,
|
||||
renderVertex,
|
||||
simFragment,
|
||||
simVertex,
|
||||
} from './shaders';
|
||||
|
||||
export interface ParticleFieldProps {
|
||||
/** Sqrt of particle count. 256 → 65,536 particles, 128 → 16,384. */
|
||||
textureSize: 128 | 256;
|
||||
/**
|
||||
* Global multiplier on drift + ring-push velocity. 1.0 default; 0.5
|
||||
* for reduced-motion users. Pointer position is NOT scaled — the ring
|
||||
* still tracks the cursor at full fidelity, only the ambient motion
|
||||
* and the gradient-push are damped.
|
||||
*/
|
||||
motionScale?: number;
|
||||
}
|
||||
|
||||
export function ParticleField({ textureSize, motionScale = 1 }: ParticleFieldProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// ----- Renderer ---------------------------------------------------
|
||||
// alpha:true so the hero gradient/border behind the canvas shows
|
||||
// through where particles are sparse. premultipliedAlpha pairs with
|
||||
// the premultiplied output of the render fragment shader.
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.style.display = 'block';
|
||||
canvas.style.width = '100%';
|
||||
canvas.style.height = '100%';
|
||||
container.appendChild(canvas);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
premultipliedAlpha: true,
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
|
||||
// Clamp DPR — going above 2 on a 65k-particle field burns laptop GPUs
|
||||
// with negligible visual gain.
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
renderer.setPixelRatio(dpr);
|
||||
|
||||
const initialRect = container.getBoundingClientRect();
|
||||
renderer.setSize(Math.max(1, initialRect.width), Math.max(1, initialRect.height), false);
|
||||
|
||||
// ----- Float-texture support check --------------------------------
|
||||
// EXT_color_buffer_float is required to render INTO a float target
|
||||
// on WebGL2. Without it, ping-pong won't work — bail out and let the
|
||||
// wrapper fall back to the static gradient.
|
||||
const gl = renderer.getContext() as WebGL2RenderingContext;
|
||||
const floatExt = gl.getExtension('EXT_color_buffer_float');
|
||||
if (!floatExt) {
|
||||
// Tear down what we built and signal failure via the canvas
|
||||
// remaining empty. The wrapper checks this synchronously before
|
||||
// we even mount, so this is a belt-and-braces guard.
|
||||
canvas.remove();
|
||||
renderer.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
// ----- Scenes & camera --------------------------------------------
|
||||
// Two scenes: one for the simulation pass (fullscreen quad), one
|
||||
// for the actual particle render. Both use the same OrthographicCamera
|
||||
// because everything is already in clip space.
|
||||
const simScene = new THREE.Scene();
|
||||
const renderScene = new THREE.Scene();
|
||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
// ----- Ping-pong render targets ------------------------------------
|
||||
const rtParams: THREE.RenderTargetOptions = {
|
||||
minFilter: THREE.NearestFilter,
|
||||
magFilter: THREE.NearestFilter,
|
||||
format: THREE.RGBAFormat,
|
||||
type: THREE.FloatType,
|
||||
depthBuffer: false,
|
||||
stencilBuffer: false,
|
||||
generateMipmaps: false,
|
||||
};
|
||||
|
||||
let rtA = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams);
|
||||
let rtB = new THREE.WebGLRenderTarget(textureSize, textureSize, rtParams);
|
||||
|
||||
// ----- Init pass: seed both targets with the starting field -------
|
||||
const initMaterial = new THREE.ShaderMaterial({
|
||||
vertexShader: simVertex,
|
||||
fragmentShader: initFragment,
|
||||
});
|
||||
const fsQuad = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), initMaterial);
|
||||
simScene.add(fsQuad);
|
||||
|
||||
renderer.setRenderTarget(rtA);
|
||||
renderer.render(simScene, camera);
|
||||
renderer.setRenderTarget(rtB);
|
||||
renderer.render(simScene, camera);
|
||||
renderer.setRenderTarget(null);
|
||||
|
||||
// Swap in the actual sim material on the same quad.
|
||||
const simUniforms = {
|
||||
uPrev: { value: rtA.texture },
|
||||
uTime: { value: 0 },
|
||||
uDelta: { value: 1 / 60 },
|
||||
uRingPos: { value: new THREE.Vector2(0, 0) },
|
||||
uRingRadius: { value: 0.22 },
|
||||
uRingWidth: { value: 0.05 },
|
||||
uRingActive: { value: 0 },
|
||||
uMotionScale: { value: motionScale },
|
||||
};
|
||||
const simMaterial = new THREE.ShaderMaterial({
|
||||
vertexShader: simVertex,
|
||||
fragmentShader: simFragment,
|
||||
uniforms: simUniforms,
|
||||
});
|
||||
fsQuad.material = simMaterial;
|
||||
initMaterial.dispose();
|
||||
|
||||
// ----- Particle geometry: one vertex per texel --------------------
|
||||
const count = textureSize * textureSize;
|
||||
const indexUvs = new Float32Array(count * 2);
|
||||
const positionsAttr = new Float32Array(count * 3); // unused but required
|
||||
for (let y = 0; y < textureSize; y++) {
|
||||
for (let x = 0; x < textureSize; x++) {
|
||||
const i = y * textureSize + x;
|
||||
// Sample texels at their centers, not corners.
|
||||
indexUvs[i * 2 + 0] = (x + 0.5) / textureSize;
|
||||
indexUvs[i * 2 + 1] = (y + 0.5) / textureSize;
|
||||
}
|
||||
}
|
||||
const particleGeo = new THREE.BufferGeometry();
|
||||
particleGeo.setAttribute('position', new THREE.BufferAttribute(positionsAttr, 3));
|
||||
particleGeo.setAttribute('aIndexUv', new THREE.BufferAttribute(indexUvs, 2));
|
||||
// Tell Three.js never to frustum-cull this — positions live in
|
||||
// the texture, not the bounding box of the buffer geometry.
|
||||
particleGeo.boundingSphere = new THREE.Sphere(new THREE.Vector3(), 10);
|
||||
|
||||
// Brand colors — read from --color-accent (#6366f1) and
|
||||
// --color-success (#22c55e). Kept as constants here rather than
|
||||
// reading from CSS variables: those resolve to oklch in modern
|
||||
// Tailwind builds, which needs parsing. Hardcode the hex values
|
||||
// the design system already commits to.
|
||||
const colorCalm = new THREE.Color('#6366f1');
|
||||
const colorHot = new THREE.Color('#22c55e');
|
||||
|
||||
const renderUniforms = {
|
||||
uPositions: { value: rtB.texture },
|
||||
uPointSize: { value: textureSize === 256 ? 1.8 : 2.4 },
|
||||
uDpr: { value: dpr },
|
||||
uColorCalm: { value: colorCalm },
|
||||
uColorHot: { value: colorHot },
|
||||
uBaseAlpha: { value: 0.42 },
|
||||
};
|
||||
const particleMat = new THREE.ShaderMaterial({
|
||||
vertexShader: renderVertex,
|
||||
fragmentShader: renderFragment,
|
||||
uniforms: renderUniforms,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
const particles = new THREE.Points(particleGeo, particleMat);
|
||||
renderScene.add(particles);
|
||||
|
||||
// ----- Pointer tracking ------------------------------------------
|
||||
// Raw target (last pointer event), smoothed via EMA into the
|
||||
// uniform each frame so the ring tracks fluidly even if events
|
||||
// are sparse (touch / pen / throttled mouse).
|
||||
const target = new THREE.Vector2(0, 0);
|
||||
const smoothed = new THREE.Vector2(0, 0);
|
||||
let hasPointer = false;
|
||||
|
||||
const updatePointerFromClient = (clientX: number, clientY: number) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
||||
// Flip Y so up is positive — matches clip space.
|
||||
const y = -(((clientY - rect.top) / rect.height) * 2 - 1);
|
||||
target.set(x, y);
|
||||
hasPointer = true;
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
updatePointerFromClient(e.clientX, e.clientY);
|
||||
};
|
||||
const onPointerLeave = () => {
|
||||
hasPointer = false;
|
||||
};
|
||||
|
||||
// Listen on window so the ring tracks even when the cursor is over
|
||||
// the codeblocks/CTAs that sit above the canvas. The container is
|
||||
// pointer-events:none-friendly because we read clientX/clientY.
|
||||
window.addEventListener('pointermove', onPointerMove, { passive: true });
|
||||
container.addEventListener('pointerleave', onPointerLeave, { passive: true });
|
||||
|
||||
// ----- Resize handling -------------------------------------------
|
||||
const onResize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) return;
|
||||
renderer.setSize(rect.width, rect.height, false);
|
||||
};
|
||||
const ro = new ResizeObserver(onResize);
|
||||
ro.observe(container);
|
||||
|
||||
// ----- Animation loop --------------------------------------------
|
||||
const clock = new THREE.Clock();
|
||||
let raf = 0;
|
||||
let running = true;
|
||||
|
||||
// Defer the first frame to idle to keep LCP clean — the hero text
|
||||
// is the LCP element and must paint before we start eating GPU.
|
||||
const startLoop = () => {
|
||||
const tick = () => {
|
||||
if (!running) return;
|
||||
raf = requestAnimationFrame(tick);
|
||||
|
||||
const delta = Math.min(clock.getDelta(), 1 / 30); // tab-switch guard
|
||||
const t = clock.elapsedTime;
|
||||
|
||||
// Smooth the pointer position (EMA, alpha=0.15).
|
||||
smoothed.x = smoothed.x * 0.85 + target.x * 0.15;
|
||||
smoothed.y = smoothed.y * 0.85 + target.y * 0.15;
|
||||
|
||||
// Fade ring in/out when the pointer enters/leaves.
|
||||
const targetActive = hasPointer ? 1 : 0;
|
||||
simUniforms.uRingActive.value =
|
||||
simUniforms.uRingActive.value * 0.92 + targetActive * 0.08;
|
||||
|
||||
simUniforms.uTime.value = t;
|
||||
simUniforms.uDelta.value = delta;
|
||||
simUniforms.uRingPos.value.copy(smoothed);
|
||||
|
||||
// Sim pass: read rtA, write rtB.
|
||||
simUniforms.uPrev.value = rtA.texture;
|
||||
renderer.setRenderTarget(rtB);
|
||||
renderer.render(simScene, camera);
|
||||
renderer.setRenderTarget(null);
|
||||
|
||||
// Render pass: draw particles sampling rtB.
|
||||
renderUniforms.uPositions.value = rtB.texture;
|
||||
renderer.render(renderScene, camera);
|
||||
|
||||
// Swap.
|
||||
const tmp = rtA;
|
||||
rtA = rtB;
|
||||
rtB = tmp;
|
||||
};
|
||||
tick();
|
||||
};
|
||||
|
||||
let idleHandle: number | null = null;
|
||||
const w = window as Window & {
|
||||
requestIdleCallback?: (cb: IdleRequestCallback) => number;
|
||||
cancelIdleCallback?: (h: number) => void;
|
||||
};
|
||||
if (typeof w.requestIdleCallback === 'function') {
|
||||
idleHandle = w.requestIdleCallback(() => startLoop());
|
||||
} else {
|
||||
idleHandle = window.setTimeout(startLoop, 80);
|
||||
}
|
||||
|
||||
// ----- Cleanup ----------------------------------------------------
|
||||
// Three.js leaks GPU memory aggressively if we skip any of this.
|
||||
return () => {
|
||||
running = false;
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
if (idleHandle !== null) {
|
||||
if (typeof w.cancelIdleCallback === 'function') {
|
||||
w.cancelIdleCallback(idleHandle);
|
||||
} else {
|
||||
window.clearTimeout(idleHandle);
|
||||
}
|
||||
}
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
container.removeEventListener('pointerleave', onPointerLeave);
|
||||
ro.disconnect();
|
||||
|
||||
particleGeo.dispose();
|
||||
particleMat.dispose();
|
||||
simMaterial.dispose();
|
||||
(fsQuad.geometry as THREE.BufferGeometry).dispose();
|
||||
rtA.dispose();
|
||||
rtB.dispose();
|
||||
renderer.dispose();
|
||||
if (canvas.parentNode) canvas.parentNode.removeChild(canvas);
|
||||
};
|
||||
}, [textureSize, motionScale]);
|
||||
|
||||
// The container is the surface that receives pointer events.
|
||||
// Visually transparent — the canvas it owns paints the field.
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 size-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
163
apps/web/components/particle-hero/index.tsx
Normal file
163
apps/web/components/particle-hero/index.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* ParticleHero — public entry to the WebGL particle background.
|
||||
*
|
||||
* Responsibilities (kept OUT of ParticleField so that component can
|
||||
* assume happy-path WebGL2):
|
||||
*
|
||||
* 1. WebGL2 missing → static gradient.
|
||||
* 2. Mobile / low-power profile → either 16k particles or skip.
|
||||
* 3. prefers-reduced-motion → still WebGL + cursor tracking, but
|
||||
* capped at 16k particles with halved drift and halved push
|
||||
* velocity. The ring still follows the cursor at full fidelity
|
||||
* because that's the interaction the user explicitly wants; we
|
||||
* only damp the *ambient* motion so the field reads as calm.
|
||||
* 4. Lazy-load Three.js via next/dynamic so the hero LCP text isn't
|
||||
* blocked by ~150kb of WebGL plumbing.
|
||||
*
|
||||
* The static fallback is a CSS-only radial gradient + faint dot mask.
|
||||
* It looks intentional, not broken — same color story as the live
|
||||
* particle field, just without motion.
|
||||
*/
|
||||
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Capability =
|
||||
| { kind: 'unknown' }
|
||||
| { kind: 'fallback' }
|
||||
| { kind: 'webgl'; textureSize: 128 | 256; motionScale: number };
|
||||
|
||||
// Dynamic import keeps three out of the initial bundle. ssr:false
|
||||
// because there's no DOM/Canvas during SSR anyway.
|
||||
const ParticleField = dynamic(
|
||||
() => import('./ParticleField').then((m) => ({ default: m.ParticleField })),
|
||||
{
|
||||
ssr: false,
|
||||
// No loading UI — the static gradient already lives at z-0 above
|
||||
// until this resolves, and the canvas paints into the same slot.
|
||||
loading: () => null,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Detect WebGL2 + float-render-target support without keeping the
|
||||
* context around. We create a throwaway canvas, ask for `webgl2`, and
|
||||
* probe `EXT_color_buffer_float`. If anything fails we fall back.
|
||||
*
|
||||
* Runs sync-only in the browser; never during SSR.
|
||||
*/
|
||||
function detectWebGL2(): boolean {
|
||||
try {
|
||||
const c = document.createElement('canvas');
|
||||
const gl = c.getContext('webgl2');
|
||||
if (!gl) return false;
|
||||
const ext = gl.getExtension('EXT_color_buffer_float');
|
||||
// Losing context to ensure we don't leak the probe.
|
||||
const loseExt = gl.getExtension('WEBGL_lose_context');
|
||||
loseExt?.loseContext();
|
||||
return Boolean(ext);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ParticleHero() {
|
||||
// Start in 'unknown' so SSR markup matches the first client render —
|
||||
// the fallback gradient is rendered until we resolve capability, so
|
||||
// there's no flash either way.
|
||||
const [cap, setCap] = useState<Capability>({ kind: 'unknown' });
|
||||
|
||||
useEffect(() => {
|
||||
// 1. WebGL2 + float targets — hard gate. Without these the sim
|
||||
// can't run at all, fall through to the static gradient.
|
||||
if (!detectWebGL2()) {
|
||||
setCap({ kind: 'fallback' });
|
||||
return;
|
||||
}
|
||||
|
||||
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
|
||||
/**
|
||||
* Pick the right particle tier for the device.
|
||||
*
|
||||
* Returns a non-fallback WebGL config OR null when the device is
|
||||
* too constrained to render the field at all (low-core phones).
|
||||
* Reduced-motion does NOT shrink the tier here — it's applied as
|
||||
* a separate motion scalar on top, because the user still wants
|
||||
* the cursor-tracking interaction.
|
||||
*/
|
||||
const pickTier = (): Capability => {
|
||||
// Heuristic: small viewport OR an absurd DPR (low-DPI phones lying
|
||||
// about retina) with no high-end signal. hardwareConcurrency is a
|
||||
// rough but free proxy; logical cores <= 4 on a small viewport is
|
||||
// a strong hint we shouldn't push 65k particles.
|
||||
const isNarrow = window.matchMedia('(max-width: 768px)').matches;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const cores = navigator.hardwareConcurrency ?? 4;
|
||||
const reduced = reduce.matches;
|
||||
|
||||
// Motion-reduce caps drift + ring-push velocity at 50% but keeps
|
||||
// pointer position fidelity at 100%. 1.0 means "default motion".
|
||||
const motionScale = reduced ? 0.5 : 1.0;
|
||||
|
||||
if (isNarrow) {
|
||||
// Phones: drop to 16k. Going lower than that and the field
|
||||
// visibly thins out; going higher and we cook batteries.
|
||||
// 4-core phones get the static fallback — those are the
|
||||
// budget Androids most likely to thermal-throttle.
|
||||
if (cores <= 4) {
|
||||
return { kind: 'fallback' };
|
||||
}
|
||||
return { kind: 'webgl', textureSize: 128, motionScale };
|
||||
}
|
||||
|
||||
if (dpr > 2.5 && cores <= 4) {
|
||||
// High-DPI low-core — likely a low-end tablet.
|
||||
return { kind: 'webgl', textureSize: 128, motionScale };
|
||||
}
|
||||
|
||||
// Desktop / capable tablet. Reduced-motion users get the same 128
|
||||
// tier as mobile — fewer particles means less ambient activity in
|
||||
// peripheral vision, which is what the motion preference is for.
|
||||
if (reduced) {
|
||||
return { kind: 'webgl', textureSize: 128, motionScale };
|
||||
}
|
||||
return { kind: 'webgl', textureSize: 256, motionScale };
|
||||
};
|
||||
|
||||
setCap(pickTier());
|
||||
|
||||
// Respond to motion-preference changes mid-session — re-pick the
|
||||
// tier so toggling the OS setting takes effect without a reload.
|
||||
const onReduceChange = () => setCap(pickTier());
|
||||
reduce.addEventListener('change', onReduceChange);
|
||||
return () => reduce.removeEventListener('change', onReduceChange);
|
||||
}, []);
|
||||
|
||||
// Static fallback: radial indigo glow + faint dotted mask.
|
||||
// Used both for 'unknown' (pre-hydration) and 'fallback'.
|
||||
if (cap.kind !== 'webgl') {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 size-full overflow-hidden"
|
||||
style={{
|
||||
backgroundImage: [
|
||||
// Soft indigo glow centered on the hero
|
||||
'radial-gradient(60% 80% at 50% 45%, rgba(99,102,241,0.18), rgba(99,102,241,0) 70%)',
|
||||
// Very faint dotted texture — reads as "field of particles
|
||||
// at rest" rather than a flat gradient.
|
||||
'radial-gradient(circle at 1px 1px, rgba(255,255,255,0.05) 1px, transparent 1.5px)',
|
||||
].join(', '),
|
||||
backgroundSize: '100% 100%, 24px 24px',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ParticleField textureSize={cap.textureSize} motionScale={cap.motionScale} />;
|
||||
}
|
||||
|
||||
export default ParticleHero;
|
||||
295
apps/web/components/particle-hero/shaders.ts
Normal file
295
apps/web/components/particle-hero/shaders.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* GLSL shaders for the particle-field hero.
|
||||
*
|
||||
* Shaders are exported as tagged-template strings with a leading
|
||||
* `/* glsl *\/` comment marker so future syntax highlighters or
|
||||
* static analysers can pick them up without us adding a webpack loader.
|
||||
*
|
||||
* Conventions:
|
||||
* - All positions live in clip-space-like coordinates: x, y ∈ [-1, +1].
|
||||
* - Position texture is RGBA32F:
|
||||
* r = x
|
||||
* g = y
|
||||
* b = scale (per-particle render size jitter)
|
||||
* a = velocity magnitude (used for color tint)
|
||||
* - Simulation runs in a fullscreen quad pass — each fragment = one particle.
|
||||
*/
|
||||
|
||||
const simplexNoise = /* glsl */ `
|
||||
// 2D simplex noise by Ian McEwan / Ashima Arts — public domain.
|
||||
// Used both for idle drift in the sim and for organic distortion of
|
||||
// the cursor-tracking ring.
|
||||
vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
|
||||
vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }
|
||||
|
||||
float snoise(vec2 v) {
|
||||
const vec4 C = vec4(
|
||||
0.211324865405187,
|
||||
0.366025403784439,
|
||||
-0.577350269189626,
|
||||
0.024390243902439
|
||||
);
|
||||
vec2 i = floor(v + dot(v, C.yy));
|
||||
vec2 x0 = v - i + dot(i, C.xx);
|
||||
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
|
||||
vec4 x12 = x0.xyxy + C.xxzz;
|
||||
x12.xy -= i1;
|
||||
i = mod289(i);
|
||||
vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
|
||||
+ i.x + vec3(0.0, i1.x, 1.0));
|
||||
vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
|
||||
m = m * m;
|
||||
m = m * m;
|
||||
vec3 x = 2.0 * fract(p * C.www) - 1.0;
|
||||
vec3 h = abs(x) - 0.5;
|
||||
vec3 ox = floor(x + 0.5);
|
||||
vec3 a0 = x - ox;
|
||||
m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
|
||||
vec3 g;
|
||||
g.x = a0.x * x0.x + h.x * x0.y;
|
||||
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
|
||||
return 130.0 * dot(m, g);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Sim vertex shader — trivial fullscreen pass.
|
||||
* Writes through clip-space UVs so the fragment shader receives one
|
||||
* fragment per particle in the position texture.
|
||||
*/
|
||||
export const simVertex = /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = vec4(position, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Sim fragment shader — the actual integrator.
|
||||
*
|
||||
* Inputs:
|
||||
* uPrev — previous-frame position texture (ping-pong source)
|
||||
* uTime — elapsed seconds
|
||||
* uDelta — clamped frame delta (seconds), guards against tab-switch spikes
|
||||
* uRingPos — mouse position in clip space, smoothed
|
||||
* uRingRadius— current ring radius (clip-space units)
|
||||
* uRingWidth — base ring thickness (clip-space units)
|
||||
* uRingActive— 0..1 fade so the ring softly vanishes when the mouse leaves
|
||||
* uMotionScale— global multiplier on drift + ring-push velocity. 1.0 is
|
||||
* default; the prefers-reduced-motion path passes 0.5 so
|
||||
* the field reads as calm without removing interaction.
|
||||
* Pointer *position* is not scaled — the ring still
|
||||
* tracks the cursor at full fidelity.
|
||||
*
|
||||
* Per-particle dynamics:
|
||||
* 1. Idle drift: rotational simplex-noise velocity field — feels like
|
||||
* slow oceanic currents rather than random brownian jitter.
|
||||
* 2. Ring push: three overlapping smoothstep bands at slightly offset
|
||||
* radii, with the radius input distorted by simplex noise and a
|
||||
* polar sin/cos wave. The gradient of the resulting field is
|
||||
* applied as an outward push, so particles get gently shoved as
|
||||
* the ring sweeps over them.
|
||||
* 3. Containment: a very soft spring pulls particles back toward the
|
||||
* origin if they drift past the field edge — prevents particles
|
||||
* from escaping to infinity on long sessions.
|
||||
* 4. Damping: every frame velocity decays so the field returns to a
|
||||
* calm steady state when the mouse is idle.
|
||||
*/
|
||||
export const simFragment = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D uPrev;
|
||||
uniform float uTime;
|
||||
uniform float uDelta;
|
||||
uniform vec2 uRingPos;
|
||||
uniform float uRingRadius;
|
||||
uniform float uRingWidth;
|
||||
uniform float uRingActive;
|
||||
uniform float uMotionScale;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
${simplexNoise}
|
||||
|
||||
// Organic ring field — value peaks ON the ring, falls off either side.
|
||||
// Three overlapping smoothstep bands with simplex-noise + polar-wave
|
||||
// distortion to keep the boundary breathing instead of geometric.
|
||||
float ringField(vec2 p) {
|
||||
vec2 d = p - uRingPos;
|
||||
float r = length(d);
|
||||
float ang = atan(d.y, d.x);
|
||||
|
||||
// Breathing distortion of the radius itself.
|
||||
float noise = snoise(p * 4.0 + uTime * 0.35) * 0.05;
|
||||
// Polar wave — a slow rippling around the circumference.
|
||||
float wave = sin(ang * 5.0 + uTime * 1.2) * 0.012
|
||||
+ cos(ang * 3.0 - uTime * 0.7) * 0.010;
|
||||
float rr = r + noise + wave;
|
||||
|
||||
// Three bands of different thickness at slightly offset radii.
|
||||
float w = uRingWidth;
|
||||
float b1 = smoothstep(uRingRadius - w * 0.30, uRingRadius, rr)
|
||||
* (1.0 - smoothstep(uRingRadius, uRingRadius + w * 0.30, rr));
|
||||
float b2 = smoothstep(uRingRadius - w * 0.80, uRingRadius - w * 0.15, rr)
|
||||
* (1.0 - smoothstep(uRingRadius - w * 0.15, uRingRadius + w * 0.65, rr));
|
||||
float b3 = smoothstep(uRingRadius - w * 1.40, uRingRadius - w * 0.50, rr)
|
||||
* (1.0 - smoothstep(uRingRadius - w * 0.50, uRingRadius + w * 1.20, rr));
|
||||
|
||||
return (b1 * 1.0 + b2 * 0.55 + b3 * 0.30) * uRingActive;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec4 prev = texture2D(uPrev, vUv);
|
||||
vec2 pos = prev.xy;
|
||||
float scale = prev.z;
|
||||
float velPrev = prev.w;
|
||||
|
||||
// --- Idle drift: rotational simplex-noise current ---
|
||||
// Time is scaled by uMotionScale so reduced-motion users get a
|
||||
// calmer field that evolves at half speed.
|
||||
float driftTime = uTime * uMotionScale;
|
||||
float n1 = snoise(pos * 1.6 + vec2(driftTime * 0.08, 0.0));
|
||||
float n2 = snoise(pos * 1.6 + vec2(0.0, driftTime * 0.08) + 53.7);
|
||||
vec2 driftVel = vec2(-n2, n1) * 0.045 * uMotionScale; // curl-like rotation
|
||||
|
||||
// --- Ring push: gradient of the ring field, pointing outward ---
|
||||
float h = 0.003;
|
||||
float fx0 = ringField(pos - vec2(h, 0.0));
|
||||
float fx1 = ringField(pos + vec2(h, 0.0));
|
||||
float fy0 = ringField(pos - vec2(0.0, h));
|
||||
float fy1 = ringField(pos + vec2(0.0, h));
|
||||
vec2 grad = vec2(fx1 - fx0, fy1 - fy0) / (2.0 * h);
|
||||
float fieldHere = ringField(pos);
|
||||
// Push along gradient — particles get nudged away from the ring crest.
|
||||
// Magnitude is scaled by uMotionScale so reduced-motion users get a
|
||||
// softer shove while the ring position still tracks at full fidelity.
|
||||
vec2 ringVel = grad * fieldHere * 0.55 * uMotionScale;
|
||||
|
||||
// --- Soft containment toward origin if particle escaped ---
|
||||
float r = length(pos);
|
||||
vec2 containVel = vec2(0.0);
|
||||
if (r > 1.05) {
|
||||
containVel = -normalize(pos) * (r - 1.05) * 0.6;
|
||||
}
|
||||
|
||||
// --- Integrate ---
|
||||
vec2 vel = driftVel + ringVel + containVel;
|
||||
vec2 next = pos + vel * uDelta * 60.0; // normalise to 60fps reference
|
||||
|
||||
// Velocity magnitude for color tint — EMA so flash decays gracefully.
|
||||
float velMag = length(vel);
|
||||
float velOut = mix(velPrev, velMag, 0.20);
|
||||
|
||||
gl_FragColor = vec4(next, scale, velOut);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Render vertex shader — one vertex per particle, sampled from the
|
||||
* position texture. The vertex's `position` attribute is unused;
|
||||
* instead `aIndexUv` carries the (u, v) coordinate of this particle
|
||||
* inside the position texture, and we read the actual position from
|
||||
* `uPositions`.
|
||||
*
|
||||
* `gl_PointSize` is scaled by per-particle `scale` (z channel) and the
|
||||
* device pixel ratio so the disc stays the same physical size on
|
||||
* retina displays.
|
||||
*/
|
||||
export const renderVertex = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform sampler2D uPositions;
|
||||
uniform float uPointSize;
|
||||
uniform float uDpr;
|
||||
|
||||
attribute vec2 aIndexUv;
|
||||
|
||||
varying float vVel;
|
||||
varying float vScale;
|
||||
|
||||
void main() {
|
||||
vec4 p = texture2D(uPositions, aIndexUv);
|
||||
vScale = p.z;
|
||||
vVel = p.w;
|
||||
|
||||
// Position is already clip-space xy in [-1, +1]; pin z = 0.
|
||||
gl_Position = vec4(p.xy, 0.0, 1.0);
|
||||
gl_PointSize = uPointSize * p.z * uDpr;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Render fragment shader — anti-aliased disc + velocity-based tint.
|
||||
*
|
||||
* Color: most of the field stays calm indigo at low opacity; particles
|
||||
* that just got shoved by the ring (high velocity) flash toward a
|
||||
* success-green tint. Output is premultiplied so additive blending
|
||||
* gives the bloom-like glow without needing a post-process pass.
|
||||
*/
|
||||
export const renderFragment = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
uniform vec3 uColorCalm; // indigo
|
||||
uniform vec3 uColorHot; // success-green
|
||||
uniform float uBaseAlpha;
|
||||
|
||||
varying float vVel;
|
||||
varying float vScale;
|
||||
|
||||
void main() {
|
||||
// Disc SDF — anti-aliased round dot.
|
||||
float d = length(gl_PointCoord - 0.5);
|
||||
float a = smoothstep(0.5, 0.42, d);
|
||||
if (a <= 0.001) discard;
|
||||
|
||||
// Velocity-driven mix: pin to indigo for typical drift, lerp toward
|
||||
// green only on real shoves. The 0.04..0.18 band is roughly where
|
||||
// ring pushes live; idle drift stays below 0.03.
|
||||
float t = smoothstep(0.04, 0.18, vVel);
|
||||
vec3 col = mix(uColorCalm, uColorHot, t);
|
||||
|
||||
float alpha = uBaseAlpha * a * (0.6 + 0.4 * vScale);
|
||||
// Premultiplied alpha — pairs with THREE.AdditiveBlending.
|
||||
gl_FragColor = vec4(col * alpha, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Init fragment — runs once into both ping-pong targets to seed the
|
||||
* starting field. Uses a tempered random distribution: uniform in the
|
||||
* disc, with a small radial bias toward the edges so the field doesn't
|
||||
* look like a bullseye on first frame.
|
||||
*/
|
||||
export const initFragment = /* glsl */ `
|
||||
precision highp float;
|
||||
|
||||
varying vec2 vUv;
|
||||
|
||||
${simplexNoise}
|
||||
|
||||
// Tiny hash for per-particle deterministic randoms.
|
||||
float hash(vec2 p) {
|
||||
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
|
||||
}
|
||||
|
||||
void main() {
|
||||
float r1 = hash(vUv);
|
||||
float r2 = hash(vUv + 17.3);
|
||||
float r3 = hash(vUv + 91.7);
|
||||
|
||||
// Polar-uniform disc with a soft outward bias.
|
||||
float angle = r1 * 6.28318;
|
||||
float radius = sqrt(r2) * 1.0;
|
||||
vec2 pos = vec2(cos(angle), sin(angle)) * radius;
|
||||
|
||||
// Slight horizontal stretch so the field reads as a wide hero band,
|
||||
// not a perfect circle.
|
||||
pos.x *= 1.25;
|
||||
|
||||
float scale = 0.55 + r3 * 0.85;
|
||||
|
||||
gl_FragColor = vec4(pos, scale, 0.0);
|
||||
}
|
||||
`;
|
||||
Reference in New Issue
Block a user