声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

摘要

浏览网页时,常被一些基于鼠标滚轮控制的页面动画所惊艳到,比如greensock 官网这些 showcase 案例页面就非常优秀,它们大多数都是使用 Tween.jsgaspgreensock 提供的一些动画扩展库实现的。使用 Three.js 也能很容易实现丝滑的滚动效果,本文使用 React + Three.js + React Three Fiber 技术栈,实现一个《塞尔达传说:王国之泪》主题风格基于滚动控制的平滑滚动图片展示页面。通过本文的阅读,你将学习到的知识点包括:了解 R3FuseFrame hookuseThree hook 基本原理及用法;了解 @react-three/drei 库的基本组成,学习使用它提供的 PreloaduseIntersectScrollControlsScroll、及 Image 等组件和方法;用 CSS 生成简单的循环悬浮动画等。

效果

本文案例的实现效果如下图所示,当页面每个模块滚动进入视区时,每个模块会具有平滑向上移动的视差效果,并且伴随着由大到小的缩放动画,当鼠标悬浮到当前模块时,模块会产生高亮 效果。除此之外,页面还有一些其他的装饰,比如塞尔达风格的页面背景和边框、具有缓动动画效果的希卡之石以及同样具有平滑滚动效果的文字装饰王国之泪四个字。

页面的整体布局是这样的,总共有 7 页,即高度为 700vh,每一页都具有不同的布局风格样式,滚动时都会具有缓动效果。

打开以下链接,在线预览效果,本文中的 gif 造成丢帧和画质损失,大屏访问效果更佳。

本专栏系列代码托管在 Github 仓库【threejs-odessey】后续所有目录也都将在此仓库中更新

原理

本文是使用 React Three Fiber 实现的,它不仅可以非常容易实现漂亮的三维图形,在二维平面页面开发中也能大放异彩。在开始实现本文案例之前,我们先来汇总下本文中需要应用到的知识点。掌握这些原理和方法,可以帮助我们迅速构建一个交互体验极佳的平滑滚动页面。

useFrame

hook 允许在页面每一帧渲染的时候运行代码,比如更新渲染效果、控件等,与 Three.js 中调用 requestAnimationFrame 实行重绘动画效果是一样的。你将接收到状态值 state 和时钟增量 delta。回调函数将在渲染帧之前被调用,当组件卸载时,它会自动从渲染循环中注销。
·

useFrame((state, delta, xrFrame) => {
  // 此函数在共享渲染循环内以本机刷新率运行
});

控制渲染循序

如果你需要更多的控制,你可以传递一个数字渲染优先级值。这将导致 React Three Fiber 完全禁用自动渲染。现在,渲染顺序将由我们自己控制,这在后期渲染通道处理以及在多个视图渲染的场景下非常有用。

function Render() {
  // 控制渲染顺序
  useFrame(({ gl, scene, camera }) => {
    gl.render(scene, camera)
  }, 1)

function RenderOnTop() {
  // 这里将在上面 Render 方法的 useFrame 之后执行。
  useFrame(({ gl, ... }) => {
    gl.render(...)
  }, 2)

负索引

使用负索引无法接管渲染循环控制,但如果确实必须对组件树中的 useFrame 序列进行排序,使用负索引将很有用。

function A() {
  // 此处将先执行
  useFrame(() => ..., -2)

function B() {
  // 此处将在A的 useFrame 之后执行
  useFrame(() => ..., -1)

useThree

hook 允许访问的状态模型包括默认渲染器 renderer 、场景 scene、相机 camera 等。它还提当前画布 canvas 在屏幕和视区中的坐标位置大小。它是动态自适应的,如果调整浏览器大小,将返回新的测量值,它适用于所有可能更改的状态对象。

import { useThree } from '@react-three/fiber'

function Foo() {
  const state = useThree()

State 属性值

选择属性

可以通过选择属性,避免对仅对关注的组件进行不必要的重新渲染,需要注意的是无法响应式地获得 Three.js 深层次的动态属性。

// 仅当默认相机发生变化时会触发重新渲染
const camera = useThree((state) => state.camera)
// 仅当尺寸发生变化时会触发
const viewport = useThree((state) => state.viewport)
// ❌ 不能响应式地获得three.js深层次地属性值变化
const zoom = useThree((state) => state.camera.zoom)

从组件循环外部读取状态

function Foo() {
  const get = useThree((state) => state.get)
  ...
  get() // 在任意位置获取最新状态

交换默认值

function Foo() {
  const set = useThree((state) => state.set)
  ...
  useEffect(() => {
    set({ camera: new THREE.OrthographicCamera(...) })
  }, [])

Three.js 进阶之旅:页面平滑滚动-王国之泪 💧-LMLPHP

@react-three/drei

@react-three/drei 是一个正在不断扩充的,用于 @react-three/fiber 的由实用的辅助工具、完整的功能性方法以及现成的抽象构成的库。可以通过如下方法进行安装。下图列出了当前该仓库中包含的所有组件和方法,本文案例中将通过 @react-three/drei 的以下几个组件来实现平滑滚动效果。

npm install @react-three/drei

Preload

WebGLRenderer 只有材质被触发时才会进行编译,这可能会导致卡顿。此组件使用 gl.compile 预编译场景,确保应用从一开始就具有响应性。默认情况下,gl.compile 只会预加载可见对象,如果你提供了所有属性,它们可能会被忽略。

<Canvas>
  <Suspense fallback={null}>
    <Model />
    <Preload all />

useIntersect

它可以非常方便的检测三维元素是否可见,当对象进入视图或者处于视图之外时,可以通过它获得对象的可见性参考值,useIntersect 依赖于 THREE.Object3D.onBeforeRender 实现,因此它仅适用于有效渲染的对象,如 meshlinesprite等,在 groupobject3D 的骨骼中无法生效。

const ref = useIntersect((visible) => console.log('object is visible', visible))
return <mesh ref={ref} />

ScrollControls 和 Scroll

ScrollControls 可以在 canvas 前方创建一个 HTML 滚动容器,你放入滚动组件 <Scroll> 中的所有元素都将受到影响。你可以使用 useScroll 钩子对滚动事件进行监听并响应,它提供很多有用的数据,例如当前的滚动偏移量、增量以及用于范围查找的函数:rangecurvevisible。如果需要对滚动偏移做出响应,如对象进入或移除视图时添加淡入淡出效果等,则后面的方法非常有用。

ScrollControls 的可配置属性:

type ScrollControlsProps = {
  // 精度,默认值 0.00001
  eps?: number
  // 是否水平滚动,默认为 false,垂直滚动
  horizontal?: boolean
  // 是否开启无限滚动,默认为 false,该属性是实验性的
  infinite?: boolean
  // 定义滚动区域大小,每个 page 的高度是 100%,默认为 1
  pages?: number
  // 用于增加滚动间距的参数,默认为 1
  distance?: number
  // 滚动阻尼系数,以秒为单位,默认为 0.2
  damping?: number
  // 用于限制最大滚动速度,默认值为 Infinite
  maxSpeed?: number
  // 是否开启
  enabled?: boolean
  style?: React.CSSProperties
  children: React.ReactNode
}

可以像下面这样使用:

<ScrollControls pages={3} damping={0.1}>
  {/* 此处 Canvas 的内容不会滚动,但是可以接收 useScroll! */}
  <SomeModel />
  <Scroll>
    {/* 此处 Canvas 内容将产生滚动 */}
    <Foo position={[0, 0, 0]} />
    <Foo position={[0, viewport.height, 0]} />
    <Foo position={[0, viewport.height * 1, 0]} />
  </Scroll>
  <Scroll html>
    {/* 此处 DOM 内容将产生滚动 */}
    <h1>html in here (optional)</h1>
    <h1 style={{ top: '100vh' }}>second page</h1>
    <h1 style={{ top: '200vh' }}>third page</h1>
  </Scroll>
</ScrollControls>
function Foo(props) {
  const ref = useRef()
  // 通过 useScroll 钩子对滚动事件进行监听并响应
  const data = useScroll()
  useFrame(() => {
    // data.offset:当前滚动位置,介于 0 和 1 之间,受阻尼系数影响
    // data.delta:当前增量,介于 0 和 1 之间,受阻尼系数影响

    // 当滚动条处于起始位置时为 0,当达到滚动距离的 1/3 时,将增加到 1
    const a = data.range(0, 1 / 3)
    // 当达到滚动距离的 1/3 时将开始增加,当滚动到 2/3 时,将增加到 1
    const b = data.range(1 / 3, 1 / 3)
    // 与上述相同,但是两边的余量均为 0.1
    const c = data.range(1 / 3, 1 / 3, 0.1)
    // 将在所选范围的 0-1-0 之间移动
    const d = data.curve(1 / 3, 1 / 3)
    // 与上述相同,但是两边的余量均为 0.1
    const e = data.curve(1 / 3, 1 / 3, 0.1)
    // 如果偏移量在范围内,则返回 true,如果偏移量不在范围内,则返回 false。
    const f = data.visible(2 / 3, 1 / 3)
    // visible 方法同样可以接收一个余量参数
    const g = data.visible(2 / 3, 1 / 3, 0.1)
  })
  return <mesh ref={ref} {...props} />
}

Image

是一个自动开启平铺效果的基于着色器的图片组件,图片填充效果类似于 CSS 中的 background-size: cover;

function Foo() {
  const ref = useRef()
  useFrame(() => {
    ref.current.material.zoom = ...         // 1 或更大
    ref.current.material.grayscale = ...    // 介于 0 和 1 之间
    ref.current.material.color.set(...)     // 混合颜色
  })
  return <Image ref={ref} url="/file.jpg" />
}

给材质增加透明度:

<Image url="/file.jpg" transparent opacity={0.5} />

Three.js 进阶之旅:页面平滑滚动-王国之泪 &#128167;-LMLPHP

实现

现在,我们就应用上述原理知识,实现预览效果所示的 《塞尔达传说:王国之泪》 主题的平滑滚动页面。

资源引入

原理篇幅 👆 已经详细讲解了本文用到的功能库和组件,我们在代码顶部像下面这样引入它们。

import * as THREE from 'three'
import { Suspense, useRef, useState } from 'react'
import { Canvas, useFrame, useThree } from '@react-three/fiber'
import { Preload, useIntersect, ScrollControls, Scroll, Image as ImageImpl } from '@react-three/drei'

场景初始化

专栏文章《Three.js 进阶之旅:物理效果-3D乒乓球小游戏》已经详细介绍过 React Three Fiber 入门知识。使用 R3F 初始化三维场景非常简单,像下面这样一行代码就能完成场景初始化。本次实现不需要精细的三维效果,因此渲染器的抗锯齿属性 antialias 可以设置为 false

<Canvas gl={{ antialias: false }} dpr={[1, 1.5]}></Canvas>

页面装饰

接着,使用 R3F 实现平滑的滚动效果之前,我们先来装饰一下页面,为了符合 《塞尔达传说:王国之类》 的主题,我在本页面中添加了游戏主题背景、边框、以及 希卡之石 动画。由于这些内容不是本文的重点,本文不再赘述,具体实现可以查看源码 📜

Three.js 进阶之旅:页面平滑滚动-王国之泪 &#128167;-LMLPHP

<>
  <Canvas gl={{ antialias: false }} dpr={[1, 1.5]}></Canvas>
  <div className='sheikah-box'></div>
</>

首屏页面

首屏页面主要有 2 个元素,一个是背景图、另一个是 ZELDA 图片 logo,当页面加载时背景图有一个由大到小的缩放效果,鼠标悬浮到图片上时,当前鼠标所处图片会变为高亮状态。它们我们可以使用原理部分了解到的 Image 元素实现。

Three.js 进阶之旅:页面平滑滚动-王国之泪 &#128167;-LMLPHP

图片组件封装

页面其他图片采用的动画效果也是类似的,为了可以复用,我们先对 Image 元素封装一下,将由大到小的缩放效果以及鼠标悬浮的 hover 高亮效果添加到每个 Image 元素中:

function Image({ c = new THREE.Color(), ...props }) {
  const visible = useRef(false)
  const [hovered, hover] = useState(false)
  const ref = useIntersect((isVisible) => (visible.current = isVisible))
  useFrame((state, delta) => {
    // 鼠标悬浮时的图片材质颜色变化
    ref.current.material.color.lerp(c.set(hovered ? '#fff' : '#ccc'), hovered ? 0.4 : 0.05);
    // 图片滚动到视区时大小缩放变化
    ref.current.material.zoom = THREE.MathUtils.damp(ref.current.material.zoom, visible.current ? 1 : 4, 4, delta)
  })
  return <ImageImpl ref={ref} onPointerOver={() => hover(true)} onPointerOut={() => hover(false)} {...props} />
}

然后我们再封装一个名为 Imagesgroup 元素,用来统一管理页面上的所有图片,分别设置每个图片的链接、在页面上的位置、大小、透明度等一些个性化属性。

function Images() {
  const { width, height } = useThree((state) => state.viewport);
  const group = useRef();
  return (
    <group ref={group}>
      // 背景图片
      <Image position={[0, 0, 0]} scale={[width, height, 1]} url="/images/0.jpg" />
      // logo 图片
      <Image position={[0, 0, 1]} scale={3.2} url="/images/banner.png" transparent={true} />
    </group>
  )
}

图片组件使用

然后,使用 <ScrollControls><Scroll> 来直接生成滚动页面,在其中添加上述封装的 <Image> 组件,也可以在其中添加一些装饰性文字 王国之泪,同样可以进行滚动控制,使用 Preload 进行预加载,提升页面渲染性能。

<Canvas gl={{ antialias: false }} dpr={[1, 1.5]}>
  <Suspense fallback={null}>
    <ScrollControls damping={1} pages={7}>
      <Scroll>
        <Images />
      </Scroll>
      <Scroll html>
        <h1 className='text'>王</h1>
        <h1 className='text'>国</h1>
        <h1 className='text'>之</h1>
        <h1 className='text'>泪</h1>
      </Scroll>
    </ScrollControls>
    <Preload />
  </Suspense>
</Canvas>

此时我们可以看看实现效果,首先是图片元素进入视区时由大到小的缩放动画效果。中间的透明 logo 图片似乎有点问题,我们可以像下面这样修复 😂

Three.js 进阶之旅:页面平滑滚动-王国之泪 &#128167;-LMLPHP

function Images() {
  useFrame(() => {
    // 取消 zelda logo 缩放动画
    group.current.children[1].material.zoom = 1;
  });
  // ...
}

鼠标悬浮高亮效果:

Three.js 进阶之旅:页面平滑滚动-王国之泪 &#128167;-LMLPHP

页面平滑滚动:

Three.js 进阶之旅:页面平滑滚动-王国之泪 &#128167;-LMLPHP

其他页面

到这里,其他页面的实现就非常简单了,我们只需按自己的页面设计,在 Images 中像下面这样排好页面上所有需要平滑滚动的图片即可,可以通过 positionscale 等属性设置个性化调整图片在页面上的位置、大小、加载时机等,比如本文示例中第 2 页有 3 张图片、第 3 页有 1 张图片……

function Images() {
  return (
    <group ref={group}>
      {/* 第1页 */}
      <Image position={[0, 0, 0]} scale={[width, height, 1]} url="./images/0.jpg" />
      <Image position={[0, 0, 1]} scale={3.2} url="./images/banner.png" transparent={true} />
      {/* 第2页 */}
      <Image position={[-2.5, -height + 1, 2]} scale={3} url="./images/1.jpg" />
      <Image position={[0, -height, 3]} scale={2} url="./images/2.jpg" />
      <Image position={[1.25, -height - 1, 3.5]} scale={1.5} url="./images/3.jpg" />
      {/* 第3页 */}
      <Image position={[0, -height * 1.5, 2.5]} scale={[6, 3, 1]} url="./images/4.jpg" />
      {/* 第3页 */}
      <Image position={[0, -height * 2 - height / 4, 0]} scale={[width, height, 1]} url="./images/5.jpg" />
      {/* ... */}
    </group>
  )
}

下图是本文示例所有图片的页面布局,总共有 7 页,每页图片都有不同的排版样式。

Three.js 进阶之旅:页面平滑滚动-王国之泪 &#128167;-LMLPHP

结束页面

最后一张页面,林克 由小变大平滑滚动进入视区,与背景形成视差效果,是通过调整它的 position.z 来实现这一效果的,大家在动手实践时可以尝试设置不同的值,以达到自己的预期效果。

Three.js 进阶之旅:页面平滑滚动-王国之泪 &#128167;-LMLPHP

总结

本文中主要包含的知识点包括:

  • 了解 useFrame hook 基本原理及使用它控制渲染顺序和使用负索引。
  • 了解 useThree hook 基本原理、基本属性值,使用它选择属性、从组件循环外部读取状态、交换默认值等。
  • 了解 @react-three/drei 库的基本组成,学习使用它提供的 PreloaduseIntersectScrollControlsScroll、及 Image 等组件和方法。
  • CSS 生成简单的循环悬浮动画。
  • 使用上述 R3F 知识原理,生成一个具有视差效果的平滑滚动页面。

Three.js 进阶之旅:页面平滑滚动-王国之泪 &#128167;-LMLPHP

附录

参考

Three.js 进阶之旅:页面平滑滚动-王国之泪 &#128167;-LMLPHP

05-06 18:59