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

摘要
瀏覽網頁時,常被一些基于滑鼠滾輪控制的頁面影片所驚艷到,比如greensock 官網這些 showcase 案例頁面就非常優秀,它們大多數都是使用 Tween.js、gasp 及 greensock 提供的一些影片擴展庫實作的,使用 Three.js 也能很容易實作絲滑的滾動效果,本文使用 React + Three.js + React Three Fiber 技術堆疊,實作一個《塞爾達傳說:王國之淚》主題風格基于滾動控制的平滑滾動圖片展示頁面,通過本文的閱讀,你將學習到的知識點包括:了解 R3F 中 useFrame hook 及 useThree hook 基本原理及用法;了解 @react-three/drei 庫的基本組成,學習使用它提供的 Preload、useIntersect、ScrollControls、Scroll、及 Image 等組件和方法;用 CSS 生成簡單的回圈懸浮影片等,
效果
本文案例的實作效果如下圖所示,當頁面每個模塊滾動進入視區時,每個模塊會具有平滑向上移動的視差效果,并且伴隨著由大到小的縮放影片,當滑鼠懸浮到當前模塊時,模塊會產生高亮 ? 效果,除此之外,頁面還有一些其他的裝飾,比如塞爾達風格的頁面背景和邊框、具有緩動影片效果的希卡之石以及同樣具有平滑滾動效果的文字裝飾王國之淚四個字,

頁面的整體布局是這樣的,總共有 7 頁,即高度為 700vh,每一頁都具有不同的布局風格樣式,滾動時都會具有緩動效果,

打開以下鏈接,在線預覽效果,本文中的 gif 造成丟幀和畫質損失,大屏訪問效果更佳,
?????在線預覽地址:https://dragonir.github.io/tearsOfTheKingdom/
本專欄系列代碼托管在 Github 倉庫【threejs-odessey】,后續所有目錄也都將在此倉庫中更新,
??代碼倉庫地址:[email protected]: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 屬性值
| 屬性 | 描述 | 型別 |
|---|---|---|
gl |
Renderer |
THREE.WebGLRenderer |
scene |
Scene |
THREE.Scene |
camera |
Camera |
THREE.PerspectiveCamera |
raycaster |
默認 raycaster |
THREE.Raycaster |
pointer |
包含更新的、規范化的、居中的指標坐標 | THREE.Vector2 |
mouse |
已被棄用,可以使用 pointer 代替處理坐標 |
THREE.Vector2 |
clock |
正在運行的系統時鐘 | THREE.Clock |
linear |
當色彩空間為線性時為 true |
boolean |
flat |
未使用色調映射時為 true |
boolean |
legacy |
通過 THREE.ColorManagement 禁用全域色彩管理 |
boolean |
frameloop |
渲染模式: always, demand, never |
always, demand, never |
performance |
系統回歸 | { current: number, min: number, max: number, debounce: number, regress: () => void } |
size |
Canvas 像素值尺寸 |
{ width: number, height: number, top: number, left: number, updateStyle?: boolean } |
viewport |
在 three.js 中視區的尺寸 |
{ width: number, height: number, initialDpr: number, dpr: number, factor: number, distance: number, aspect: number, getCurrentViewport: (camera?: Camera, target?: THREE.Vector3, size?: Size) => Viewport } |
xr |
XR 介面, 管理 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 實作,因此它僅適用于有效渲染的物件,如 mesh、line、sprite等,在 group 及 object3D 的骨骼中無法生效,
const ref = useIntersect((visible) => console.log('object is visible', visible))
return <mesh ref={ref} />
ScrollControls 和 Scroll
ScrollControls 可以在 canvas 前方創建一個 HTML 滾動容器,你放入滾動組件 <Scroll> 中的所有元素都將受到影響,你可以使用 useScroll 鉤子對滾動事件進行監聽并回應,它提供很多有用的資料,例如當前的滾動偏移量、增量以及用于范圍查找的函式:range、curve 及 visible,如果需要對滾動偏移做出回應,如物件進入或移除視圖時添加淡入淡出效果等,則后面的方法非常有用,
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 = https://www.cnblogs.com/dragonir/archive/2023/05/06/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
}
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} />
}
然后我們再封裝一個名為 Images 的 group 元素,用來統一管理頁面上的所有圖片,分別設定每個圖片的鏈接、在頁面上的位置、大小、透明度等一些個性化屬性,
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 中像下面這樣排好頁面上所有需要平滑滾動的圖片即可,可以通過 position、scale 等屬性設定個性化調整圖片在頁面上的位置、大小、加載時機等,比如本文示例中第 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庫的基本組成,學習使用它提供的Preload、useIntersect、ScrollControls、Scroll、及Image等組件和方法, - 用
CSS生成簡單的回圈懸浮影片, - 使用上述
R3F知識原理,生成一個具有視差效果的平滑滾動頁面,

附錄
- [1]. ?? Three.js 打造繽紛夏日3D夢中情島
- [2]. ?? Three.js 實作炫酷的賽博朋克風格3D數字地球大屏
- [3]. ?? Three.js 實作2022冬奧主題3D趣味頁面,含冰墩墩
- [4]. ?? Three.js 實作3D開放世界小游戲:阿貍的多元宇宙
- [5]. ?? Three.js 進階之旅:全景漫游-初階移動相機版
...- 【Three.js 進階之旅】系列專欄訪問 ??
- 更多往期【3D】專欄訪問 ??
- 更多往期【前端】專欄訪問 ??
參考
- [1]. threejs.org
- [2]. React Three Filber
- [3]. greensock 官網案例
本文作者:dragonir 本文地址:https://www.cnblogs.com/dragonir/p/17375879.html

轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/551788.html
標籤:其他
上一篇:Three.js 進階之旅:頁面平滑滾動-王國之淚 💧
下一篇:返回列表
