/** * PaperScene — the hero's 3D moment. * * A single sheet of paper floating in soft light, slowly drifting. The paper * is not a plain plane — it has faint ruled lines and a stamp mark drawn onto * a canvas texture, so at rest it reads as "a document" rather than "a card." * * Interaction: * - Mouse position parallaxes the paper's rotation (gentle, ~8°). * - When `state === "extracting"`, the sheet dips forward and a soft glow * appears — signals "processing" without a spinner. * - When `state === "extracted"`, the sheet tilts up and the corner folds * over, revealing an ink-green underside — signals "done." * * Rendering strategy: * - Zero external models. Everything is procedural — a plane + a small * `ExtrudeGeometry` for the folded corner, textured with a Canvas the * component draws once on mount. This keeps bundle size tiny. */ import { Canvas, useFrame } from "@react-three/fiber"; import { Environment } from "@react-three/drei"; import { useEffect, useMemo, useRef } from "react"; import type { Group, Mesh } from "three"; import * as THREE from "three"; export type PaperState = "idle" | "extracting" | "extracted"; interface Props { state: PaperState; } export function PaperScene({ state }: Props) { return (
{/* alpha:true + skipping color attach makes the canvas transparent */} {/* Warm vignette layered on top so the sheet reads against soft light */}
); } /* ------------------------------------------------------------------------ */ function Sheet({ state }: { state: PaperState }) { const group = useRef(null!); // Mouse position lives in a ref, not state, so pointer movement doesn't // force React re-renders at ~60Hz. `useFrame` reads it directly on the next // GPU tick, so parallax stays live without touching the render loop. const mouse = useRef({ x: 0, y: 0 }); // Track the mouse across the whole window — the parallax reads better when // it's tied to page position, not just the canvas. useEffect(() => { const onMove = (e: MouseEvent) => { mouse.current.x = (e.clientX / window.innerWidth) * 2 - 1; mouse.current.y = -(e.clientY / window.innerHeight) * 2 + 1; }; window.addEventListener("mousemove", onMove, { passive: true }); return () => window.removeEventListener("mousemove", onMove); }, []); const texture = useMemo(() => paperTexture(), []); useFrame((clock, delta) => { if (!group.current) return; const t = clock.clock.elapsedTime; // Base slow drift (idle motion). const driftY = Math.sin(t * 0.5) * 0.08; const driftRotX = Math.sin(t * 0.4) * 0.05; const driftRotZ = Math.cos(t * 0.3) * 0.03; // Parallax offset from mouse (ref-based — no React re-renders). const paraX = mouse.current.x * 0.14; const paraY = mouse.current.y * 0.08; // Targets vary by state. const targetRotX = state === "extracted" ? -0.35 : driftRotX + paraY * 0.8; const targetRotY = state === "extracted" ? -0.15 : paraX * 0.9; const targetRotZ = state === "extracting" ? 0.02 : driftRotZ; const targetY = state === "extracting" ? -0.15 : driftY; const targetScale = state === "extracting" ? 0.97 : 1; // Ease each channel toward target. const k = 1 - Math.pow(0.001, delta); group.current.rotation.x += (targetRotX - group.current.rotation.x) * k; group.current.rotation.y += (targetRotY - group.current.rotation.y) * k; group.current.rotation.z += (targetRotZ - group.current.rotation.z) * k; group.current.position.y += (targetY - group.current.position.y) * k; const s = group.current.scale.x + (targetScale - group.current.scale.x) * k; group.current.scale.set(s, s, s); }); return ( {/* Soft cast shadow beneath the sheet — a squashed dark plane. */} {/* Main sheet — a slightly wider-than-tall rectangle. Uses MeshBasicMaterial so the texture (which already contains its own baked-in shading + gradient) reads at full brightness. Standard PBR shading was dimming the printed text — user flagged this. */} {/* Folded corner reveal — only visible when extracted. */} {/* Glow puck under the sheet during extraction */} ); } /* -- folded corner --------------------------------------------------------- */ function FoldedCorner({ active }: { active: boolean }) { const meshRef = useRef(null!); useFrame((_, delta) => { if (!meshRef.current) return; const target = active ? 1 : 0; const k = 1 - Math.pow(0.001, delta); const cur = meshRef.current.userData.progress ?? 0; const next = cur + (target - cur) * k; meshRef.current.userData.progress = next; meshRef.current.scale.setScalar(0.001 + next * 1); (meshRef.current.material as THREE.MeshStandardMaterial).opacity = next; }); return ( ); } /* -- glow puck ------------------------------------------------------------- */ function Glow({ active }: { active: boolean }) { const meshRef = useRef(null!); useFrame((clock, delta) => { if (!meshRef.current) return; const target = active ? 1 : 0; const k = 1 - Math.pow(0.001, delta); const mat = meshRef.current.material as THREE.MeshBasicMaterial; const cur = mat.opacity; mat.opacity = cur + (target * 0.6 - cur) * k; const scale = 1 + Math.sin(clock.clock.elapsedTime * 2) * 0.06 * target; meshRef.current.scale.setScalar(scale); }); return ( ); } /* -- procedural texture ---------------------------------------------------- */ /** * Paints a paper-like canvas: warm cream base, faint ruled lines, a subtle * stamp mark, and a "TOTAL" label near the bottom. Just enough visual noise * that the sheet reads as a document rather than a blank plane. */ function paperTexture(): THREE.CanvasTexture { // Draw at 2× so the sheet stays sharp when the camera is close. // `ctx.scale` lets us keep all layout coordinates in the original 512×680 // logical space — every font-size and offset below is unchanged. const SCALE = 2; const LOGICAL_W = 512; const LOGICAL_H = 680; const c = document.createElement("canvas"); c.width = LOGICAL_W * SCALE; c.height = LOGICAL_H * SCALE; const ctx = c.getContext("2d")!; ctx.scale(SCALE, SCALE); // From here on, treat the drawing surface as if it were LOGICAL_W × LOGICAL_H. // Local aliases keep the layout math readable. const cw = LOGICAL_W; const ch = LOGICAL_H; // Base paper — warm cream with a subtle gradient const grad = ctx.createLinearGradient(0, 0, 0, ch); grad.addColorStop(0, "#f8f2e2"); grad.addColorStop(1, "#efe8d0"); ctx.fillStyle = grad; ctx.fillRect(0, 0, cw, ch); // Speckle grain for (let i = 0; i < 2200; i++) { ctx.fillStyle = `rgba(120,100,60,${Math.random() * 0.04})`; ctx.fillRect( Math.random() * cw, Math.random() * ch, Math.random() * 1.6, Math.random() * 1.6 ); } // Header rule ctx.strokeStyle = "rgba(16,32,59,0.75)"; ctx.lineWidth = 1.2; ctx.beginPath(); ctx.moveTo(40, 96); ctx.lineTo(cw - 40, 96); ctx.stroke(); // Vendor/merchant label ctx.fillStyle = "#10203b"; ctx.font = 'italic 34px "Instrument Serif", serif'; ctx.fillText("Invoice", 40, 76); // Ruled body lines (light) ctx.strokeStyle = "rgba(16,32,59,0.09)"; ctx.lineWidth = 1; for (let y = 130; y < ch - 120; y += 26) { ctx.beginPath(); ctx.moveTo(40, y); ctx.lineTo(cw - 40, y); ctx.stroke(); } // Fake line items ctx.fillStyle = "rgba(16,32,59,0.55)"; ctx.font = '13px "JetBrains Mono", monospace'; const rows: [string, string][] = [ ["Software license", "$1,240.00"], ["Support (annual)", " $360.00"], ["Onboarding", " $200.00"], ]; rows.forEach(([desc, amt], i) => { ctx.fillText(desc, 40, 156 + i * 26); ctx.fillText(amt, cw - 130, 156 + i * 26); }); // Total ctx.strokeStyle = "rgba(16,32,59,0.55)"; ctx.beginPath(); ctx.moveTo(40, 260); ctx.lineTo(cw - 40, 260); ctx.stroke(); ctx.fillStyle = "#10203b"; ctx.font = 'italic 22px "Instrument Serif", serif'; ctx.fillText("Total", 40, 292); ctx.font = 'italic 22px "Instrument Serif", serif'; ctx.fillText("$1,800.00", cw - 140, 292); // Bottom stamp — a red circle with "PAID" (ish) const cx = cw - 110; const cy = ch - 110; ctx.save(); ctx.translate(cx, cy); ctx.rotate(-0.18); ctx.strokeStyle = "rgba(197,58,44,0.75)"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 0, 52, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.arc(0, 0, 46, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = "rgba(197,58,44,0.85)"; ctx.font = 'bold 20px "Geist", sans-serif'; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("PAID", 0, 0); ctx.restore(); const tex = new THREE.CanvasTexture(c); // Sharpness knobs — user flagged text as bitty on the hero. // • LinearFilter (no mipmap blur) keeps small type crisp at close camera range. // • anisotropy 16 keeps text readable at glancing angles as the sheet tilts. // • generateMipmaps off — we don't need distance LODs for a hero prop. tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; tex.anisotropy = 16; tex.generateMipmaps = false; tex.needsUpdate = true; return tex; }