Three.js實現(xiàn)3D乒乓球小游戲(物理效果)

本文將使用React Three Fiber 和 Cannon.js 來實現(xiàn)一個具有物理特性的乒乓球小游戲,通過本文的閱讀,你將學(xué)習(xí)到的知識點包括:了解什么是 React Three Fiber 及它的相關(guān)生態(tài)、使用 React Three Fiber 搭建基礎(chǔ)三維場景、如何使用新技術(shù)棧給場景中對象的添加物理特性等,最后利用上述知識點,將開發(fā)一個簡單的乒乓球小游戲。

聲明:本文涉及圖文和模型素材僅用于個人學(xué)習(xí)、研究和欣賞,請勿二次修改、非法傳播、轉(zhuǎn)載、出版、商用、及進行其他獲利行為。
摘要
本文在專欄上一篇內(nèi)容《three.js利用射線Raycaster進行碰撞檢測》的基礎(chǔ)上,將使用新的技術(shù)棧 React Three Fiber 和 Cannon.js 來實現(xiàn)一個具有物理特性的小游戲,通過本文的閱讀,你將學(xué)習(xí)到的知識點包括:了解什么是 React Three Fiber 及它的相關(guān)生態(tài)、使用 React Three Fiber 搭建基礎(chǔ)三維場景、如何使用新技術(shù)棧給場景中對象的添加物理特性等,最后利用上述知識點,將開發(fā)一個簡單的乒乓球小游戲。
效果
在正式學(xué)習(xí)之前,我們先來看看本文示例最終實現(xiàn)效果:頁面主體內(nèi)容是一個手握乒乓球拍的模型和一個乒乓球 ??,對球拍像現(xiàn)實生活中一樣進行顛球施力操作,乒乓球可以在球拍上彈起,乒乓球彈起的高度隨著施加在球拍上的力的大小的變化而變化,球拍中央顯示的是連續(xù)顛球次數(shù) 5??,當(dāng)乒乓球從球拍掉落時一局游戲結(jié)束,球拍上的數(shù)字歸零 0?? ??靵碓囋嚹阋淮慰梢灶嵍嗌賯€球吧 ??。

打開以下鏈接,在線預(yù)覽效果,大屏訪問效果更佳。
???? 在線預(yù)覽地址:https://dragonir.github.io/physics-pingpong/
本專欄系列代碼托管在 Github 倉庫【threejs-odessey】,后續(xù)所有目錄也都將在此倉庫中更新。
?? 代碼倉庫地址:git@github.com:dragonir/threejs-odessey.git
原理
React-Three-Fiber
React Three Fiber 是一個基于 Three.js 的 React 渲染器,簡稱 R3F。它像是一個配置器,把 Three.js 的對象映射為 R3F 中的組件。以下是一些相關(guān)鏈接:
倉庫: https://github.com/pmndrs/react-three-fiber
官網(wǎng): https://docs.pmnd.rs/react-three-fiber/getting-started/introduction
示例: https://docs.pmnd.rs/react-three-fiber/getting-started/examples

特點
- 使用可重用的組件以聲明方式構(gòu)建動態(tài)場景圖,使
Three.js的處理變得更加輕松,并使代碼庫更加整潔。這些組件對狀態(tài)變化做出反應(yīng),具有開箱即用的交互性。 Three.js中所有內(nèi)容都能在這里運行。它不針對特定的Three.js版本,也不需要更新以修改,添加或刪除上游功能。- 渲染性能與
Three.js和GPU相仿。組件參與React之外的render loop時,沒有任何額外開銷。
寫 React Three Fiber 比較繁瑣,我們可以寫成 R3F 或簡稱為 Fiber。讓我們從現(xiàn)在開始使用 R3F 吧。
生態(tài)系統(tǒng)
R3F 有充滿活力的生態(tài)系統(tǒng),包括各種庫、輔助工具以及抽象方法:
@react-three/drei– 有用的輔助工具,自身就有豐富的生態(tài)@react-three/gltfjsx– 將GLTFs轉(zhuǎn)換為JSX組件@react-three/postprocessing– 后期處理效果@react-three/test-renderer– 用于在Node中進行單元測試@react-three/flex–react-three-fiber的flex盒子布局@react-three/xr–VR/AR控制器和事件@react-three/csg– 構(gòu)造實體幾何@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的狀態(tài)管理jotai– 基于atoms的狀態(tài)管理valtio– 基于proxy的狀態(tài)管理react-spring– 一個spring-physics-based的動畫庫framer-motion-3d–framer motion,一個很受歡迎的動畫庫use-gesture– 鼠標(biāo)/觸摸手勢leva– 創(chuàng)建GUI控制器maath– 數(shù)學(xué)輔助工具miniplex–ECS實體管理系統(tǒng)composer-suite– 合成著色器、粒子、特效和游戲機制、
安裝
npm install three @react-three/fiber
第一個場景
在一個新建的 React 項目中,我們通過以下的步驟使用 R3F 來創(chuàng)建第一個場景。
初始化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,它們都是渲染所需的基本模塊。 - 它在頁面每一幀更新中都渲染場景,我們不需要再到頁面重繪方法中循環(huán)調(diào)用渲染方法。
?? Canvas 大小響應(yīng)式自適應(yīng)于父節(jié)點,我們可以通過改變父節(jié)點的寬度和高度來控制渲染場景的尺寸大小。
添加一個Mesh組件
為了真正能夠在場景中看到一些物體,現(xiàn)在我們添加一個小寫的 <mesh /> 元素,它直接等效于 new THREE.Mesh()。
<Canvas> <mesh />
?? 可以看到我們沒有特地去額外引入mesh組件,我們不需要引入任何元素,所有Three.js中的對象都將被當(dāng)作原生的JSX元素,就像在 ReactDom 中寫 <div /> 及 <span /> 元素一樣。R3F Fiber組件的通用規(guī)則是將Three.js中的它們的名字寫成駝峰式的DOM元素即可。
一個 Mesh 是 Three.js 中的基礎(chǔ)場景對象,需要給它提供一個幾何對象 geometry 以及一個材質(zhì) material 來代表一個三維空間的幾何形狀,我們將使用一個 BoxGeometry 和 MeshStandardMaterial 來創(chuàng)建一個新的網(wǎng)格 Mesh,它們會自動關(guān)聯(lián)到它們的父節(jié)點。
<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()構(gòu)造函數(shù)參數(shù):
根據(jù) BoxGeometry 的文檔,我們可以選擇給它傳遞三個參數(shù):width、length 及 depth:
new THREE.BoxGeometry(2, 2, 2)
為了實現(xiàn)相同的功能,我們可以在 R3F 中使用 args 屬性,它總是接受一個數(shù)組,其項目表示構(gòu)造函數(shù)參數(shù):
<boxGeometry args={[2, 2, 2]} />添加光源
接著,我們通過像下面這樣添加光源組件來為我們的場景添加一些光線。
<Canvas>
<ambientLight intensity={0.1} />
<directionalLight color="red" position={[0, 0, 5]} />屬性:
這里介紹關(guān)于 R3F 的最后一個概念,即 React 屬性是如何在 Three.js 對象中工作的。當(dāng)你給一個 Fiber 組件設(shè)置任意屬性時,它將對 Three.js 設(shè)置一個相同名字的屬性。我們關(guān)注到 ambientLight 上,由它的文檔可知,我們可以選擇 color 和 intensity 屬性來初始化它:
<ambientLight intensity={0.1} />等價于
const light = new THREE.AmbientLight() light.intensity = 0.1
快捷方法:
在 Three.js 中對于很多屬性的設(shè)置如 colors、vectors 等都可以使用 set() 方法進行快捷設(shè)置:
const light = new THREE.DirectionalLight()
light.position.set(0, 0, 5)
light.color.set('red')在 JSX 中也是相同的:
<directionalLight position={[0, 0, 5]} color="red" />結(jié)果
<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文檔
實現(xiàn)
到這里,我們已經(jīng)掌握了 R3F 的基本知識,我們再結(jié)合專欄上篇關(guān)于物理特性的內(nèi)容,來實現(xiàn)如文章開頭介紹的乒乓球 ?? 小游戲。
?? 本文乒乓球小游戲基礎(chǔ)版及乒乓球三維模型資源來源于R3F官網(wǎng)示例。
〇 搭建頁面基本結(jié)構(gòu)
首先,我們創(chuàng)建一個 Experience 文件作為渲染三維場景的組件,并在其中添加 Canvas 組件搭建基本頁面結(jié)構(gòu)。
import { Canvas } from "@react-three/fiber";
export default function Experience() {
return (
<>
<Canvas></Canvas>
</>
);
}
① 場景初始化
接著我們開啟 Canvas 的陰影并設(shè)置相機參數(shù),然后添加環(huán)境光 ambientLight 和點光源 pointLight 兩種光源:
<Canvas
shadows
camera={{ fov: 50, position: [0, 5, 12] }}
>
<ambientLight intensity={.5} />
<pointLight position={[-10, -10, -10]} />
</Canvas>
如果需要修改 Canvas 的背景色,可以在其中添加一個 color 標(biāo)簽并設(shè)置參數(shù) attach 為 background,在 args 參數(shù)中設(shè)置顏色即可。
<Canvas>
<color attach="background" args={["lightgreen"]} />
</Canvas>
② 添加輔助工具
接著,我們在頁面頂部引入 Perf,它是 R3F 生態(tài)中查看頁面性能的組件,它的功能和 Three.js 中 stats.js 是類似的,像下面這樣添加到代碼中設(shè)置它的顯示位置,頁面對應(yīng)區(qū)域就會出現(xiàn)可視化的查看工具,在上面可以查看 GPU、CPU、FPS 等性能參數(shù)。
如果想使用網(wǎng)格作為輔助線或用作裝飾,可以使用 gridHelper 組件,它支持配置 position、rotation、args 等參數(shù)。
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>
</>
);
}
③ 創(chuàng)建乒乓球和球拍
我們創(chuàng)建一個名為 PingPong.jsx 的乒乓球組件文件,然后在文件頂部引入以下依賴,其中 Physics、useBox、usePlane、useSphere 用于創(chuàng)建物理世界;useFrame 是用來進行頁面動畫更新的 hook,它將在頁面每幀重繪時執(zhí)行,我們可以在它里面執(zhí)行一些動畫函數(shù)和更新控制器,相當(dāng)于 Three.js 中用原生實現(xiàn)的 requestAnimationFrame;useLoader 用于加載器的管理,使用它更方便進行加載錯誤管理和回調(diào)方法執(zhí)行;lerp 是一個插值運算函數(shù),它可以計算某一數(shù)值到另一數(shù)值的百分比,從而得出一個新的數(shù)值,常用于移動物體、修改透明度、顏色、大小、模擬動畫等。
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";創(chuàng)建物理世界
然后創(chuàng)建一個 PingPong 類,在其中添加 <Physics> 組件來創(chuàng)建物理世界,像直接使用 Cannon.js 一樣,可以給它設(shè)置 iterations、tolerance、gravity、allowSleep 等參數(shù)來分別設(shè)置物理世界的迭代次數(shù)、容錯性、引力以及是否支持進入休眠狀態(tài)等,然后在其中添加一個平面幾何體和一個平面剛體 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>
</>
);
}

創(chuàng)建乒乓球
接著,我們創(chuàng)建一個球體類 Ball,在其中添加球體 ?? ,可以使用前面介紹的 useLoader 來管理它的貼圖加載,為了方便觀察到乒乓球的轉(zhuǎn)動情況,貼圖中央加了一個十字交叉圖案 ?。然后將其放在 <Physics> 標(biāo)簽下。
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>
</>
);
}
創(chuàng)建球拍
球拍 ?? 采用的是一個 glb 格式的模型,在 Blender 中我們可以看到模型的樣式和詳細(xì)的骨骼結(jié)構(gòu),對于模型的加載,我們同樣使用 useLoader 來管理,此時的加載器需要使用 GLTFLoader。

我們創(chuàng)建一個 Paddle 類并將其添加到 <Physics> 標(biāo)簽中,在這個類中我們實現(xiàn)模型加載,模型加載完成后綁定骨骼,并在 useFrame 頁面重繪方法中,根據(jù)鼠標(biāo)所在位置更新乒乓球拍模型的位置 position,并根據(jù)是否一開始游戲狀態(tài)以及鼠標(biāo)的位置來更新球拍的 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>
);
}
到這里,我們已經(jīng)實現(xiàn)乒乓球顛球的基本功能了 ??

顛球計數(shù)
為了顯示每次游戲可以顛球的次數(shù),現(xiàn)在我們在乒乓球拍中央加上數(shù)字顯示 5?? 。我們可以像下面這樣創(chuàng)建一個 Text 類,在文件頂部引入 TextGeometry、FontLoader、fontJson 作為字體幾何體、字體加載器以及字體文件,添加一個 geom 作為創(chuàng)建字體幾何體的方法,當(dāng) count 狀態(tài)值發(fā)生變化時,實時更新創(chuàng)建字體幾何體模型。
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 字段需要在物理世界中剛體發(fā)生碰撞時進行更新,該方法加載下節(jié)內(nèi)容添加碰撞音效時一起實現(xiàn)。
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>
);
}
④ 頁面裝飾
到這里,整個小游戲的全部流程都開發(fā)完畢了,現(xiàn)在我們來加一些頁面提示語、顛球時的碰撞音效,頁面的光照效果等,使 3D 場景看起來更加真實。
音效
實現(xiàn)音效 ?? 前,我們先像下面這樣添加一個狀態(tài)管理器 ?? ,來進行頁面全局狀態(tài)的管理。zustand 是一個輕量級的狀態(tài)管理庫;_.clamp(number, [lower], upper) 用于返回限制在 lower 和 upper 之間的值;pingSound 是需要播放的音頻文件。我們在其中添加一個 pong 方法用來更新音效和顛球計數(shù),添加一個 reset 方法重置顛球數(shù)字。count 字段表示每次的顛球次數(shù),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 乒乓球拍類中像這樣在物體發(fā)生碰撞時觸發(fā) pong 方法:
function Paddle() {
{/* ... */}
const [ref, api] = useBox(() => ({
type: "Kinematic",
args: [3.4, 1, 3.5],
onCollide: (e) => pong(e.contact.impactVelocity),
}));
}光照
為了是場景更加真實,我們可以開啟 Canvas 的陰影,然后添加多種光源 ?? 來優(yōu)化場景,如 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 /> 標(biāo)簽內(nèi)哦 ??。
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)}>?? 點擊任意區(qū)域開始顛球</div>
?? 源碼地址: https://github.com/dragonir/threejs-odessey
總結(jié)
本文中主要包含的知識點包括:
- 了解什么是
React Three Fiber及相關(guān)生態(tài)。 React Three Fiber基礎(chǔ)入門。- 使用
React Three Fiber開發(fā)一個乒乓球小游戲,學(xué)會如何場景構(gòu)建、模型加載、物理世界關(guān)聯(lián)、全局狀態(tài)管理等。
到此這篇關(guān)于Three.js實現(xiàn)3D乒乓球小游戲(物理效果)的文章就介紹到這了,更多相關(guān)Three.js 3D乒乓球小游戲內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
JavaScript遍歷實現(xiàn)DFS算法和BFS算法
DFS(Depth?first?search)稱作「深度優(yōu)先遍歷」,BFS(Breadth?first?search)稱作「廣度優(yōu)先遍歷」。本文將通過JavaScript遍歷實現(xiàn)這兩種算法,需要的可以參考一下2023-01-01
微信小程序使用this.setData()遇到的問題及解決方案詳解
this.setData估計是小程序中最經(jīng)常用到的一個方法,但是要注意其實他是有限制的,忽略這些限制的話,會導(dǎo)致數(shù)據(jù)無法更新,下面這篇文章主要給大家介紹了關(guān)于微信小程序使用this.setData()遇到的問題及解決方案,需要的朋友可以參考下2022-08-08

