Skip to content

Repository files navigation

FFT — Banknotes

An interactive WebGL scene: an endless, staggered grid of banknotes lying on a dark table. Drag to pan, hover to shove the paper around, click a note to fly the camera onto it and reveal its headline.

Built with React Three Fiber on an orthographic camera. There are no real lights in the scene — every bit of depth (curl, sheen, shadow, edge blur) is faked in shaders and 2D canvas textures, sharing one geometry and one compiled program across every note.

Quick start

pnpm install
pnpm dev        # vite dev server
pnpm build      # tsc --noEmit && vite build
pnpm preview    # serve the production build

Requires pnpm (dependencies are pinned exactly via save-exact=true).

Stack

React 19 · TypeScript · Vite · Tailwind v4 · three.js · @react-three/fiber · @react-three/drei · @react-three/postprocessing · maath

How it works

Infinite grid

src/lib/grid.ts doesn't store a grid — it derives one. visibleTiles() returns the cells covering the current frustum plus a one-cell margin, and each tile's design, tilt, jitter and z-order come from a hash of its col:row coordinate. Panning re-windows the visible set; every note that scrolls back into view is bit-for-bit identical to how it left.

Odd columns are offset half a cell so the notes stagger instead of forming visible rows. Cells are deliberately smaller than a tilted note's bounding box, so notes overlap most of the time.

Interaction

Input Result
Drag (one pointer) Pan with inertia (usePanControls.ts)
Pinch (two pointers) Zoom, clamped to 0.6×–1.8× the responsive base
Hover a note It lifts; neighbours are pushed aside and buckle
Click a note Camera flies onto it, overlay headline appears
Esc / backdrop / ✕ Close the focused note

Hover and focus share one repel model (src/lib/hover.ts), differing only in radius and strength. The falloff runs in a cell-aspect-normalized elliptical metric — the grid is 3.8 × 1.7 per cell, so an isotropic falloff would shove vertical neighbours roughly 4× harder than horizontal ones.

Paper physics

src/lib/paper.ts holds the deformation model; paperMaterial.ts turns it into vertex displacement and shading. Every note shares one 32×16 subdivided unit quad and one compiled shader program — only uniforms differ.

Notes lie flat at rest. Interaction bends them three ways:

  • Buckle — a pushed note's cursor-facing edge lifts like a flicked sheet corner; the far edge stays pinned to the table.
  • Flex — a velocity-driven bow against the direction of motion.
  • Lean — additive in-plane rotation tracking lateral velocity, on a stiff critically-damped spring so it dies with the motion instead of trailing it.

The load-bearing trick is in the vertex shader: pure z-displacement is invisible under an orthographic camera. So each vertex is also pulled back along its bend axis by the first-order arc-length deficit (Δ(u) = ⅔c²u³ for a parabola z = cu²), which shrinks and curves the projected silhouette. Real paper is inextensible; without that foreshortening term a curled note reads as a flat rectangle.

Camera and post

Focus flight (Scene.tsx) damps a single progress value: zoom interpolates in log-space while the camera position is derived so the note's screen-space offset decays linearly. Damping x/y/zoom independently instead reads as zoom-then-slide, because zoom covers most of its distance immediately and magnifies the remaining pan error.

Effects.tsx adds a custom radial edge blur (a postprocessing Effect whose strength ramps with uv distance from center, so it tracks the vignette ellipse) plus a vignette. Two DOM layers on top of the canvas apply a navy colour grade and a corner falloff.

prefers-reduced-motion is respected: the camera snaps instead of flying and the overlay transition is dropped.

Layout

src/
  App.tsx              canvas + DOM ambience layers + focus state
  data/notes.ts        note designs and their headlines
  lib/
    grid.ts            hash-derived infinite tiling
    hover.ts           repel/push model
    paper.ts           spring integrator + per-note paper params
    zoom.ts            responsive base zoom for the ortho camera
  scene/
    Scene.tsx          focus camera, responsive zoom, background
    BanknoteGrid.tsx   windowing + the single useFrame driving every note
    Banknote.tsx       one note: shadow mesh + paper mesh + animation handle
    paperMaterial.ts   bent-paper vertex/fragment shaders
    usePanControls.ts  drag-pan with inertia, pinch zoom
    Effects.tsx        radial edge blur + vignette
  ui/FocusOverlay.tsx  headline overlay for the focused note
public/notes/          banknote artwork

One useFrame in BanknoteGrid drives every channel for every note — position, rotation, scale, shadow, curl, flex, buckle — via a shared handle map that each Banknote registers into. Animation state lives on those handles, not in React state, so re-windowing during a pan doesn't reset a note mid-motion.

Notes for contributors

  • Tuning constants are grouped at the top of lib/hover.ts and lib/paper.ts, each with a comment explaining the value's origin. Several encode a z-displacement budget that keeps bent notes under the hash z-spread (0.5) and the focus z (0.6) — worth reading before raising an amplitude.
  • Notes render transparent with depthWrite: false, so overlaps resolve by painter order rather than depth. That's intentional and load-bearing.
  • Designs and layout are derived from a Figma file; comments reference its node IDs (e.g. 159:472) where a value came straight from the design.

Releases

Packages

Used by

Contributors

Languages