@@ -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