滚动驱动的三维场景

861 字 · 3 分钟#three.js#动效

这两年很多产品官网把滚轮变成了运镜:页面往下滚,相机在一个三维场景里推拉摇移,内容跟着章节浮现。这篇把这套做法完整拆一遍——不过先别读文字,直接滚。下面这段路,是兰亭集序的字散在三维空间里搭成的走廊:

往下滚,穿过这片字——滚到头有东西等你
0%

回来了?拆解开始。

结构:定住画布,拉长轨道

布局只需要两层:一个高出视口好几倍的滚动容器,里面一块 position: sticky 的画布。

<div class="track" style="height: 260vh">
  <canvas class="stage" style="position: sticky; top: 0" />
</div>

用户滚动时画布钉在视口里,真正移动的是滚动条。260vh 减去视口高度的部分就是「时间轴」——滚过多少,运镜走到多少。想让某一幕慢一点,把容器加高就行,叙事节奏完全由高度控制。

进度、阻尼与帧率

滚动事件里只做一件事:记下目标进度。所有重活都放在渲染循环里。

let target = 0
let current = 0

window.addEventListener(
  'scroll',
  () => {
    const rect = track.getBoundingClientRect()
    const total = rect.height - innerHeight
    target = clamp(-rect.top / total, 0, 1)
  },
  { passive: true },
)

滚轮是离散的,直接把 target 喂给相机会一顿一顿。中间垫一层阻尼:

const tick = (dt) => {
  current += (target - current) * (1 - Math.exp(-6 * dt))
  updateCamera(current)
  renderer.render(scene, camera)
}

阻尼这样写有讲究。常见的 current += (target - current) * 0.1 是按帧算的,120Hz 屏幕上每秒执行两倍次数,动画速度直接翻倍。换成 1 - Math.exp(-lambda * dt) 后收敛速度只和真实时间有关,什么刷新率都一个手感。上面 demo 里滚动停下后镜头还会滑行一小段,就是这个函数在收尾。

相机怎么走

demo 里的相机走的是直线推进加正弦摆动——z 随进度深入,x 和 y 用 z 的正余弦轻轻晃,像人走路的自然摇:

const z = 1.5 - progress * 43
camera.position.set(Math.sin(z * 0.35) * 0.35, Math.cos(z * 0.28) * 0.25, z)
camera.lookAt(0, 0, z - 6)

轨迹更复杂的场景用样条:摆几个关键点,CatmullRomCurve3 插出平滑路径,进度映射到曲线位置。有个坑要记住:getPointgetPointAt 不是一回事——样条参数和弧长不成正比,关键点分布不均时 getPoint 会让镜头忽快忽慢,getPointAt 按弧长重新参数化过,匀速。

纵深感有一半是雾给的。把雾的颜色设成和背景完全一致,远处的物体就不是「被裁掉」而是「隐进纸里」:

scene.fog = new THREE.Fog('#f3efe7', 3, 15)

章节:scrub 和 trigger

滚动叙事里的动画分两类,处理方式完全不同:

  • scrub:和进度绑死,滚多少播多少,往回滚就倒放。运镜、进度数字是这类
  • trigger:过线就播,播完就完,回滚不倒放。文案淡入适合这类,倒放文字很出戏

scrub 动画往往只活在整段滚动的某个区间里,用 remap 把全局进度切成局部进度:

const remap = (t, a, b) => clamp((t - a) / (b - a), 0, 1)

demo 结尾那个「墨」字就是这么做的:remap(progress, 0.82, 1) 作为它的缩放系数——前 82% 的路它压根不存在,最后一段从零长大。整个页面的编排就是一张 remap 区间表,调节奏改数字就行。

降级

这类页面对晕动症用户不友好,prefers-reduced-motion: reduce 时应该关掉缓动和摇摆(demo 里是直接让 current 等于 target);移动端把像素比和物件数量再砍一档。另外纯 CSS 的 scroll-driven animations(animation-timeline: scroll())已经能接管文案淡入这类简单段落,Chromium 系是稳定特性,能少写不少 JS。

技术拆完了,最后说句实话:这类页面最难的不是代码,是编排——哪一幕给多少滚动距离、什么时候让人停下来读字,只能一遍遍滚着调。上面那个 demo 的 260vh 和 0.82,都是改了十几轮的数字。