使用GSAP构建电影级3D滚动体验

本教程详细讲解如何使用GSAP、WebGL和Three.js创建沉浸式3D滚动体验,包含圆柱体着色器动画、粒子系统和相机路径控制等核心技术实现。

如何使用GSAP构建电影级3D滚动体验

在本教程中,我们将探索两个示例,展示GSAP如何作为3D环境的电影导演。通过将滚动运动与相机路径、光照和着色器驱动效果相连接,我们将静态场景转变为流畅的、类似故事的序列。

第一个演示专注于基于着色器的深度——一个被反应性粒子包围的旋转WebGL圆柱体,而第二个则将3D场景转变为具有移动相机和动画排版的滚动控制展示。

到最后,您将学习如何编排3D构图、缓动和时序,以创建对用户输入自然响应的沉浸式、电影启发的交互。

圆柱运动:着色器驱动的滚动动态

1. 设置GSAP和自定义缓动

我们将导入并注册ScrollTrigger、ScrollSmoother和CustomEase。

自定义缓动曲线对于控制滚动感觉至关重要——加速度的微小变化会显著影响视觉节奏。您可以使用GSAP文档中的缓动可视化器创建和视觉编辑缓动曲线。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { ScrollSmoother } from "gsap/ScrollSmoother"
import { CustomEase } from "gsap/CustomEase"

if (typeof window !== "undefined") {
  gsap.registerPlugin(ScrollTrigger, ScrollSmoother, CustomEase)

  CustomEase.create("cinematicSilk",   "0.45,0.05,0.55,0.95")
  CustomEase.create("cinematicSmooth", "0.25,0.1,0.25,1")
  CustomEase.create("cinematicFlow",   "0.33,0,0.2,1")
  CustomEase.create("cinematicLinear", "0.4,0,0.6,1")
}

2. 页面布局和ScrollSmoother设置

ScrollSmoother与包装器和内容容器一起工作。

WebGL画布固定在背景中,而平滑内容在其上方滚动。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<div className="fixed inset-0 z-0">
  <canvas ref={canvasRef} className="w-full h-full" />
</div>

<div className="fixed inset-0 pointer-events-none z-10 mix-blend-difference">
  {/* 与滚动同步的叠加文本 */}
</div>

<div ref={smoothWrapperRef} id="smooth-wrapper" className="relative z-20">
  <div ref={smoothContentRef} id="smooth-content">
    <div ref={containerRef} style={{ height: "500vh" }} />
  </div>
</div>

我们初始化平滑器:

1
2
3
4
5
6
7
const smoother = ScrollSmoother.create({
  wrapper:  smoothWrapperRef.current,
  content:  smoothContentRef.current,
  smooth:   4,
  smoothTouch: 0.1,
  effects:  false
})

3. 构建WebGL场景

我们将使用OGL设置渲染器、相机和场景。圆柱体显示图像图集(一个水平拼接多个图像的画布)。这使我们能够通过旋转单个网格无缝滚动多个图像。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const renderer = new Renderer({
  canvas: canvasRef.current,
  width:  window.innerWidth,
  height: window.innerHeight,
  dpr:    Math.min(window.devicePixelRatio, 2),
  alpha:  true
})
const gl = renderer.gl
gl.clearColor(0.95, 0.95, 0.95, 1)
gl.disable(gl.CULL_FACE)

const camera = new Camera(gl, { fov: 45 })
camera.position.set(0, 0, 8)

const scene = new Transform()
const geometry = createCylinderGeometry(gl, cylinderConfig)

我们动态创建图像图集:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")!
canvas.width  = imageConfig.width * images.length
canvas.height = imageConfig.height

images.forEach((img, i) => {
  drawImageCover(ctx, img, i * imageConfig.width, 0, imageConfig.width, imageConfig.height)
})

const texture = new Texture(gl, { minFilter: gl.LINEAR, magFilter: gl.LINEAR })
texture.image = canvas
texture.needsUpdate = true

然后将纹理附加到圆柱体着色器:

圆柱体着色器

圆柱体的着色器处理图像图集的UV映射和细微的表面颜色调制。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// cylinderVertex.glsl
attribute vec3 position;
attribute vec2 uv;

uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;

varying vec2 vUv;

void main() {
  vUv = uv;
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// cylinderFragment.glsl
precision highp float;

uniform sampler2D tMap;
varying vec2 vUv;

void main() {
  vec4 color = texture2D(tMap, vUv);
  gl_FragColor = color;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
const program = new Program(gl, {
  vertex:   cylinderVertex,
  fragment: cylinderFragment,
  uniforms: { tMap: { value: texture } },
  cullFace: null
})

const cylinder = new Mesh(gl, { geometry, program })
cylinder.setParent(scene)
cylinder.rotation.y = 0.5

4. 滚动驱动的电影时间轴

现在我们将使用ScrollTrigger将滚动连接到相机移动和圆柱体旋转。容器的高度:500vh为我们提供了足够的空间来分隔多个"镜头"。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
const cameraAnim = { x: 0, y: 0, z: 8 }

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: containerRef.current,
    start:   "top top",
    end:     "bottom bottom",
    scrub:   1
  }
})

tl.to(cameraAnim, { x: 0, y: 0, z: 8,   duration: 1,   ease: "cinematicSilk" })
  .to(cameraAnim, { x: 0, y: 5, z: 5,   duration: 1,   ease: "cinematicFlow" })
  .to(cameraAnim, { x: 1.5, y: 2, z: 2, duration: 2,   ease: "cinematicLinear" })
  .to(cameraAnim, { x: 0.5, y: 0, z: 0.8, duration: 3.5, ease: "power1.inOut" })
  .to(cameraAnim, { x: -6, y: -1, z: 8,  duration: 1,   ease: "cinematicSmooth" })

tl.to(cylinder.rotation, { y: "+=28.27", duration: 8.5, ease: "none" }, 0)

渲染循环:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const animate = () => {
  requestAnimationFrame(animate)

  camera.position.set(cameraAnim.x, cameraAnim.y, cameraAnim.z)
  camera.lookAt([0, 0, 0])

  const vel = cylinder.rotation.y - lastRotation
  lastRotation = cylinder.rotation.y

  renderer.render({ scene, camera })
}
animate()

5. 排版叠加

每个标题部分与滚动同步淡入淡出,将旅程划分为视觉章节。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
perspectives.forEach((perspective, i) => {
  const textEl = textRefs.current[i]
  if (!textEl) return

  const section = 100 / perspectives.length
  const start   = `${i * section}% top`
  const end     = `${(i + 1) * section}% top`

  gsap.timeline({
    scrollTrigger: {
      trigger: containerRef.current,
      start, end,
      scrub: 0.8
    }
  })
  .fromTo(textEl, { opacity: 0 }, { opacity: 1, duration: 0.2, ease: "cinematicSmooth" })
  .to(textEl,      { opacity: 1, duration: 0.6, ease: "none" })
  .to(textEl,      { opacity: 0, duration: 0.2, ease: "cinematicSmooth" })
})

6. 具有旋转惯性的粒子

为了强调运动,我们将添加围绕圆柱体轨道的细微基于线的粒子。当圆柱体旋转时,它们的不透明度增加,当减速时淡出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
for (let i = 0; i < particleConfig.numParticles; i++) {
  const { geometry, userData } = createParticleGeometry(gl, particleConfig, i, cylinderConfig.height)

  const program = new Program(gl, {
    vertex:   particleVertex,
    fragment: particleFragment,
    uniforms: { uColor: { value: [0,0,0] }, uOpacity: { value: 0.0 } },
    transparent: true,
    depthTest:   true
  })

  const particle = new Mesh(gl, { geometry, program, mode: gl.LINE_STRIP })
  particle.userData = userData
  particle.setParent(scene)
  particles.push(particle)
}

在渲染循环内部:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const inertiaFactor = 0.15
const decayFactor   = 0.92
momentum = momentum * decayFactor + vel * inertiaFactor

const isRotating = Math.abs(vel) > 0.0001
const speed      = Math.abs(vel) * 100

particles.forEach(p => {
  const target = isRotating ? Math.min(speed * 3, 0.95) : 0
  p.program.uniforms.uOpacity.value += (target - p.program.uniforms.uOpacity.value) * 0.15

  const rotationOffset = vel * p.userData.speed * 1.5
  p.userData.baseAngle += rotationOffset

  const positions = p.geometry.attributes.position.data as Float32Array
  for (let j = 0; j <= particleConfig.segments; j++) {
    const t = j / particleConfig.segments
    const angle = p.userData.baseAngle + p.userData.angleSpan * t
    positions[j*3 + 0] = Math.cos(angle) * p.userData.radius
    positions[j*3 + 1] = p.userData.baseY
    positions[j*3 + 2] = Math.sin(angle) * p.userData.radius
  }
  p.geometry.attributes.position.needsUpdate = true
})

粒子着色器

每个粒子线由沿弧定位点的顶点着色器和控制颜色与不透明度的片段着色器定义。

1
2
3
4
5
6
7
8
// particleVertex.glsl
attribute vec3 position;
uniform mat4 modelViewMatrix;
uniform mat4 projectionMatrix;

void main() {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
1
2
3
4
5
6
7
8
9
// particleFragment.glsl
precision highp float;

uniform vec3 uColor;
uniform float uOpacity;

void main() {
  gl_FragColor = vec4(uColor, uOpacity);
}

场景指导:Three.js中的滚动控制叙事

1. GSAP设置

在客户端注册插件一次。我们将使用ScrollTrigger、ScrollSmoother和SplitText来编排相机移动和文本节拍。

1
2
3
4
5
6
7
8
import gsap from "gsap"
import { ScrollTrigger } from "gsap/ScrollTrigger"
import { ScrollSmoother } from "gsap/ScrollSmoother"
import { SplitText } from "gsap/SplitText"

if (typeof window !== "undefined") {
  gsap.registerPlugin(ScrollTrigger, ScrollSmoother, SplitText)
}

2. 页面布局 + ScrollSmoother

我们将3D画布固定在后面,顶部叠加UI(滚动提示 + 进度),并用#smooth-wrapper / #smooth-content包装长内容区域以启用平滑。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<div className="fixed inset-0 w-full h-screen z-0">
  <Canvas /* R3F画布选项 */> ... </Canvas>
</div>

{/* 左侧滚动提示和底部进度条叠加在这里 */}

<div ref={smoothWrapperRef} id="smooth-wrapper" className="relative z-20">
  <div ref={smoothContentRef} id="smooth-content">
    <div ref={containerRef} style={{ height: "900vh" }} />
  </div>
</div>

激活平滑:

1
2
3
4
5
6
7
ScrollSmoother.create({
  wrapper:  smoothWrapperRef.current!,
  content:  smoothContentRef.current!,
  smooth:   4,
  effects:  false,
  smoothTouch: 2,
})

3. 3D场景 (R3F + drei)

我们挂载一个可以在每帧更新的PerspectiveCamera,添加雾效以增加深度,并为建筑打光。建筑模型使用useGLTF加载并轻微变换。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
function CyberpunkBuilding() {
  const { scene } = useGLTF("/cyberpunk_skyscraper.glb")
  useEffect(() => {
    if (scene) {
      scene.scale.set(3, 3, 3)
      scene.position.set(0, 0, 0)
    }
  }, [scene])
  return <primitive object={scene} />
}

function AnimatedCamera({ cameraAnimRef, targetAnimRef }: any) {
  const cameraRef = useRef<any>(null)
  const { set } = useThree()
  useEffect(() => {
    if (cameraRef.current) set({ camera: cameraRef.current })
  }, [set])
  useFrame(() => {
    if (cameraRef.current) {
      cameraRef.current.position.set(
        cameraAnimRef.current.x,
        cameraAnimRef.current.y,
        cameraAnimRef.current.z
      )
      cameraRef.current.lookAt(
        targetAnimRef.current.x,
        targetAnimRef.current.y,
        targetAnimRef.current.z
      )
    }
  })
  return <PerspectiveCamera ref={cameraRef} makeDefault fov={45} near={1} far={1000} position={[0, 5, 10]} />
}

function Scene({ cameraAnimRef, targetAnimRef }: any) {
  const { scene } = useThree()
  useEffect(() => {
    if (scene) {
      const fogColor = new THREE.Color("#0a0a0a")
      scene.fog = new THREE.Fog(fogColor, 12, 28)
      scene.background = new THREE.Color("#0a0a0a")
    }
  }, [scene])
  return (
    <>
      <AnimatedCamera cameraAnimRef={cameraAnimRef} targetAnimRef={targetAnimRef} />
      <ambientLight intensity={0.4} />
      <directionalLight position={[10, 20, 10]} intensity={1.2} castShadow />
      <directionalLight position={[-10, 10, -10]} intensity={0.6} />
      <pointLight position={[0, 50, 20]} intensity={0.8} color="#00ffff" />
      <CyberpunkBuilding />
    </>
  )
}

4. 由滚动驱动的相机时间轴

我们保持两个可变引用:cameraAnimRef(相机位置)和targetAnimRef(注视点)。单个时间轴将场景片段(来自scenePerspectives配置)映射到滚动进度。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const cameraAnimRef = useRef({ x: -20, y: 0,  z: 0 })
const targetAnimRef = useRef({ x:   0, y: 15, z: 0 })

const tl = gsap.timeline({
  scrollTrigger: {
    trigger: containerRef.current,
    start: "top top",
    end:   "bottom bottom",
    scrub: true,
    onUpdate: (self) => {
      const progress = self.progress * 100
      setProgressWidth(progress)      // quickSetter用于宽度%
      setProgressText(String(Math.round(progress)).padStart(3, "0") + "%")
    }
  }
})

scenePerspectives.forEach((p) => {
  const start = p.scrollProgress.start / 100
  const end   = p.scrollProgress.end   / 100
  tl.to(cameraAnimRef.current, { x: p.camera.x, y: p.camera.y, z: p.camera.z, duration: end - start, ease: "none" }, start)
  tl.to(targetAnimRef.current, { x: p.target.x, y: p.target.y, z: p.target.z, duration: end - start, ease: "none" }, start)
})

5. SplitText章节提示

对于每个视角,我们将文本块放置在其配置派生的屏幕位置,并用小交错动画将字符移入/移出。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
scenePerspectives.forEach((p, index) => {
  const textEl = textRefs.current[index]
  if (!textEl) return
  if (p.hideText) { gsap.set(textEl, { opacity: 0, pointerEvents: "none" }); return }

  const titleEl = textEl.querySelector("h2")
  const subtitleEl = textEl.querySelector("p")
  if (titleEl && subtitleEl) {
    const titleSplit = new SplitText(titleEl, { type: "chars" })
    const subtitleSplit = new SplitText(subtitleEl, { type: "chars" })
    splitInstancesRef.current.push(titleSplit, subtitleSplit)

    const textTl = gsap.timeline({
      scrollTrigger: {
        trigger: containerRef.current,
        start: `${p.scrollProgress.start}% top`,
        end:   `${p.scrollProgress.end}% top`,
        scrub: 0.5,
      }
    })

    const isFirst = index === 0
    const isLast  = index === scenePerspectives.length - 1

    if (isFirst) {
      gsap.set([titleSplit.chars, subtitleSplit.chars], { x: 0, opacity: 1 })
      textTl.to([titleSplit.chars, subtitleSplit.chars], {
        x: 100, opacity: 0, duration: 1, stagger: 0.02, ease: "power2.in"
      })
    } else {
      textTl
        .fromTo([titleSplit.chars, subtitleSplit.chars],
          { x: -100, opacity: 0 },
          {
            x: 0, opacity: 1,
            duration: isLast ? 0.2 : 0.25,
            stagger:  isLast ? 0.01 : 0.02,
            ease: "power2.out"
          }
        )
        .to({}, { duration: isLast ? 1.0 : 0.5 })
        .to([titleSplit.chars, subtitleSplit.chars], {
          x: 100, opacity: 0, duration: 0.25, stagger: 0.02, ease: "power2.in"
        })
    }
  }
})

6. 叠加UI:滚动提示 + 进度

左侧的最小滚动提示和底部的居中进度条。我们使用gsap.quickSetter从ScrollTrigger的onUpdate高效更新宽度和标签。

1
2
3
4
const setProgressWidth = gsap.quickSetter(progressBarRef.current, "width", "%")
const setProgressText  = gsap.quickSetter(progressTextRef.current, "textContent")

// ... 在上面ScrollTrigger的onUpdate()内部使用

结论

本教程到此结束。您已经看到滚动运动如何塑造场景,时序和缓动如何暗示节奏,以及相机移动如何将静态布局转变为感觉有意和电影化的东西。使用GSAP,所有内容都保持灵活和流畅——每个动作都变得更容易控制和优化。

这里的技术只是一个起点。尝试转移焦点、减慢速度或夸大过渡,看看它如何改变情绪。将滚动视为导演的提示,引导观众的注意力穿越空间、光线和运动。

最终,使这些体验引人入胜的不是代码的复杂性,而是您创造的流动感。继续实验,保持好奇心,让您的下一个项目一次滚动一个地讲述它的故事。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计