用 GSAP + Three.js 搭一个滚动驱动的 3D 叙事网页
用 GSAP Three.js 搭一个滚动驱动的 3D 叙事网页源码地址https://download.csdn.net/download/u012446963/93027540这篇笔记总结一个简化版的沉浸式网页框架用户滚动页面GSAP 负责读取滚动进度统一状态保存进度Three.js 根据进度更新 3D 场景DOM 文案也跟着滚动入场和退场。它的核心不是某个单独动画而是一条清晰的数据流用户滚动 ↓ GSAP ScrollTrigger 计算 progress ↓ 写入全局 state ↓ UI 和 Three.js 读取 state ↓ 文字、相机、物体、颜色一起变化1. 为什么需要一个全局 progress普通网页里很多组件会各自监听滚动。但在滚动叙事网站里更好的方式是把整个页面看成一条时间轴。页面顶部是progress 0页面底部是progress 1中间任意位置都是一个 0 到 1 的值。这样 Three.js 不用直接关心浏览器滚动条DOM 动画也不用各自计算滚动距离。它们都只读取同一个状态。2. Section 配置demo 里有一组 section 配置exportconstsections[{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单位length: 100 → 100vh length: 180 → 180vhcolor是当前段落的 UI 主题比如light或dark。3. ScrollTrigger 的 start 和 end核心滚动监听是ScrollTrigger.create({trigger:document.body,start:top top,end:bottom bottom,scrub:true,onUpdate(self){state.targetProgressself.progress},})start和end是 ScrollTrigger 的位置描述语法触发元素的位置 视口的位置所以start:top top表示当 document.body 的顶部碰到视口顶部时进度开始为 0end:bottom bottom表示当 document.body 的底部碰到视口底部时进度结束为 1因为这个 demo 需要一个全局进度所以用整个页面作为滚动范围。4. scrub: true 是什么scrub:true表示动画进度和滚动条位置直接绑定。页面从顶部滚到底部顶部 self.progress 0 中间 self.progress 0.5 底部 self.progress 1onUpdate(self)每次滚动更新都会执行。这里的self是 GSAP 自动传入的当前 ScrollTrigger 实例。onUpdate(self){state.targetProgressself.progress state.scrollSpeedself.getVelocity()constsectiongetSectionByProgress(self.progress)state.sectionIndexsection.index state.sectionIdsection.id state.sectionColorsection.color}这段做三件事1. 保存全局滚动进度 targetProgress 2. 保存滚动速度 scrollSpeed 3. 根据 progress 判断当前在哪个 section5. progress 0.45 时怎么计算 section假设 section 配置是hero: 100 intro: 140 crystal: 180 case: 150 footer: 120总长度100 140 180 150 120 690如果progress0.45换算成虚拟位置cursor0.45*690cursor310.5再看每一段范围hero: 0 ~ 100 intro: 100 ~ 240 crystal: 240 ~ 420 case: 420 ~ 570 footer: 570 ~ 690310.5落在crystal里。然后计算它在crystal内部的局部进度localProgress(310.5-240)/180localProgress0.3916所以结果大概是{id:crystal,index:2,color:dark,localProgress:0.39,}全局进度适合控制整站大时间轴局部进度适合控制某一段内部的小动画。6. 为什么需要 smoothProgress滚动条给出的targetProgress是真实位置。如果直接把它用于 3D 场景画面会非常跟手但也容易显得硬。所以 demo 里增加了一个缓动进度gsap.ticker.add((){state.smoothProgress(state.targetProgress-state.smoothProgress)*0.085})这句的意思是smoothProgress 每一帧都向 targetProgress 靠近一点点比如targetProgress0.8smoothProgress0.2差距是0.8 - 0.2 0.6每帧追 8.5%0.6 * 0.085 0.051下一帧smoothProgress 0.251它会越来越接近0.8但不是瞬间跳过去。这其实就是线性插值常叫lerpfunctionlerp(current,target,factor){returncurrent(target-current)*factor}0.085是手感参数数值越大追得越快画面更紧 数值越小追得越慢拖尾更明显7. position、rotation、scale 的区别Three.js 里最常操作的三个属性是position 位置 rotation 旋转 scale 缩放例如object.position.x object.position.y object.position.z控制物体放在哪里。object.rotation.x object.rotation.y object.rotation.z控制物体绕哪个轴转。可以这样记position.x 左右移动 position.y 上下移动 position.z 前后移动 rotation.x 绕左右轴翻转像点头 rotation.y 绕上下轴旋转像摇头 rotation.z 绕前后轴旋转像钟表指针转比如wire.rotation.zprogress*1.2这不是让物体靠近屏幕而是让它像钟表指针一样在屏幕平面内旋转。如果想靠近屏幕通常改object.position.z或者移动相机camera.position.z如果想直接变大改object.scale.setScalar(1.5)8. 外层线框 wire 做什么demo 里主体外面有一层白色线框constwirenewTHREE.Mesh(newTHREE.IcosahedronGeometry(1.53,2),newTHREE.MeshBasicMaterial({color:#ffffff,wireframe:true,transparent:true,opacity:0.18,}),)IcosahedronGeometry(1.53, 2)创建一个二十面体1.53 半径比主体略大 2 细分等级材质里wireframe:true表示只显示网格线不显示实体面。transparent:trueopacity:0.18表示开启透明并把透明度设为 18%。MeshBasicMaterial不受灯光影响所以这层线框会保持稳定的白色透明效果。渲染时wire.rotation.copy(core.rotation)wire.rotation.zprogress*1.2线框先复制主体旋转再额外绕 z 轴转一点。这样主体和线框不会完全重合画面更有层次。9. 灯光和粒子场景里有背景粒子constparticlescreateParticles()scene.add(particles)粒子不是主角它的作用是增强空间深度。没有粒子时物体像是在空背景里转有粒子时相机和物体的运动更容易被感知。灯光有三类constambientnewTHREE.AmbientLight(#ffffff,1.6)AmbientLight是环境光没有方向负责打底避免暗面完全黑掉。constkeynewTHREE.DirectionalLight(#e8fbff,3.5)key.position.set(3,4,5)DirectionalLight是方向光类似太阳光负责主要高光和明暗方向。constfillnewTHREE.PointLight(#ff6f9f,3.2,15)fill.position.set(-4,-2,4)PointLight是点光源类似一个彩色灯泡。这里用粉色补光给暗部增加氛围。整体组合AmbientLight 打底 DirectionalLight 主光 PointLight 彩色补光10. 鼠标坐标为什么要转成 -1 到 1浏览器里的鼠标坐标是像素左上角x 0, y 0 右下角x window.innerWidth, y window.innerHeight但做 3D 交互时更适合使用标准化坐标x: -1 到 1 y: -1 到 1代码是targetPointer.x(event.clientX/window.innerWidth-0.5)*2targetPointer.y(event.clientY/window.innerHeight-0.5)*-2以 x 为例event.clientX / window.innerWidth先把像素换成 0 到 1。左边 0 中间 0.5 右边 1然后减去 0.5左边 -0.5 中间 0 右边 0.5最后乘以 2左边 -1 中间 0 右边 1y 轴乘的是-2因为浏览器坐标里越往下 y 越大而 3D/数学坐标通常希望越往上 y 越大。所以需要反向。最终结果左上角x -1, y 1 中心点x 0, y 0 右下角x 1, y -1然后就可以用它控制相机camera.position.xpointer.x*0.28camera.position.ypointer.y*0.1811. pointer 和 targetPointerdemo 里有两个鼠标向量constpointernewTHREE.Vector2(0,0)consttargetPointernewTHREE.Vector2(0,0)targetPointer保存真实鼠标位置鼠标一动就立刻更新。pointer是缓动后的鼠标位置。在渲染循环里pointer.lerp(targetPointer,0.06)意思是每一帧向真实鼠标位置靠近 6%。这样鼠标影响相机或物体时不会突然跳动而是有一点柔和的跟随感。12. render 循环是 3D 场景的心脏核心渲染函数是functionrender(){constelapsedclock.getElapsedTime()constprogressstate.smoothProgress pointer.lerp(targetPointer,0.06)core.rotation.xelapsed*0.18progress*Math.PI*1.8core.rotation.yelapsed*0.28progress*Math.PI*3.1constphasegetPhase(progress)group.position.xgsap.utils.interpolate(-1.6,1.4,phase.travel)group.scale.setScalar(gsap.utils.interpolate(0.75,1.55,phase.scale))camera.position.zgsap.utils.interpolate(8.5,5.2,phase.camera)camera.lookAt(0,0,0)renderer.render(scene,camera)requestAnimationFrame(render)}render()每一帧做这些事1. 读取时间 elapsed 2. 读取平滑滚动进度 smoothProgress 3. 平滑鼠标位置 4. 更新物体旋转 5. 更新物体位置和缩放 6. 更新相机位置 7. 渲染当前帧 8. 请求下一帧requestAnimationFrame(render)会让浏览器在下一帧继续调用render()所以它会形成一个持续循环。最后的render()是第一次启动循环。没有它函数只是被定义不会执行。13. getPhase把一个 progress 拆成多个动画通道如果所有动画都直接用progress它们会同时开始、同时结束节奏会很单调。所以 demo 用functiongetPhase(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),}}一个全局进度被拆成多个通道travel 控制横向移动 float 控制上下浮动 scale 控制放大 camera 控制相机靠近 warmth 控制颜色变暖这样每个动画可以在不同时间点开始形成更好的叙事节奏。14. gzip 是什么Vite 构建时会显示dist/assets/index.js 590.18 kB │ gzip: 165.85 kB这不是代码里要调用的功能而是告诉你如果服务器开启 gzip 压缩这个文件传输时大概会变成 165.85 kB。浏览器会自动解压并运行。本地开发不用管 gzipnpmrun dev正式部署到 Vercel、Netlify、Cloudflare Pages 等平台时通常会自动开启 gzip 或 brotli。如果自己用 Nginx可以配置gzip on; gzip_types text/plain text/css application/javascript application/json image/svgxml; gzip_min_length 1024;15. 总结这个 demo 的关键不是某个特效而是架构ScrollTrigger 负责读滚动 state 负责保存统一状态 Three.js 负责视觉主体 GSAP ticker 负责平滑数值 DOM ScrollTrigger 负责文字入场和退场一旦这个框架跑通就可以逐步增强普通几何体 → GLB 模型 普通材质 → ShaderMaterial 普通粒子 → GPU 粒子 单页滚动 → 多页面转场 简单 HUD → 完整交互系统这就是沉浸式滚动叙事网页的最小骨架。源码地址https://download.csdn.net/download/u012446963/93027540