Skip to content

Commit a47c474

Browse files
feat(web): cursor-press elastic membrane physics on paper-rail
Adds a Mexican-hat deformation centred on the cursor's UV, driven by three near-critically-damped springs so the paper deforms locally under the cursor without ringing. Anisotropic radius weights match the vertical washi grain; small persistent hover lift keeps the surface attentive to the cursor at rest. Scroll-driven motion, parallax, and exit transitions are unchanged.
1 parent 50d0534 commit a47c474

1 file changed

Lines changed: 98 additions & 4 deletions

File tree

web/components/canvas/paper-rail.tsx

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,12 +1114,17 @@ function bendPaper(
11141114
slack: number,
11151115
time: number,
11161116
verlet: ReturnType<typeof makeVerlet>,
1117+
cursorU: number,
1118+
cursorV: number,
1119+
cursorPress: number,
11171120
) {
11181121
if (!Number.isFinite(curl)) curl = 0;
11191122
if (!Number.isFinite(wave)) wave = 0;
11201123
if (!Number.isFinite(flap)) flap = 0;
11211124
if (!Number.isFinite(slack)) slack = 0;
11221125
if (!Number.isFinite(time)) time = 0;
1126+
if (!Number.isFinite(cursorPress)) cursorPress = 0;
1127+
const cursorActive = Math.abs(cursorPress) > 0.001;
11231128
for (let i = 0; i < pos.length; i += 3) {
11241129
const px = rest[i];
11251130
const py = rest[i + 1];
@@ -1142,8 +1147,21 @@ function bendPaper(
11421147
const sagY = -sagMask * slack * 0.1;
11431148
const sagZ = -sagMask * slack * 0.06;
11441149
const yWobble = Math.sin(u * 5.0 + time * 1.6) * 0.006 * (1 - sagMask * 0.5);
1150+
// Cursor bump: subtle volume-conserving deformation centred on the
1151+
// cursor's UV. Most of the displacement is the positive gaussian
1152+
// lobe; the (1 - 0.6·r²) factor adds only a faint counter-curve at
1153+
// the rim, since real paper barely dips around a press. Tight radius
1154+
// weights (50/32) keep the footprint at ~10% of the paper, with
1155+
// mild vertical anisotropy along the washi grain.
1156+
let cursorZ = 0;
1157+
if (cursorActive) {
1158+
const du = u - cursorU;
1159+
const dv = v - cursorV;
1160+
const r2 = du * du * 50 + dv * dv * 32;
1161+
cursorZ = (1 - 0.6 * r2) * Math.exp(-r2) * cursorPress;
1162+
}
11451163
const newY = py + ripple * 0.5 + sagY + yWobble;
1146-
const newZ = curlZ + waveZ + flapZ + sagZ + ripple + verletZ;
1164+
const newZ = curlZ + waveZ + flapZ + sagZ + ripple + verletZ + cursorZ;
11471165
pos[i] = px;
11481166
pos[i + 1] = Number.isFinite(newY) ? newY : py;
11491167
pos[i + 2] = Number.isFinite(newZ) ? newZ : 0;
@@ -1468,6 +1486,17 @@ export function PaperRail() {
14681486
// Texture-change "punch" — squashes scale briefly when the doc swaps.
14691487
const spPunch = makeSpring(1, 240, 22);
14701488

1489+
// Cursor-bump physics. Paper has high internal damping and tracks
1490+
// a finger closely, so all three springs are overdamped or near-
1491+
// critical, no ringing. spBumpU/V (100/28) lock to the cursor with
1492+
// a barely-perceptible lag and no overshoot. spBumpAmp (70/18) is
1493+
// close to critical damping with one tiny overshoot, so a press
1494+
// rises sharply, returns smoothly, and never oscillates like a
1495+
// tuning fork.
1496+
const spBumpU = makeSpring(0.5, 100, 28);
1497+
const spBumpV = makeSpring(0.5, 100, 28);
1498+
const spBumpAmp = makeSpring(0, 70, 18);
1499+
14711500
const sectionEls = Array.from(
14721501
document.querySelectorAll<HTMLElement>("[data-paper-stage]"),
14731502
);
@@ -1943,11 +1972,76 @@ export function PaperRail() {
19431972
const waveTime = waveStart > 0 ? (now - waveStart) / 600 : 1;
19441973
const wave = waveTime < 1 ? waveTime : 0;
19451974

1946-
bendPaper(frontPosArr, frontRest, curl, wave, flap, slack, tSec, verlet);
1947-
bendPaper(backPosArr, backRest, curl, wave, flap, slack, tSec, verlet);
1975+
// Raw cursor UV in paper-screen space. Used as the spring target
1976+
// for the trailing bump position.
1977+
const cursorRawU = Math.max(0, Math.min(1, mouseRawX * 0.5 + 0.5));
1978+
const cursorRawV = Math.max(0, Math.min(1, -mouseRawY * 0.5 + 0.5));
1979+
// Cursor velocity in NDC. mouseX/Y are the lowpass-filtered cursor
1980+
// (pre-existing); the diff vs raw gives an instantaneous velocity
1981+
// estimate without needing per-frame state.
1982+
const cursorVelX = mouseRawX - mouseX;
1983+
const cursorVelY = mouseRawY - mouseY;
1984+
// Hover lift: when the cursor is roughly over the paper's
1985+
// viewport region, add a tiny persistent positive bias. Kept
1986+
// small (0.004) so it reads as the paper noticing the cursor,
1987+
// not lifting toward it.
1988+
const overU = Math.max(0, 1 - Math.max(0, Math.abs(mouseRawX) - 0.35) * 5);
1989+
const overV = Math.max(0, 1 - Math.max(0, Math.abs(mouseRawY) - 0.5) * 5);
1990+
const hoverLift = overU * overV * 0.004;
1991+
// Press input: signed by horizontal motion (so swipes have
1992+
// direction), magnitude boosted by total speed (so vertical
1993+
// gestures still register), plus the hover lift baseline.
1994+
// Clamp at 0.16 keeps the bump in the realistic finger-press
1995+
// range, never the cartoon-balloon range.
1996+
const cursorPressInput = Math.max(
1997+
-0.16,
1998+
Math.min(
1999+
0.16,
2000+
cursorVelX * 2.6
2001+
+ Math.hypot(cursorVelX, cursorVelY) * 0.8
2002+
+ hoverLift,
2003+
),
2004+
);
2005+
2006+
spBumpU.target(cursorRawU, dt);
2007+
spBumpV.target(cursorRawV, dt);
2008+
spBumpAmp.target(cursorPressInput, dt);
2009+
2010+
const bumpU = spBumpU.value;
2011+
const bumpV = spBumpV.value;
2012+
const bumpAmp = spBumpAmp.value;
2013+
2014+
bendPaper(
2015+
frontPosArr,
2016+
frontRest,
2017+
curl,
2018+
wave,
2019+
flap,
2020+
slack,
2021+
tSec,
2022+
verlet,
2023+
bumpU,
2024+
bumpV,
2025+
bumpAmp,
2026+
);
2027+
bendPaper(
2028+
backPosArr,
2029+
backRest,
2030+
curl,
2031+
wave,
2032+
flap,
2033+
slack,
2034+
tSec,
2035+
verlet,
2036+
bumpU,
2037+
bumpV,
2038+
bumpAmp,
2039+
);
19482040
frontGeom.attributes.position.needsUpdate = true;
19492041
backGeom.attributes.position.needsUpdate = true;
1950-
const windActive = curl + flap + Math.abs(slack) + Math.abs(wave) > 0.005;
2042+
const windActive =
2043+
curl + flap + Math.abs(slack) + Math.abs(wave) + Math.abs(bumpAmp)
2044+
> 0.005;
19512045

19522046
const settleAmt = Math.max(0, 1 - velNorm * 1.6);
19532047
sweepMat.opacity = Math.max(0, settleAmt - 0.3) * 0.5 * fadeFiltered;

0 commit comments

Comments
 (0)