从补间到叙事:GSAP 动画实战 · 公开阅读
第13章 Three.js 与 GSAP
结合 Three.js 与 GSAP 控制三维物体、镜头和场景动画。
本章小节
难度:★★★★☆ | 用到的GSAP能力:对象属性动画、Timeline、ScrollTrigger | 项目类型:3D产品展示
13.1 为什么3D场景也需要GSAP
如果你在做3D网页,你会发现Three.js自带了一套动画机制——requestAnimationFrame 加手动插值。能用,但控制起来真费劲。就像你有一台精密相机,但每次拍照都要手动算焦距、调光圈,而GSAP就是那支"自动对焦"按钮。
Three.js 本身提供了基础的动画机制,但在实际项目中会遇到3个痛点:
痛点1:手动插值复杂
// Three.js 原生方式:手动写缓动
let startTime = null;
function animate(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
const t = Math.min(elapsed / 2000, 1); // 归一化时间
const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
mesh.position.x = 0 + (5 - 0) * eased; // 手动插值
if (t < 1) requestAnimationFrame(animate);
}
// GSAP 方式:一行解决
gsap.to(mesh.position, { x: 5, duration: 2, ease: 'power3.out' });十几行手动计算 vs 一行声明式代码——这差距足够明显了。
痛点2:缓动函数缺失
Three.js 没有内置缓动函数库。常见的easeInOutCubic、elastic.out需要自己实现或引入第三方库。
Three.js 本身有 AnimationMixer、关键帧和动画剪辑体系。GSAP 适合给普通对象属性、相机过渡和自定义 UI 编排提供缓动;不能认为 Three.js 只能匀速运动。
痛点3:时序编排困难
多个3D对象按照时间轴协调动画时,Three.js原生方式需要手动管理大量状态变量和时间判断逻辑。
// Three.js 原生方式:状态机地狱
if (phase === 0) { /* 入场动画 */ }
else if (phase === 1 && elapsed > 2000) { /* 旋转 */ }
else if (phase === 2 && elapsed > 4000) { /* 缩放 */ }
// GSAP Timeline:声明式编排
const tl = gsap.timeline();
tl.to(mesh.position, { x: 5, duration: 1 }) // 入场
.to(mesh.rotation, { y: Math.PI * 2, duration: 2 }) // 旋转
.to(mesh.scale, { x: 1.5, y: 1.5, z: 1.5, duration: 1 }); // 缩放Timeline将"先做什么、再做什么"的顺序写清楚,不再需要if/else管理动画阶段。
13.2 直接动画 Three.js 对象属性
GSAP 可以动画任何 JavaScript 对象的属性。Three.js 的 mesh.position、mesh.rotation、mesh.scale 都是普通的JS对象,GSAP可以直接操作——移一个3D立方体和移一个DOM div本质上没区别。
位置动画
const mesh = new THREE.Mesh(geometry, material);
// 水平移动
gsap.to(mesh.position, { x: 5, duration: 2, ease: 'power2.inOut' });
// 三维移动
gsap.to(mesh.position, {
x: 3, y: 2, z: -5,
duration: 3,
ease: 'power2.out'
});
// 路径动画(分段Timeline)
const pathTL = gsap.timeline();
pathTL.to(mesh.position, { x: 3, duration: 1 })
.to(mesh.position, { y: 2, duration: 1 })
.to(mesh.position, { z: -5, duration: 1 });旋转动画
// 绕Y轴旋转一周
gsap.to(mesh.rotation, {
y: Math.PI * 2,
duration: 4,
ease: 'none', // 匀速旋转
repeat: -1 // 无限循环
});
// 弹性旋转到目标角度
gsap.to(mesh.rotation, {
x: Math.PI / 4,
y: Math.PI / 2,
duration: 1.5,
ease: 'elastic.out(1, 0.5)'
});缩放动画
// 弹入
gsap.from(mesh.scale, {
x: 0, y: 0, z: 0,
duration: 1,
ease: 'back.out(2)'
});
// 脉动循环
gsap.to(mesh.scale, {
x: 1.2, y: 1.2, z: 1.2,
duration: 0.8,
yoyo: true,
repeat: -1,
ease: 'sine.inOut'
});把这一节跑一遍:Three.js 实际场景
本演示实际创建 Three.js scene、PerspectiveCamera、Mesh 和 WebGLRenderer。GSAP 修改 cube.rotation,渲染器读取这些值;CSS 的度与 Three.js 的弧度不能混用。
实验:Three.js 实际场景
GSAP 3.13 / 实验亲手调整参数,观察动画差异。点击开启后再播放,不会自动启动动画。
观察与验证:定位到 50%,rotation.y 应为 π 弧度对应 180°。选择侧面相机终点后重置,比较物体旋转与观察位置变化。
13.3 光源动画
光源也是普通Three.js对象,GSAP同样可以直接动画。
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
// 光源强度变化(模拟日出效果)
gsap.to(light, {
intensity: 3,
duration: 2,
ease: 'power2.inOut'
});
// 光源位置移动(改变阴影方向)
gsap.to(light.position, {
x: -5,
y: 10,
z: 2,
duration: 4,
ease: 'sine.inOut',
yoyo: true,
repeat: -1
});
// 光源颜色过渡
gsap.to(light.color, {
r: 1, // 红色分量 0→1
g: 0.6, // 绿色分量
b: 0.2, // 蓝色分量
duration: 3,
ease: 'power2.out'
});注意:
light.color是THREE.Color实例,其r、g、b属性范围是 0-1。
13.4 相机动画
相机动画是3D场景中最常见的需求——就像无人机飞过城市,你想控制飞行路线:从哪里起飞,在哪悬停,从哪个角度俯瞰。
相机位置动画
const camera = new THREE.PerspectiveCamera(75, w/h, 0.1, 1000);
camera.position.set(0, 2, 8);
// 推近
gsap.to(camera.position, {
z: 3,
duration: 2,
ease: 'power3.inOut'
});
// 环绕(相机始终看向原点)
gsap.to(camera.position, {
x: 8,
z: 0,
duration: 3,
ease: 'sine.inOut',
onUpdate: () => camera.lookAt(0, 0, 0) // 保持看向中心
});完整的轨道飞行Timeline
const flyTL = gsap.timeline({ defaults: { duration: 2, ease: 'power2.inOut' } });
// 阶段1:从远到近
flyTL.to(camera.position, { z: 3, duration: 1.5 }, 'enter')
// 阶段2:环绕到侧面,同时lookAt
.to(camera.position, { x: 6, z: 0, duration: 2 }, 'orbit')
// 阶段3:上升到俯视
.to(camera.position, { y: 8, x: 0, z: 0.1, duration: 2 }, 'top')
// 阶段4:拉远退场
.to(camera.position, { z: 12, duration: 1.5 }, 'exit');13.5 Timeline 编排3D动画序列
将多个3D对象的动画编排到一个Timeline中:
// 创建场景元素
const cube = new THREE.Mesh(geometry, material);
const sphere = new THREE.Mesh(sphereGeo, sphereMat);
const torus = new THREE.Mesh(torusGeo, torusMat);
const light = new THREE.DirectionalLight(0xffffff, 0.5);
const pointLight = new THREE.PointLight(0xff6b6b, 0, 10);
const sceneTL = gsap.timeline({
defaults: { ease: 'power3.out' },
repeat: -1,
repeatDelay: 2
});
// === 阶段1:立方体入场(弹入 + 旋转)===
sceneTL.from(cube.scale, { x: 0, y: 0, z: 0, duration: 1, ease: 'back.out(2)' }, 'enter')
.from(cube.rotation, { y: Math.PI, duration: 1, ease: 'power2.out' }, 'enter');
// === 阶段2:球体+圆环入场(跟随在立方体之后)===
sceneTL.from(sphere.scale, { x: 0, y: 0, z: 0, duration: 0.8, ease: 'back.out(2)' }, '-=0.3')
.from(torus.scale, { x: 0, y: 0, z: 0, duration: 0.8, ease: 'back.out(2)' }, '-=0.5');
// === 阶段3:同步旋转 ===
sceneTL.to(cube.rotation, { y: cube.rotation.y + Math.PI * 2, duration: 3, ease: 'none' }, 'spin')
.to(sphere.rotation, { y: sphere.rotation.y + Math.PI * 3, duration: 3, ease: 'none' }, 'spin')
.to(torus.rotation, { x: torus.rotation.x + Math.PI * 2, duration: 3, ease: 'none' }, 'spin');
// === 阶段4:灯光渐亮 ===
sceneTL.to(light, { intensity: 1.5, duration: 1.5 }, 'spin+=0.5')
.to(pointLight, { intensity: 2, duration: 1 }, 'spin+=1');
// === 阶段5:退场 ===
sceneTL.to([cube.scale, sphere.scale, torus.scale], {
x: 0, y: 0, z: 0,
duration: 0.6,
stagger: 0.1,
ease: 'power2.in'
}, 'exit');13.6 ScrollTrigger 驱动 3D 场景
这是本章最酷的部分——一边滚,一边飞。滚动轮变成了3D世界的遥控器:
const scroll3D = gsap.timeline({
scrollTrigger: {
trigger: '.threejs-section',
start: 'top top',
end: 'bottom bottom',
scrub: 1,
pin: true,
markers: false
}
});
// 滚动 = 相机推近
scroll3D.to(camera.position, { z: 2, duration: 1 }, 0);
// 滚动 = 立方体旋转
scroll3D.to(cube.rotation, {
x: Math.PI * 2,
y: Math.PI * 4,
duration: 1
}, 0);
// 滚动 = 光源强度变化
scroll3D.to(light, { intensity: 2, duration: 0.5 }, 0.3);13.7 完整代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>从补间到叙事:GSAP 动画实战 - 第13章:Three.js 与 GSAP</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC',
'Microsoft YaHei', sans-serif;
background: #000;
color: #fff;
overflow-x: hidden;
}
/* === Hero 3D区域 === */
.hero-3d {
position: relative;
width: 100%;
height: 100vh;
overflow: hidden;
}
.hero-3d canvas {
display: block;
}
.hero-overlay {
position: absolute;
bottom: 3rem;
left: 50%;
transform: translateX(-50%);
z-index: 2;
text-align: center;
pointer-events: none;
}
.hero-overlay h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
text-shadow: 0 4px 30px rgba(0,0,0,0.5);
}
.hero-overlay p {
font-size: 1rem;
color: rgba(255,255,255,0.7);
margin-top: 0.5rem;
}
/* === 滚动驱动区域 === */
.scroll-3d {
position: relative;
width: 100%;
height: 200vh; /* 需要足够的滚动距离 */
}
.scroll-3d .sticky-view {
position: sticky;
top: 0;
width: 100%;
height: 100vh;
overflow: hidden;
}
.scroll-indicator {
position: absolute;
right: 2rem;
top: 50%;
transform: translateY(-50%);
z-index: 2;
display: flex;
flex-direction: column;
gap: 1rem;
}
.scroll-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: rgba(255,255,255,0.2);
cursor: pointer;
transition: background 0.3s;
}
.scroll-dot.active {
background: #6C63FF;
}
/* === 控制面板 === */
.controls-panel {
position: absolute;
top: 2rem;
right: 2rem;
z-index: 2;
display: flex;
gap: 0.5rem;
}
.ctrl-btn {
padding: 0.5rem 1rem;
font-size: 0.85rem;
font-weight: 600;
color: #fff;
background: rgba(255,255,255,0.1);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 8px;
cursor: pointer;
backdrop-filter: blur(10px);
transition: all 0.3s;
}
.ctrl-btn:hover {
background: rgba(255,255,255,0.2);
border-color: rgba(255,255,255,0.4);
}
.ctrl-btn.active {
background: #6C63FF;
border-color: #6C63FF;
}
/* === 信息提示 === */
.tooltip {
position: absolute;
top: 2rem;
left: 2rem;
z-index: 2;
font-size: 0.85rem;
color: rgba(255,255,255,0.5);
}
</style>
</head>
<body>
<!-- ===== 3D Hero ===== -->
<section class="hero-3d" id="hero3D">
<div class="hero-overlay">
<h1>3D 遇见 GSAP</h1>
<p>滚动鼠标探索三维世界</p>
</div>
<div class="controls-panel">
<button class="ctrl-btn active" data-mode="cube">立方体</button>
<button class="ctrl-btn" data-mode="sphere">球体</button>
<button class="ctrl-btn" data-mode="torus">圆环</button>
</div>
<div class="tooltip">拖拽鼠标旋转视角</div>
</section>
<!-- ===== 滚动驱动区域 ===== -->
<section class="scroll-3d">
<div class="sticky-view" id="sticky3D"></div>
<div class="scroll-indicator" id="scrollDots">
<div class="scroll-dot active"></div>
<div class="scroll-dot"></div>
<div class="scroll-dot"></div>
<div class="scroll-dot"></div>
</div>
</section>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// ============================================
// GSAP 插件注册
// ============================================
gsap.registerPlugin(ScrollTrigger);
// ============================================
// 工具函数:创建渲染器
// ============================================
function createRenderer(container, alpha = false) {
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: alpha
});
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
container.appendChild(renderer.domElement);
return renderer;
}
// ============================================
// 场景1:Hero 3D 场景(带OrbitControls)
// ============================================
const heroContainer = document.getElementById('hero3D');
const heroRenderer = createRenderer(heroContainer, true);
const heroScene = new THREE.Scene();
// 相机
const heroCamera = new THREE.PerspectiveCamera(
60,
heroContainer.clientWidth / heroContainer.clientHeight,
0.1,
100
);
heroCamera.position.set(0, 1.5, 6);
heroCamera.lookAt(0, 0, 0);
const heroControls = new OrbitControls(heroCamera, heroRenderer.domElement);
heroControls.enableDamping = true;
heroControls.dampingFactor = 0.08;
heroControls.minDistance = 3;
heroControls.maxDistance = 12;
heroControls.maxPolarAngle = Math.PI / 1.8; // 限制俯视角
// 光照
const heroAmbient = new THREE.AmbientLight(0x404080, 0.5);
heroScene.add(heroAmbient);
const heroDirLight = new THREE.DirectionalLight(0xffffff, 2);
heroDirLight.position.set(5, 8, 5);
heroDirLight.castShadow = true;
heroDirLight.shadow.mapSize.set(1024, 1024);
heroScene.add(heroDirLight);
const heroPointLight = new THREE.PointLight(0x6C63FF, 15, 10);
heroPointLight.position.set(2, 2, 2);
heroScene.add(heroPointLight);
// 地面
const groundGeo = new THREE.PlaneGeometry(20, 20);
const groundMat = new THREE.MeshStandardMaterial({
color: 0x111122,
roughness: 0.8,
metalness: 0.2
});
const ground = new THREE.Mesh(groundGeo, groundMat);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -2.5;
ground.receiveShadow = true;
heroScene.add(ground);
// 网格粒子背景
const particlesGeo = new THREE.BufferGeometry();
const particlesCount = 500;
const posArray = new Float32Array(particlesCount * 3);
for (let i = 0; i < particlesCount * 3; i++) {
posArray[i] = (Math.random() - 0.5) * 15;
}
particlesGeo.setAttribute('position', new THREE.BufferAttribute(posArray, 3));
const particlesMat = new THREE.PointsMaterial({
size: 0.02,
color: 0x6C63FF,
transparent: true,
opacity: 0.6,
blending: THREE.AdditiveBlending
});
const particles = new THREE.Points(particlesGeo, particlesMat);
heroScene.add(particles);
// 主体对象组
const heroGroup = new THREE.Group();
heroScene.add(heroGroup);
// 创建立方体
const cubeGeo = new THREE.BoxGeometry(1.2, 1.2, 1.2, 8, 8, 8);
const cubeMat = new THREE.MeshStandardMaterial({
color: 0x6C63FF,
roughness: 0.2,
metalness: 0.8,
emissive: 0x1a1050,
emissiveIntensity: 0.3
});
const cube = new THREE.Mesh(cubeGeo, cubeMat);
cube.castShadow = true;
cube.receiveShadow = true;
heroGroup.add(cube);
// 创建球体
const sphereGeo = new THREE.SphereGeometry(0.7, 64, 64);
const sphereMat = new THREE.MeshStandardMaterial({
color: 0xFF6B6B,
roughness: 0.1,
metalness: 0.9,
emissive: 0x500010,
emissiveIntensity: 0.3
});
const sphere = new THREE.Mesh(sphereGeo, sphereMat);
sphere.position.x = 2;
sphere.castShadow = true;
sphere.receiveShadow = true;
heroGroup.add(sphere);
// 创建圆环
const torusGeo = new THREE.TorusGeometry(0.6, 0.2, 32, 64);
const torusMat = new THREE.MeshStandardMaterial({
color: 0x4ECDC4,
roughness: 0.15,
metalness: 0.85,
emissive: 0x001a18,
emissiveIntensity: 0.3
});
const torus = new THREE.Mesh(torusGeo, torusMat);
torus.position.x = -2;
torus.castShadow = true;
torus.receiveShadow = true;
heroGroup.add(torus);
// ============================================
// Hero 入场动画(Timeline编排)
// ============================================
const heroTL = gsap.timeline({ defaults: { ease: 'power3.out' } });
// 元素依次弹入
heroTL.from(cube.scale, {
x: 0, y: 0, z: 0,
duration: 1,
ease: 'elastic.out(1, 0.5)'
});
heroTL.from(sphere.scale, {
x: 0, y: 0, z: 0,
duration: 0.8,
ease: 'elastic.out(1, 0.4)'
}, '-=0.5');
heroTL.from(torus.scale, {
x: 0, y: 0, z: 0,
duration: 0.8,
ease: 'elastic.out(1, 0.4)'
}, '-=0.5');
// 光源渐亮
heroTL.from(heroPointLight, {
intensity: 0,
duration: 1.5
}, '-=0.8');
heroTL.from(heroDirLight, {
intensity: 0,
duration: 1.5
}, '-=1.2');
// 持续旋转动画(独立于Timeline)
gsap.to(heroGroup.rotation, {
y: Math.PI * 2,
duration: 20,
repeat: -1,
ease: 'none'
});
// 粒子缓慢飘动
gsap.to(particles.position, {
y: 2,
duration: 8,
repeat: -1,
yoyo: true,
ease: 'sine.inOut'
});
// ============================================
// 形状切换交互
// ============================================
const modeButtons = document.querySelectorAll('.ctrl-btn');
modeButtons.forEach(btn => {
btn.addEventListener('click', () => {
modeButtons.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const mode = btn.dataset.mode;
// 切换激活的几何体:改变缩放
const targetScale = (mesh, target) => {
gsap.to(mesh.scale, {
x: target ? 1.3 : 0.7,
y: target ? 1.3 : 0.7,
z: target ? 1.3 : 0.7,
duration: 0.6,
ease: 'elastic.out(1, 0.5)'
});
};
targetScale(cube, mode === 'cube');
targetScale(sphere, mode === 'sphere');
targetScale(torus, mode === 'torus');
});
});
// ============================================
// Hero 渲染循环
// ============================================
function animateHero() {
requestAnimationFrame(animateHero);
heroControls.update();
// 灯光随鼠标微动
const time = Date.now() * 0.001;
heroPointLight.intensity = 15 + Math.sin(time * 2) * 3;
heroRenderer.render(heroScene, heroCamera);
}
animateHero();
// Hero Resize
window.addEventListener('resize', () => {
heroCamera.aspect = heroContainer.clientWidth / heroContainer.clientHeight;
heroCamera.updateProjectionMatrix();
heroRenderer.setSize(heroContainer.clientWidth, heroContainer.clientHeight);
});
// ============================================
// 场景2:滚动驱动的3D场景
// ============================================
const stickyContainer = document.getElementById('sticky3D');
const scrollRenderer = createRenderer(stickyContainer);
scrollRenderer.domElement.style.position = 'absolute';
scrollRenderer.domElement.style.top = '0';
const scrollScene = new THREE.Scene();
scrollScene.background = new THREE.Color(0x0a0a1a);
// 相机
const scrollSection = document.querySelector('.scroll-3d');
const scrollCamera = new THREE.PerspectiveCamera(
50,
stickyContainer.clientWidth / stickyContainer.clientHeight,
0.1,
100
);
scrollCamera.position.set(0, 2, 10);
scrollCamera.lookAt(0, 0, 0);
// 光照
const scrollAmbient = new THREE.AmbientLight(0x404080, 0.3);
scrollScene.add(scrollAmbient);
const scrollDirLight = new THREE.DirectionalLight(0xffffff, 1.5);
scrollDirLight.position.set(5, 8, 5);
scrollScene.add(scrollDirLight);
const scrollPointLight = new THREE.PointLight(0xFF6B6B, 10, 15);
scrollPointLight.position.set(-3, 1, 3);
scrollScene.add(scrollPointLight);
// 旋转的环
const ringGroup = new THREE.Group();
scrollScene.add(ringGroup);
// 外环
const outerRing = new THREE.Mesh(
new THREE.TorusGeometry(2.5, 0.08, 32, 128),
new THREE.MeshStandardMaterial({
color: 0x6C63FF,
roughness: 0.1,
metalness: 0.9,
emissive: 0x1a1050,
emissiveIntensity: 0.5
})
);
ringGroup.add(outerRing);
// 中环
const midRing = new THREE.Mesh(
new THREE.TorusGeometry(1.8, 0.06, 32, 100),
new THREE.MeshStandardMaterial({
color: 0x4ECDC4,
roughness: 0.1,
metalness: 0.9,
emissive: 0x001a18,
emissiveIntensity: 0.4
})
);
midRing.rotation.x = Math.PI / 3;
ringGroup.add(midRing);
// 内环
const innerRing = new THREE.Mesh(
new THREE.TorusGeometry(1.1, 0.04, 32, 80),
new THREE.MeshStandardMaterial({
color: 0xFF6B6B,
roughness: 0.1,
metalness: 0.9,
emissive: 0x500010,
emissiveIntensity: 0.4
})
);
innerRing.rotation.x = -Math.PI / 4;
ringGroup.add(innerRing);
// 中心球体
const centerSphere = new THREE.Mesh(
new THREE.SphereGeometry(0.5, 64, 64),
new THREE.MeshStandardMaterial({
color: 0xFFFFFF,
roughness: 0.05,
metalness: 0.95,
emissive: 0x333333,
emissiveIntensity: 0.3
})
);
ringGroup.add(centerSphere);
// ============================================
// ScrollTrigger 驱动 3D 动画
// ============================================
const scrollTL = gsap.timeline({
scrollTrigger: {
trigger: '.scroll-3d',
start: 'top top',
end: 'bottom bottom',
scrub: 1,
markers: false
}
});
// Phase 0-0.25: 相机推近
scrollTL.to(scrollCamera.position, {
z: 4,
duration: 0.25
}, 0);
// Phase 0-0.25: 环组加速旋转
scrollTL.to(ringGroup.rotation, {
y: Math.PI * 2,
duration: 0.25
}, 0);
// Phase 0.25-0.5: 相机提升
scrollTL.to(scrollCamera.position, {
y: 6,
z: 5,
duration: 0.25
}, 0.25);
// Phase 0.25-0.5: 环倾斜反转
scrollTL.to(ringGroup.rotation, {
x: -Math.PI / 6,
duration: 0.25
}, 0.25);
// Phase 0.5-0.75: 光源强度变化
scrollTL.to(scrollPointLight, {
intensity: 0,
duration: 0.25
}, 0.5);
scrollTL.to(scrollDirLight, {
intensity: 3,
duration: 0.25
}, 0.5);
// Phase 0.5-0.75: 球体放大
scrollTL.to(centerSphere.scale, {
x: 2, y: 2, z: 2,
duration: 0.25
}, 0.5);
// Phase 0.75-1.0: 相机拉远,环散开
scrollTL.to(scrollCamera.position, {
z: 12,
y: 3,
duration: 0.25
}, 0.75);
scrollTL.to(outerRing.scale, {
x: 2, y: 2, z: 2,
duration: 0.25
}, 0.75);
scrollTL.to(ringGroup.rotation, {
x: 0,
y: ringGroup.rotation.y + Math.PI * 2,
duration: 0.25
}, 0.75);
// 实时更新进度点
const dots = document.querySelectorAll('.scroll-dot');
ScrollTrigger.create({
trigger: '.scroll-3d',
start: 'top top',
end: 'bottom bottom',
onUpdate: (self) => {
const activeIndex = Math.min(
Math.floor(self.progress * dots.length),
dots.length - 1
);
dots.forEach((dot, i) => {
dot.classList.toggle('active', i === activeIndex);
});
}
});
// ============================================
// 滚动场景渲染循环
// ============================================
function animateScroll() {
requestAnimationFrame(animateScroll);
// 连续轻轻旋转(与ScrollTrigger不冲突——ScrollTrigger只控制y旋转)
outerRing.rotation.z += 0.002;
midRing.rotation.z -= 0.003;
innerRing.rotation.z += 0.004;
scrollRenderer.render(scrollScene, scrollCamera);
}
animateScroll();
// 滚动场景 Resize
const scrollResize = () => {
scrollCamera.aspect = stickyContainer.clientWidth / stickyContainer.clientHeight;
scrollCamera.updateProjectionMatrix();
scrollRenderer.setSize(stickyContainer.clientWidth, stickyContainer.clientHeight);
};
window.addEventListener('resize', () => {
scrollResize();
ScrollTrigger.refresh();
});
</script>
</body>
</html>13.8 技术解析
GSAP 动画 Three.js 对象的本质
Three.js 对象的 position、rotation、scale 属性实际上是普通对象的引用(Vector3、Euler),GSAP 可以直接读取和写入这些对象的 .x、.y、.z 属性。GSAP 不需要"知道"这是 Three.js——它只是对普通JS对象做属性动画。对GSAP来说,mesh.position.x 和 div.style.left 没区别,都是数字从A变到B。
// 这行代码的本质:
gsap.to(mesh.position, { x: 5, duration: 2 });
// 等价于:
// t=0: mesh.position.x = 0
// t=0.5: mesh.position.x = 0.9 (power3.out 插值)
// t=1: mesh.position.x = 2.9
// t=1.5: mesh.position.x = 4.3
// t=2: mesh.position.x = 5OrbitControls 与 GSAP 共存
OrbitControls 控制相机旋转,GSAP 控制相机位置——两者可以共存,但需注意:同时修改相机位置可能导致冲突。
// OrbitControls 自动设置 lookAt 位置
// GSAP 动画 camera.position 时,OrbitControls 会"对抗"GSAP
// 解决:在GSAP动画相机的期间,暂停OrbitControls
heroControls.enabled = false;
gsap.to(heroCamera.position, { z: 3, duration: 2, onComplete: () => {
heroControls.enabled = true;
}});本章示例中,OrbitControls 只在Hero场景中使用,滚动驱动的场景没有Controls,因此不存在冲突。
13.9 常见陷阱
陷阱1:需要 onUpdate 手动标记 needsRender
症状:动画进行中,但3D场景画面不更新。物体应该动但画面静止。
原因:Three.js 只在 renderer.render() 调用时更新画面。如果 requestAnimationFrame 循环只在无动画时渲染,GSAP 动画不会触发渲染。
解决:确保渲染循环持续运行:
// ✅ 持续渲染(简单但有GPU开销)
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();或使用按需渲染:
// ✅ 按需渲染:GSAP动画期间渲染,静止时停止
let needsRender = true;
function render() {
if (!needsRender) {
requestAnimationFrame(render);
return;
}
renderer.render(scene, camera);
requestAnimationFrame(render);
}
render();
// 动画期间设置标志
const tl = gsap.timeline({
onUpdate: () => { needsRender = true; },
onComplete: () => { needsRender = false; }
});本章示例使用了持续渲染策略——对于有两个3D场景的页面,这是最简单可靠的做法。
陷阱2:GSAP 不能直接动画 BufferGeometry 属性
症状:gsap.to(mesh.geometry.attributes.position, {...}) 不报错但没效果。
原因:BufferGeometry 的属性存储在 Float32Array 中,GSAP 可以修改数组中的值,但 Three.js 不知道属性发生了变化——需要手动设置 needsUpdate = true。
解决:
// ❌ 直接动画 BufferGeometry——Three.js不会重绘
gsap.to(mesh.geometry.attributes.position, { /* ... */ });
// ✅ 使用 onUpdate 手动标记
gsap.to({ val: 0 }, {
val: 1,
duration: 2,
onUpdate: function() {
// 手动修改顶点位置
const positions = mesh.geometry.attributes.position.array;
for (let i = 0; i < positions.length; i++) {
// ...修改顶点
}
mesh.geometry.attributes.position.needsUpdate = true;
}
});对于复杂的BufferGeometry动画,更好的做法是用自定义属性对象,在 onUpdate 中重建或更新几何体。
陷阱3:GSAP Timeline 与 Three.js 的时钟冲突
GSAP 与 Three.js 的 Clock 并存不会自动造成冲突。常见问题是两个循环重复渲染,或两个系统同时写 camera.position / rotation 等同一属性。
// 统一渲染入口;GSAP 更新值,渲染器读取值。
function render() {
controls.update();
renderer.render(scene, camera);
}
gsap.ticker.add(render);
// 卸载:
gsap.ticker.remove(render);只需要展示过渡、没有持续交互的场景,也可在 Tween 的 onUpdate 中按需 render。不要再为同一过渡手工累加 delta;使用 AnimationMixer 时明确哪些对象/属性归它管理。