用 GSAP + Three.js 搭一个滚动驱动的 3D 叙事网页

用 GSAP + Three.js 搭一个滚动驱动的 3D 叙事网页

源码地址https://download.csdn.net/download/u012446963/93027540

这篇笔记总结一个简化版的沉浸式网页框架:用户滚动页面,GSAP 负责读取滚动进度,统一状态保存进度,Three.js 根据进度更新 3D 场景,DOM 文案也跟着滚动入场和退场。

它的核心不是某个单独动画,而是一条清晰的数据流:

txt 复制代码
用户滚动
  ↓
GSAP ScrollTrigger 计算 progress
  ↓
写入全局 state
  ↓
UI 和 Three.js 读取 state
  ↓
文字、相机、物体、颜色一起变化

1. 为什么需要一个全局 progress

普通网页里,很多组件会各自监听滚动。但在滚动叙事网站里,更好的方式是把整个页面看成一条时间轴。

页面顶部是:

txt 复制代码
progress = 0

页面底部是:

txt 复制代码
progress = 1

中间任意位置都是一个 0 到 1 的值。

这样 Three.js 不用直接关心浏览器滚动条,DOM 动画也不用各自计算滚动距离。它们都只读取同一个状态。

2. Section 配置

demo 里有一组 section 配置:

js 复制代码
export const sections = [
  { id: "hero", length: 100, color: "light" },
  { id: "intro", length: 140, color: "light" },
  { id: "crystal", length: 180, color: "dark" },
  { id: "case", length: 150, color: "dark" },
  { id: "footer", length: 120, color: "dark" },
]

这里的 length 不是像素,而是虚拟滚动长度。可以理解成 vh 单位:

txt 复制代码
length: 100  →  100vh
length: 180  →  180vh

color 是当前段落的 UI 主题,比如 lightdark

3. ScrollTrigger 的 start 和 end

核心滚动监听是:

js 复制代码
ScrollTrigger.create({
  trigger: document.body,
  start: "top top",
  end: "bottom bottom",
  scrub: true,
  onUpdate(self) {
    state.targetProgress = self.progress
  },
})

startend 是 ScrollTrigger 的位置描述语法:

txt 复制代码
"触发元素的位置 视口的位置"

所以:

js 复制代码
start: "top top"

表示:

txt 复制代码
当 document.body 的顶部碰到视口顶部时,进度开始为 0
js 复制代码
end: "bottom bottom"

表示:

txt 复制代码
当 document.body 的底部碰到视口底部时,进度结束为 1

因为这个 demo 需要一个全局进度,所以用整个页面作为滚动范围。

4. scrub: true 是什么

js 复制代码
scrub: true

表示动画进度和滚动条位置直接绑定。

页面从顶部滚到底部:

txt 复制代码
顶部     self.progress = 0
中间     self.progress = 0.5
底部     self.progress = 1

onUpdate(self) 每次滚动更新都会执行。这里的 self 是 GSAP 自动传入的当前 ScrollTrigger 实例。

js 复制代码
onUpdate(self) {
  state.targetProgress = self.progress
  state.scrollSpeed = self.getVelocity()

  const section = getSectionByProgress(self.progress)
  state.sectionIndex = section.index
  state.sectionId = section.id
  state.sectionColor = section.color
}

这段做三件事:

txt 复制代码
1. 保存全局滚动进度 targetProgress
2. 保存滚动速度 scrollSpeed
3. 根据 progress 判断当前在哪个 section

5. progress = 0.45 时怎么计算 section

假设 section 配置是:

txt 复制代码
hero:    100
intro:   140
crystal: 180
case:    150
footer:  120

总长度:

txt 复制代码
100 + 140 + 180 + 150 + 120 = 690

如果:

js 复制代码
progress = 0.45

换算成虚拟位置:

js 复制代码
cursor = 0.45 * 690
cursor = 310.5

再看每一段范围:

txt 复制代码
hero:    0   ~ 100
intro:   100 ~ 240
crystal: 240 ~ 420
case:    420 ~ 570
footer:  570 ~ 690

310.5 落在 crystal 里。

然后计算它在 crystal 内部的局部进度:

js 复制代码
localProgress = (310.5 - 240) / 180
localProgress = 0.3916

所以结果大概是:

js 复制代码
{
  id: "crystal",
  index: 2,
  color: "dark",
  localProgress: 0.39,
}

全局进度适合控制整站大时间轴,局部进度适合控制某一段内部的小动画。

6. 为什么需要 smoothProgress

滚动条给出的 targetProgress 是真实位置。如果直接把它用于 3D 场景,画面会非常跟手,但也容易显得硬。

所以 demo 里增加了一个缓动进度:

js 复制代码
gsap.ticker.add(() => {
  state.smoothProgress += (state.targetProgress - state.smoothProgress) * 0.085
})

这句的意思是:

txt 复制代码
smoothProgress 每一帧都向 targetProgress 靠近一点点

比如:

js 复制代码
targetProgress = 0.8
smoothProgress = 0.2

差距是:

txt 复制代码
0.8 - 0.2 = 0.6

每帧追 8.5%:

txt 复制代码
0.6 * 0.085 = 0.051

下一帧:

txt 复制代码
smoothProgress = 0.251

它会越来越接近 0.8,但不是瞬间跳过去。

这其实就是线性插值,常叫 lerp

js 复制代码
function lerp(current, target, factor) {
  return current + (target - current) * factor
}

0.085 是手感参数:

txt 复制代码
数值越大:追得越快,画面更紧
数值越小:追得越慢,拖尾更明显

7. position、rotation、scale 的区别

Three.js 里最常操作的三个属性是:

txt 复制代码
position  位置
rotation  旋转
scale     缩放

例如:

js 复制代码
object.position.x
object.position.y
object.position.z

控制物体放在哪里。

js 复制代码
object.rotation.x
object.rotation.y
object.rotation.z

控制物体绕哪个轴转。

可以这样记:

txt 复制代码
position.x  左右移动
position.y  上下移动
position.z  前后移动

rotation.x  绕左右轴翻转,像点头
rotation.y  绕上下轴旋转,像摇头
rotation.z  绕前后轴旋转,像钟表指针转

比如:

js 复制代码
wire.rotation.z += progress * 1.2

这不是让物体靠近屏幕,而是让它像钟表指针一样在屏幕平面内旋转。

如果想靠近屏幕,通常改:

js 复制代码
object.position.z

或者移动相机:

js 复制代码
camera.position.z

如果想直接变大,改:

js 复制代码
object.scale.setScalar(1.5)

8. 外层线框 wire 做什么

demo 里主体外面有一层白色线框:

js 复制代码
const wire = new THREE.Mesh(
  new THREE.IcosahedronGeometry(1.53, 2),
  new THREE.MeshBasicMaterial({
    color: "#ffffff",
    wireframe: true,
    transparent: true,
    opacity: 0.18,
  }),
)

IcosahedronGeometry(1.53, 2) 创建一个二十面体:

txt 复制代码
1.53  半径,比主体略大
2     细分等级

材质里:

js 复制代码
wireframe: true

表示只显示网格线,不显示实体面。

js 复制代码
transparent: true
opacity: 0.18

表示开启透明,并把透明度设为 18%。

MeshBasicMaterial 不受灯光影响,所以这层线框会保持稳定的白色透明效果。

渲染时:

js 复制代码
wire.rotation.copy(core.rotation)
wire.rotation.z += progress * 1.2

线框先复制主体旋转,再额外绕 z 轴转一点。这样主体和线框不会完全重合,画面更有层次。

9. 灯光和粒子

场景里有背景粒子:

js 复制代码
const particles = createParticles()
scene.add(particles)

粒子不是主角,它的作用是增强空间深度。没有粒子时,物体像是在空背景里转;有粒子时,相机和物体的运动更容易被感知。

灯光有三类:

js 复制代码
const ambient = new THREE.AmbientLight("#ffffff", 1.6)

AmbientLight 是环境光,没有方向,负责打底,避免暗面完全黑掉。

js 复制代码
const key = new THREE.DirectionalLight("#e8fbff", 3.5)
key.position.set(3, 4, 5)

DirectionalLight 是方向光,类似太阳光,负责主要高光和明暗方向。

js 复制代码
const fill = new THREE.PointLight("#ff6f9f", 3.2, 15)
fill.position.set(-4, -2, 4)

PointLight 是点光源,类似一个彩色灯泡。这里用粉色补光给暗部增加氛围。

整体组合:

txt 复制代码
AmbientLight      打底
DirectionalLight  主光
PointLight        彩色补光

10. 鼠标坐标为什么要转成 -1 到 1

浏览器里的鼠标坐标是像素:

txt 复制代码
左上角:x = 0, y = 0
右下角:x = window.innerWidth, y = window.innerHeight

但做 3D 交互时,更适合使用标准化坐标:

txt 复制代码
x: -1 到 1
y: -1 到 1

代码是:

js 复制代码
targetPointer.x = (event.clientX / window.innerWidth - 0.5) * 2
targetPointer.y = (event.clientY / window.innerHeight - 0.5) * -2

以 x 为例:

txt 复制代码
event.clientX / window.innerWidth

先把像素换成 0 到 1。

txt 复制代码
左边 0
中间 0.5
右边 1

然后减去 0.5:

txt 复制代码
左边 -0.5
中间 0
右边 0.5

最后乘以 2:

txt 复制代码
左边 -1
中间 0
右边 1

y 轴乘的是 -2,因为浏览器坐标里越往下 y 越大,而 3D/数学坐标通常希望越往上 y 越大。所以需要反向。

最终结果:

txt 复制代码
左上角:x = -1, y = 1
中心点:x = 0,  y = 0
右下角:x = 1,  y = -1

然后就可以用它控制相机:

js 复制代码
camera.position.x = pointer.x * 0.28
camera.position.y = pointer.y * 0.18

11. pointer 和 targetPointer

demo 里有两个鼠标向量:

js 复制代码
const pointer = new THREE.Vector2(0, 0)
const targetPointer = new THREE.Vector2(0, 0)

targetPointer 保存真实鼠标位置,鼠标一动就立刻更新。

pointer 是缓动后的鼠标位置。在渲染循环里:

js 复制代码
pointer.lerp(targetPointer, 0.06)

意思是每一帧向真实鼠标位置靠近 6%。

这样鼠标影响相机或物体时不会突然跳动,而是有一点柔和的跟随感。

12. render 循环是 3D 场景的心脏

核心渲染函数是:

js 复制代码
function render() {
  const elapsed = clock.getElapsedTime()
  const progress = state.smoothProgress

  pointer.lerp(targetPointer, 0.06)

  core.rotation.x = elapsed * 0.18 + progress * Math.PI * 1.8
  core.rotation.y = elapsed * 0.28 + progress * Math.PI * 3.1

  const phase = getPhase(progress)

  group.position.x = gsap.utils.interpolate(-1.6, 1.4, phase.travel)
  group.scale.setScalar(gsap.utils.interpolate(0.75, 1.55, phase.scale))

  camera.position.z = gsap.utils.interpolate(8.5, 5.2, phase.camera)
  camera.lookAt(0, 0, 0)

  renderer.render(scene, camera)
  requestAnimationFrame(render)
}

render()

每一帧做这些事:

txt 复制代码
1. 读取时间 elapsed
2. 读取平滑滚动进度 smoothProgress
3. 平滑鼠标位置
4. 更新物体旋转
5. 更新物体位置和缩放
6. 更新相机位置
7. 渲染当前帧
8. 请求下一帧

requestAnimationFrame(render) 会让浏览器在下一帧继续调用 render(),所以它会形成一个持续循环。

最后的:

js 复制代码
render()

是第一次启动循环。没有它,函数只是被定义,不会执行。

13. getPhase:把一个 progress 拆成多个动画通道

如果所有动画都直接用 progress,它们会同时开始、同时结束,节奏会很单调。

所以 demo 用:

js 复制代码
function getPhase(progress) {
  return {
    travel: gsap.utils.clamp(0, 1, progress * 1.25),
    float: Math.sin(progress * Math.PI),
    scale: gsap.utils.clamp(0, 1, (progress - 0.1) / 0.65),
    camera: gsap.utils.clamp(0, 1, (progress - 0.12) / 0.7),
    warmth: gsap.utils.clamp(0, 1, (progress - 0.45) / 0.4),
  }
}

一个全局进度被拆成多个通道:

txt 复制代码
travel  控制横向移动
float   控制上下浮动
scale   控制放大
camera  控制相机靠近
warmth  控制颜色变暖

这样每个动画可以在不同时间点开始,形成更好的叙事节奏。

14. gzip 是什么

Vite 构建时会显示:

txt 复制代码
dist/assets/index.js   590.18 kB │ gzip: 165.85 kB

这不是代码里要调用的功能,而是告诉你:如果服务器开启 gzip 压缩,这个文件传输时大概会变成 165.85 kB。

浏览器会自动解压并运行。

本地开发不用管 gzip:

bash 复制代码
npm run dev

正式部署到 Vercel、Netlify、Cloudflare Pages 等平台时,通常会自动开启 gzip 或 brotli。

如果自己用 Nginx,可以配置:

nginx 复制代码
gzip on;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;

15. 总结

这个 demo 的关键不是某个特效,而是架构:

txt 复制代码
ScrollTrigger 负责读滚动
state 负责保存统一状态
Three.js 负责视觉主体
GSAP ticker 负责平滑数值
DOM ScrollTrigger 负责文字入场和退场

一旦这个框架跑通,就可以逐步增强:

txt 复制代码
普通几何体 → GLB 模型
普通材质 → ShaderMaterial
普通粒子 → GPU 粒子
单页滚动 → 多页面转场
简单 HUD → 完整交互系统

这就是沉浸式滚动叙事网页的最小骨架。

源码地址https://download.csdn.net/download/u012446963/93027540