Three.js 進階之旅:頁面平滑滾動-王國之淚 ?

dragonir發表於2023-05-06

宣告:本文涉及圖文和模型素材僅用於個人學習、研究和欣賞,請勿二次修改、非法傳播、轉載、出版、商用、及進行其他獲利行為。

摘要

瀏覽網頁時,常被一些基於滑鼠滾輪控制的頁面動畫所驚豔到,比如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】後續所有目錄也都將在此倉庫中更新

? 程式碼倉庫地址:git@github.com:dragonir/threejs-odessey.git

原理

本文是使用 React Three Fiber 實現的,它不僅可以非常容易實現漂亮的三維圖形,在二維平面頁面開發中也能大放異彩。在開始實現本文案例之前,我們先來彙總下本文中需要應用到的知識點。掌握這些原理和方法,可以幫助我們迅速構建一個互動體驗極佳的平滑滾動頁面。

useFrame

hook 允許在頁面每一幀渲染的時候執行程式碼,比如更新渲染效果、控制元件等,與 Three.js 中呼叫 requestAnimationFrame 實行重繪動畫效果是一樣的。你將接收到狀態值 state 和時鐘增量 delta。回撥函式將在渲染幀之前被呼叫,當元件解除安裝時,它會自動從渲染迴圈中登出。
·

useFrame((state, delta, xrFrame) => {
  // 此函式在共享渲染迴圈內以本機重新整理率執行
});
? 注意,在 useFrame 中不能使用 setState 更新狀態值!

控制渲染循序

如果你需要更多的控制,你可以傳遞一個數字渲染優先順序值。這將導致 React Three Fiber 完全禁用自動渲染。現在,渲染順序將由我們自己控制,這在後期渲染通道處理以及在多個檢視渲染的場景下非常有用。

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

function RenderOnTop() {
  // 這裡將在上面 Render 方法的 useFrame 之後執行。
  useFrame(({ gl, ... }) => {
    gl.render(...)
  }, 2)
? 回撥將按優先順序值的升序(最低在前,最高在後)執行,類似於 DOM 的層級順序。

負索引

使用負索引無法接管渲染迴圈控制,但如果確實必須對元件樹中的 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 屬性值

屬性描述型別
glRendererTHREE.WebGLRenderer
sceneSceneTHREE.Scene
cameraCameraTHREE.PerspectiveCamera
raycaster預設 raycasterTHREE.Raycaster
pointer包含更新的、規範化的、居中的指標座標THREE.Vector2
mouse已被棄用,可以使用 pointer 代替處理座標THREE.Vector2
clock正在執行的系統時鐘THREE.Clock
linear當色彩空間為線性時為 trueboolean
flat未使用色調對映時為 trueboolean
legacy透過 THREE.ColorManagement 禁用全域性色彩管理boolean
frameloop渲染模式: always, demand, neveralways, demand, never
performance系統迴歸{ current: number, min: number, max: number, debounce: number, regress: () => void }
sizeCanvas 畫素值尺寸{ width: number, height: number, top: number, left: number, updateStyle?: boolean }
viewportthree.js 中視區的尺寸{ width: number, height: number, initialDpr: number, dpr: number, factor: number, distance: number, aspect: number, getCurrentViewport: (camera?: Camera, target?: THREE.Vector3, size?: Size) => Viewport }
xrXR 介面, 管理 WebXR 渲染{ connect: () => void, disconnect: () => void }
set允許設定任何狀態屬性(state: SetState<RootState>) => void
get允許獲取任意非響應式的狀態屬性() => GetState<RootState>
invalidate請求新的渲染, 相當於 frameloop === 'demand'() => void
advance前進一個 tick, 相當於 frameloop === 'never'(timestamp: number, runGlobalEffects?: boolean) => void
setSize調整畫布大小(width: number, height: number, updateStyle?: boolean, top?: number, left?: number) => void
setDpr設定畫素比(dpr: number) => void
setFrameloop設定當前渲染模式的快捷方式(frameloop?: 'always', 'demand', 'never') => void
setEvents設定事件圖層的快捷方式(events: Partial<EventManager<any>>) => void
onPointerMissed對未命中目標的指標單擊的響應() => void
events指標事件處理{ connected: TargetNode, handlers: Events, connect: (target: TargetNode) => void, disconnect: () => void }

選擇屬性

可以透過選擇屬性,避免對僅對關注的元件進行不必要的重新渲染,需要注意的是無法響應式地獲得 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(...) })
  }, [])

@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} />

實現

現在,我們就應用上述原理知識,實現預覽效果所示的 《塞爾達傳說:王國之淚》 主題的平滑滾動頁面。

資源引入

原理篇幅 ? 已經詳細講解了本文用到的功能庫和元件,我們在程式碼頂部像下面這樣引入它們。

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>

? 為了可以看見 canvas 畫布區域,在 css 中設定了一個漸變背景色。

頁面裝飾

接著,使用 R3F 實現平滑的滾動效果之前,我們先來裝飾一下頁面,為了符合 《塞爾達傳說:王國之類》 的主題,我在本頁面中新增了遊戲主題背景、邊框、以及 希卡之石 動畫。由於這些內容不是本文的重點,本文不再贅述,具體實現可以檢視原始碼 ?

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

首屏頁面

首屏頁面主要有 2 個元素,一個是背景圖、另一個是 ZELDA 圖片 logo,當頁面載入時背景圖有一個由大到小的縮放效果,滑鼠懸浮到圖片上時,當前滑鼠所處圖片會變為高亮狀態。它們我們可以使用原理部分了解到的 Image 元素實現。

圖片元件封裝

頁面其他圖片採用的動畫效果也是類似的,為了可以複用,我們先對 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 圖片似乎有點問題,我們可以像下面這樣修復 ?

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

滑鼠懸浮高亮效果:

頁面平滑滾動:

其他頁面

到這裡,其他頁面的實現就非常簡單了,我們只需按自己的頁面設計,在 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 頁,每頁圖片都有不同的排版樣式。

結束頁面

最後一張頁面,林克 由小變大平滑滾動進入視區,與背景形成視差效果,是透過調整它的 position.z 來實現這一效果的,大家在動手實踐時可以嘗試設定不同的值,以達到自己的預期效果。

? 原始碼地址: https://github.com/dragonir/threejs-odessey

總結

本文中主要包含的知識點包括:

  • 瞭解 useFrame hook 基本原理及使用它控制渲染順序和使用負索引。
  • 瞭解 useThree hook 基本原理、基本屬性值,使用它選擇屬性、從元件迴圈外部讀取狀態、交換預設值等。
  • 瞭解 @react-three/drei 庫的基本組成,學習使用它提供的 PreloaduseIntersectScrollControlsScroll、及 Image 等元件和方法。
  • CSS 生成簡單的迴圈懸浮動畫。
  • 使用上述 R3F 知識原理,生成一個具有視差效果的平滑滾動頁面。

附錄

參考

相關文章