宣告:本文涉及圖文和模型素材僅用於個人學習、研究和欣賞,請勿二次修改、非法傳播、轉載、出版、商用、及進行其他獲利行為。
摘要
本文在專欄上一篇內容《Three.js 進階之旅:物理效果-碰撞和聲音》的基礎上,將使用新的技術棧 React Three Fiber
和 Cannon.js
來實現一個具有物理特性的小遊戲,透過本文的閱讀,你將學習到的知識點包括:瞭解什麼是 React Three Fiber
及它的相關生態、使用 React Three Fiber
搭建基礎三維場景、如何使用新技術棧給場景中物件的新增物理特性等,最後利用上述知識點,將開發一個簡單的乒乓球小遊戲。
效果
在正式學習之前,我們先來看看本文示例最終實現效果:頁面主體內容是一個手握乒乓球拍的模型和一個乒乓球 ?
,對球拍像現實生活中一樣進行顛球施力操作,乒乓球可以在球拍上彈起,乒乓球彈起的高度隨著施加在球拍上的力的大小的變化而變化,球拍中央顯示的是連續顛球次數 5️⃣
,當乒乓球從球拍掉落時一局遊戲結束,球拍上的數字歸零 0️⃣
。快來試試你一次可以顛多少個球吧 ?
。
開啟以下連結,線上預覽效果,大屏訪問效果更佳。
??
線上預覽地址:https://dragonir.github.io/physics-pingpong/
本專欄系列程式碼託管在 Github
倉庫【threejs-odessey】,後續所有目錄也都將在此倉庫中更新。
原理
React-Three-Fiber
React Three Fiber
是一個基於 Three.js
的 React
渲染器,簡稱 R3F
。它像是一個配置器,把 Three.js
的物件對映為 R3F
中的元件。以下是一些相關連結:
- 倉庫: https://github.com/pmndrs/react-three-fiber
- 官網: https://docs.pmnd.rs/react-three-fiber/getting-started/introduction
- 示例: https://docs.pmnd.rs/react-three-fiber/getting-started/examples
特點
- 使用可重用的元件以宣告方式構建動態場景圖,使
Three.js
的處理變得更加輕鬆,並使程式碼庫更加整潔。這些元件對狀態變化做出反應,具有開箱即用的互動性。 Three.js
中所有內容都能在這裡執行。它不針對特定的Three.js
版本,也不需要更新以修改,新增或刪除上游功能。- 渲染效能與
Three.js
和GPU
相仿。元件參與React
之外的render loop
時,沒有任何額外開銷。
寫 React Three Fiber
比較繁瑣,我們可以寫成 R3F
或簡稱為 Fiber
。讓我們從現在開始使用 R3F
吧。
生態系統
R3F
有充滿活力的生態系統,包括各種庫、輔助工具以及抽象方法:
@react-three/drei
– 有用的輔助工具,自身就有豐富的生態@react-three/gltfjsx
– 將GLTFs
轉換為JSX
元件@react-three/postprocessing
– 後期處理效果@react-three/test-renderer
– 用於在Node
中進行單元測試@react-three/flex
–react-three-fiber
的flex
盒子佈局@react-three/xr
–VR/AR
控制器和事件@react-three/csg
– 構造實體幾何@react-three/rapier
– 使用Rapier
的3D
物理引擎@react-three/cannon
– 使用Cannon
的3D
物理引擎@react-three/p2
– 使用P2
的2D
物理引擎@react-three/a11y
– 可訪問工具@react-three/gpu-pathtracer
– 真實的路徑追蹤create-r3f-app next
–nextjs
啟動器lamina
– 基於shader materials
的圖層zustand
– 基於flux
的狀態管理jotai
– 基於atoms
的狀態管理valtio
– 基於proxy
的狀態管理react-spring
– 一個spring-physics-based
的動畫庫framer-motion-3d
–framer motion
,一個很受歡迎的動畫庫use-gesture
– 滑鼠/觸控手勢leva
– 建立GUI
控制器maath
– 數學輔助工具miniplex
–ECS
實體管理系統composer-suite
– 合成著色器、粒子、特效和遊戲機制、
安裝
npm install three @react-three/fiber
第一個場景
在一個新建的 React
專案中,我們透過以下的步驟使用 R3F
來建立第一個場景。
初始化Canvas
首先,我們從 @react-three/fiber
引入 Canvas
元素,將其放到 React
樹中:
import ReactDOM from 'react-dom'
import { Canvas } from '@react-three/fiber'
function App() {
return (
<div id="canvas-container">
<Canvas />
</div>
)
}
ReactDOM.render(<App />, document.getElementById('root'))
Canvas
元件在幕後做了一些重要的初始化工作:
- 它初始化了一個場景
Scene
和一個相機Camera
,它們都是渲染所需的基本模組。 - 它在頁面每一幀更新中都渲染場景,我們不需要再到頁面重繪方法中迴圈呼叫渲染方法。
?
Canvas 大小響應式自適應於父節點,我們可以透過改變父節點的寬度和高度來控制渲染場景的尺寸大小。
新增一個Mesh元件
為了真正能夠在場景中看到一些物體,現在我們新增一個小寫的 <mesh />
元素,它直接等效於 new THREE.Mesh()
。
<Canvas>
<mesh />
?
可以看到我們沒有特地去額外引入mesh元件,我們不需要引入任何元素,所有Three.js中的物件都將被當作原生的JSX元素,就像在ReactDom
中寫<div />
及<span />
元素一樣。R3F Fiber元件的通用規則是將Three.js中的它們的名字寫成駝峰式的DOM元素即可。
一個 Mesh
是 Three.js
中的基礎場景物件,需要給它提供一個幾何物件 geometry
以及一個材質 material
來代表一個三維空間的幾何形狀,我們將使用一個 BoxGeometry
和 MeshStandardMaterial
來建立一個新的網格 Mesh
,它們會自動關聯到它們的父節點。
<Canvas>
<mesh>
<boxGeometry />
<meshStandardMaterial />
</mesh>
上述程式碼和以下 Three.js
程式碼是等價的:
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 1000)
const renderer = new THREE.WebGLRenderer()
renderer.setSize(width, height)
document.querySelector('#canvas-container').appendChild(renderer.domElement)
const mesh = new THREE.Mesh()
mesh.geometry = new THREE.BoxGeometry()
mesh.material = new THREE.MeshStandardMaterial()
scene.add(mesh)
function animate() {
requestAnimationFrame(animate)
renderer.render(scene, camera)
}
animate()
建構函式引數:
根據 BoxGeometry
的文件,我們可以選擇給它傳遞三個引數:width
、length
及 depth
:
new THREE.BoxGeometry(2, 2, 2)
為了實現相同的功能,我們可以在 R3F
中使用 args
屬性,它總是接受一個陣列,其專案表示建構函式引數:
<boxGeometry args={[2, 2, 2]} />
新增光源
接著,我們透過像下面這樣新增光源元件來為我們的場景新增一些光線。
<Canvas>
<ambientLight intensity={0.1} />
<directionalLight color="red" position={[0, 0, 5]} />
屬性:
這裡介紹關於 R3F
的最後一個概念,即 React
屬性是如何在 Three.js
物件中工作的。當你給一個 Fiber
元件設定任意屬性時,它將對 Three.js
設定一個相同名字的屬性。我們關注到 ambientLight
上,由它的文件可知,我們可以選擇 color
和 intensity
屬性來初始化它:
<ambientLight intensity={0.1} />
等價於
const light = new THREE.AmbientLight()
light.intensity = 0.1
快捷方法:
在 Three.js
中對於很多屬性的設定如 colors
、vectors
等都可以使用 set()
方法進行快捷設定:
const light = new THREE.DirectionalLight()
light.position.set(0, 0, 5)
light.color.set('red')
在 JSX
中也是相同的:
<directionalLight position={[0, 0, 5]} color="red" />
結果
<Canvas>
<mesh>
<boxBufferGeometry />
<meshBasicMaterial color="#03c03c" />
</mesh>
<ambientLight args={[0xff0000]} intensity={0.1} />
<directionalLight position={[0, 0, 5]} intensity={0.5} />
</Canvas>
檢視React Three Fiber完整API文件
實現
到這裡,我們已經掌握了 R3F
的基本知識,我們再結合專欄上篇關於物理特性的內容,來實現如文章開頭介紹的乒乓球 ?
小遊戲。
?
本文乒乓球小遊戲基礎版及乒乓球三維模型資源來源於R3F官網示例。
〇 搭建頁面基本結構
首先,我們建立一個 Experience
檔案作為渲染三維場景的元件,並在其中新增 Canvas
元件搭建基本頁面結構。
import { Canvas } from "@react-three/fiber";
export default function Experience() {
return (
<>
<Canvas></Canvas>
</>
);
}
① 場景初始化
接著我們開啟 Canvas
的陰影並設定相機引數,然後新增環境光 ambientLight
和點光源 pointLight
兩種光源:
<Canvas
shadows
camera={{ fov: 50, position: [0, 5, 12] }}
>
<ambientLight intensity={.5} />
<pointLight position={[-10, -10, -10]} />
</Canvas>
如果需要修改 Canvas
的背景色,可以在其中新增一個 color
標籤並設定引數 attach
為 background
,在 args
引數中設定顏色即可。
<Canvas>
<color attach="background" args={["lightgreen"]} />
</Canvas>
② 新增輔助工具
接著,我們在頁面頂部引入 Perf
,它是 R3F
生態中檢視頁面效能的元件,它的功能和 Three.js
中 stats.js
是類似的,像下面這樣新增到程式碼中設定它的顯示位置,頁面對應區域就會出現視覺化的檢視工具,在上面可以檢視 GPU
、CPU
、FPS
等效能引數。
如果想使用網格作為輔助線或用作裝飾,可以使用 gridHelper
元件,它支援配置 position
、rotation
、args
等引數。
import { Perf } from "r3f-perf";
export default function Experience() {
return (
<>
<Canvas>
<Perf position="top-right" />
<gridHelper args={[50, 50, '#11f1ff', '#0b50aa']} position={[0, -1.1, -4]} rotation={[Math.PI / 2.68, 0, 0]} />
</Canvas>
</>
);
}
③ 建立乒乓球和球拍
我們建立一個名為 PingPong.jsx
的乒乓球元件檔案,然後在檔案頂部引入以下依賴,其中 Physics
、useBox
、usePlane
、useSphere
用於建立物理世界;useFrame
是用來進行頁面動畫更新的 hook
,它將在頁面每幀重繪時執行,我們可以在它裡面執行一些動畫函式和更新控制器,相當於 Three.js
中用原生實現的 requestAnimationFrame
;useLoader
用於載入器的管理,使用它更方便進行載入錯誤管理和回撥方法執行;lerp
是一個插值運算函式,它可以計算某一數值到另一數值的百分比,從而得出一個新的數值,常用於移動物體、修改透明度、顏色、大小、模擬動畫等。
import { Physics, useBox, usePlane, useSphere } from "@react-three/cannon";
import { useFrame, useLoader } from "@react-three/fiber";
import { Mesh, TextureLoader } from "three";
import { GLTFLoader } from "three-stdlib/loaders/GLTFLoader";
import lerp from "lerp";
建立物理世界
然後建立一個 PingPong
類,在其中新增 <Physics>
元件來建立物理世界,像直接使用 Cannon.js
一樣,可以給它設定 iterations
、tolerance
、gravity
、allowSleep
等引數來分別設定物理世界的迭代次數、容錯性、引力以及是否支援進入休眠狀態等,然後在其中新增一個平面幾何體和一個平面剛體 ContactGround
。
function ContactGround() {
const [ref] = usePlane(
() => ({
position: [0, -10, 0],
rotation: [-Math.PI / 2, 0, 0],
type: "Static",
}),
useRef < Mesh > null
);
return <mesh ref={ref} />;
}
export default function PingPong() {
return (
<>
<Physics
iterations={20}
tolerance={0.0001}
defaultContactMaterial={{
contactEquationRelaxation: 1,
contactEquationStiffness: 1e7,
friction: 0.9,
frictionEquationRelaxation: 2,
frictionEquationStiffness: 1e7,
restitution: 0.7,
}}
gravity={[0, -40, 0]}
allowSleep={false}
>
<mesh position={[0, 0, -10]} receiveShadow>
<planeGeometry args={[1000, 1000]} />
<meshPhongMaterial color="#5081ca" />
</mesh>
<ContactGround />
</Physics>
</>
);
}
建立乒乓球
接著,我們建立一個球體類 Ball
,在其中新增球體 ?
,可以使用前面介紹的 useLoader
來管理它的貼圖載入,為了方便觀察到乒乓球的轉動情況,貼圖中央加了一個十字交叉圖案 ➕
。然後將其放在 <Physics>
標籤下。
function Ball() {
const map = useLoader(TextureLoader, earthImg);
const [ref] = useSphere(
() => ({ args: [0.5], mass: 1, position: [0, 5, 0] }),
useRef < Mesh > null
);
return (
<mesh castShadow ref={ref}>
<sphereGeometry args={[0.5, 64, 64]} />
<meshStandardMaterial map={map} />
</mesh>
);
}
export default function PingPong() {
return (
<>
<Physics>
{ /* ... */ }
<Ball />
</Physics>
</>
);
}
建立球拍
球拍 ?
採用的是一個 glb
格式的模型,在 Blender
中我們可以看到模型的樣式和詳細的骨骼結構,對於模型的載入,我們同樣使用 useLoader
來管理,此時的載入器需要使用 GLTFLoader
。
我們建立一個 Paddle
類並將其新增到 <Physics>
標籤中,在這個類中我們實現模型載入,模型載入完成後繫結骨骼,並在 useFrame
頁面重繪方法中,根據滑鼠所在位置更新乒乓球拍模型的位置 position
,並根據是否一開始遊戲狀態以及滑鼠的位置來更新球拍的 x軸
和 y軸
方向的 rotation
值。
function Paddle() {
const { nodes, materials } = useLoader(
GLTFLoader,
'/models/pingpong.glb',
);
const model = useRef();
const [ref, api] = useBox(() => ({
type: 'Kinematic',
args: [3.4, 1, 3.5],
}));
const values = useRef([0, 0]);
useFrame((state) => {
values.current[0] = lerp(
values.current[0],
(state.mouse.x * Math.PI) / 5,
0.2
);
values.current[1] = lerp(
values.current[1],
(state.mouse.x * Math.PI) / 5,
0.2
);
api.position.set(state.mouse.x * 10, state.mouse.y * 5, 0);
api.rotation.set(0, 0, values.current[1]);
if (!model.current) return;
model.current.rotation.x = lerp(
model.current.rotation.x,
started ? Math.PI / 2 : 0,
0.2
);
model.current.rotation.y = values.current[0];
});
return (
<mesh ref={ref} dispose={null}>
<group
ref={model}
position={[-0.05, 0.37, 0.3]}
scale={[0.15, 0.15, 0.15]}
>
<group rotation={[1.88, -0.35, 2.32]} scale={[2.97, 2.97, 2.97]}>
<primitive object={nodes.Bone} />
<primitive object={nodes.Bone003} />
{ /* ... */ }
<skinnedMesh
castShadow
receiveShadow
material={materials.glove}
material-roughness={1}
geometry={nodes.arm.geometry}
skeleton={nodes.arm.skeleton}
/>
</group>
<group rotation={[0, -0.04, 0]} scale={[141.94, 141.94, 141.94]}>
<mesh
castShadow
receiveShadow
material={materials.wood}
geometry={nodes.mesh.geometry}
/>
{ /* ... */ }
</group>
</group>
</mesh>
);
}
到這裡,我們已經實現乒乓球顛球的基本功能了 ?
顛球計數
為了顯示每次遊戲可以顛球的次數,現在我們在乒乓球拍中央加上數字顯示 5️⃣
。我們可以像下面這樣建立一個 Text
類,在檔案頂部引入 TextGeometry
、FontLoader
、fontJson
作為字型幾何體、字型載入器以及字型檔案,新增一個 geom
作為建立字型幾何體的方法,當 count
狀態值發生變化時,實時更新建立字型幾何體模型。
import { useMemo } from "react";
import { TextGeometry } from "three/examples/jsm/geometries/TextGeometry";
import { FontLoader } from "three/examples/jsm/loaders/FontLoader";
import fontJson from "../public/fonts/firasans_regular.json";
const font = new FontLoader().parse(fontJson);
const geom = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].map(
(number) => new TextGeometry(number, { font, height: 0.1, size: 5 })
);
export default function Text({ color = 0xffffff, count, ...props }) {
const array = useMemo(() => [...count], [count]);
return (
<group {...props} dispose={null}>
{array.map((char, index) => (
<mesh
position={[-(array.length / 2) * 3.5 + index * 3.5, 0, 0]}
key={index}
geometry={geom[parseInt(char)]}
>
<meshBasicMaterial color={color} transparent opacity={0.5} />
</mesh>
))}
</group>
);
}
然後將 Text
字型類放入球拍幾何體中,其中 count
欄位需要在物理世界中剛體發生碰撞時進行更新,該方法載入下節內容新增碰撞音效時一起實現。
function Paddle() {
return (
<mesh ref={ref} dispose={null}>
<group ref={model}>
{ /* ... */ }
<Text
rotation={[-Math.PI / 2, 0, 0]}
position={[0, 1, 2]}
count={count.toString()}
/>
</group>
</mesh>
);
}
④ 頁面裝飾
到這裡,整個小遊戲的全部流程都開發完畢了,現在我們來加一些頁面提示語、顛球時的碰撞音效,頁面的光照效果等,使 3D
場景看起來更加真實。
音效
實現音效 ?
前,我們先像下面這樣新增一個狀態管理器 ?
,來進行頁面全域性狀態的管理。zustand
是一個輕量級的狀態管理庫;_.clamp(number, [lower], upper)
用於返回限制在 lower
和 upper
之間的值;pingSound
是需要播放的音訊檔案。我們在其中新增一個 pong
方法用來更新音效和顛球計數,新增一個 reset
方法重置顛球數字。count
欄位表示每次的顛球次數,welcome
表示是否在歡迎介面。
import create from "zustand";
import clamp from "lodash-es/clamp";
import pingSound from "/medias/ping.mp3";
const ping = new Audio(pingSound);
export const useStore = create((set) => ({
api: {
pong(velocity) {
ping.currentTime = 0;
ping.volume = clamp(velocity / 20, 0, 1);
ping.play();
if (velocity > 4) set((state) => ({ count: state.count + 1 }));
},
reset: (welcome) =>
set((state) => ({ count: welcome ? state.count : 0, welcome })),
},
count: 0,
welcome: true,
}));
然後我們可以在上述 Paddle
乒乓球拍類中像這樣在物體發生碰撞時觸發 pong
方法:
function Paddle() {
{/* ... */}
const [ref, api] = useBox(() => ({
type: "Kinematic",
args: [3.4, 1, 3.5],
onCollide: (e) => pong(e.contact.impactVelocity),
}));
}
光照
為了是場景更加真實,我們可以開啟 Canvas
的陰影,然後新增多種光源 ?
來最佳化場景,如 spotLight
就能起到視覺聚焦的作用。
<Canvas
shadows
camera={{ fov: 50, position: [0, 5, 12] }}
>
<ambientLight intensity={.5} />
<pointLight position={[-10, -10, -10]} />
<spotLight
position={[10, 10, 10]}
angle={0.3}
penumbra={1}
intensity={1}
castShadow
shadow-mapSize-width={2048}
shadow-mapSize-height={2048}
shadow-bias={-0.0001}
/>
<PingPong />
</Canvas>
提示語
為了提升小遊戲的使用者體驗,我們可以新增一些頁面文字提示來指引使用者和提升頁面視覺效果,需要注意的是,這些額外的元素不能新增到 <Canvas />
標籤內哦 ?
。
const style = (welcome) => ({
color: '#000000',
display: welcome ? 'block' : 'none',
fontSize: '1.8em',
left: '50%',
position: "absolute",
top: 40,
transform: 'translateX(-50%)',
background: 'rgba(255, 255, 255, .2)',
backdropFilter: 'blur(4px)',
padding: '16px',
borderRadius: '12px',
boxShadow: '1px 1px 2px rgba(0, 0, 0, .2)',
border: '1px groove rgba(255, 255, 255, .2)',
textShadow: '0px 1px 2px rgba(255, 255, 255, .2), 0px 2px 2px rgba(255, 255, 255, .8), 0px 2px 4px rgba(0, 0, 0, .5)'
});
<div style={style(welcome)}>? 點選任意區域開始顛球</div>
總結
本文中主要包含的知識點包括:
- 瞭解什麼是
React Three Fiber
及相關生態。 React Three Fiber
基礎入門。- 使用
React Three Fiber
開發一個乒乓球小遊戲,學會如何場景構建、模型載入、物理世界關聯、全域性狀態管理等。
想了解其他前端知識或其他未在本文中詳細描述的Web 3D開發技術相關知識,可閱讀我往期的文章。如果有疑問可以在評論中留言,如果覺得文章對你有幫助,不要忘了一鍵三連哦 ?。
附錄
- [1]. ? Three.js 打造繽紛夏日3D夢中情島
- [2]. ? Three.js 實現炫酷的賽博朋克風格3D數字地球大屏
- [3]. ? Three.js 實現2022冬奧主題3D趣味頁面,含冰墩墩
- [4]. ? Three.js 實現3D開放世界小遊戲:阿狸的多元宇宙
- [5]. ? 掘金1000粉!使用Three.js實現一個創意紀念頁面
...
- 【Three.js 進階之旅】系列專欄訪問 ?
- 更多往期【3D】專欄訪問 ?
- 更多往期【前端】專欄訪問 ?
參考
- [1]. React Three Fiber
- [2]. threejs.org
本文作者:dragonir 本文地址:https://www.cnblogs.com/dragonir/p/17235128.html