

Three.js 进阶之旅:物理效果-3D乒乓球小游戏 🏓 - Dragonir
source link: https://www.cnblogs.com/dragonir/p/17235128.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Three.js 进阶之旅:物理效果-3D乒乓球小游戏 🏓

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。
本文在专栏上一篇内容《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
中的组件。以下是一些相关链接:

- 使用可重用的组件以声明方式构建动态场景图,使
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开发技术相关知识,可阅读我往期的文章。如果有疑问可以在评论中留言,如果觉得文章对你有帮助,不要忘了一键三连哦 👍。
本文作者:dragonir 本文地址:https://www.cnblogs.com/dragonir/p/17235128.html
Recommend
-
51
物理弹球正版 - 苹果下载榜 TOP3 官方正版 物理弹球小游戏 - NEXT
-
10
Getting Started with Create React App This project was bootstrapped with Create React App. Available Scripts In the project directory, you can run:
-
5
“小游戏”依旧风行,视觉效果就一定是必需品吗-36氪“小游戏”依旧风行,视觉效果就一定是必需品吗三易生活·2022-04-15 12:20对于一款游戏来说,视觉效果真的...
-
5
使用CSS实现《声生不息》节目Logo
-
10
声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。
-
6
Three.js 进阶之旅:新春特典-Rabbit craft go 🐇
-
7
Three.js 进阶之旅:物理效果-碰撞和声音 💥
-
4
Three.js 进阶之旅:全景漫游-初阶移动相机版
-
7
Three.js 进阶之旅:滚动控制模型动画和相机动画 🦢 ...
-
1
重磅,年度效果广告白皮书公布,手游买量回暖,小游戏爆发式增长,一投放趋势发生变化DataEye·2023-12-28 00:322023年国内游戏效果广告白皮书重磅发布!
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK