返回目录

从补间到叙事: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 = 5

OrbitControls 与 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 时明确哪些对象/属性归它管理。