
用 GSAP + Three.js 搭一个滚动驱动的 3D 叙事网页
这篇笔记总结一个简化版的沉浸式网页框架:用户滚动页面,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 主题,比如 light 或 dark。
3. ScrollTrigger 的 start 和 end
核心滚动监听是:
js
ScrollTrigger.create({
trigger: document.body,
start: "top top",
end: "bottom bottom",
scrub: true,
onUpdate(self) {
state.targetProgress = self.progress
},
})
start 和 end 是 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 → 完整交互系统
这就是沉浸式滚动叙事网页的最小骨架。