申明:本文波及图文和模型素材仅用于集体学习、钻研和观赏,请勿二次批改、非法流传、转载、出版、商用、及进行其余获利行为。
摘要
本文在专栏上一篇内容《Three.js 进阶之旅:物理成果-碰撞和声音》的根底上,将应用新的技术栈 React Three Fiber
和 Cannon.js
来实现一个具备物理个性的小游戏,通过本文的浏览,你将学习到的知识点包含:理解什么是 React Three Fiber
及它的相干生态、应用 React Three Fiber
搭建根底三维场景、如何应用新技术栈给场景中对象的增加物理个性等,最初利用上述知识点,将开发一个简略的乒乓球小游戏。
成果
在正式学习之前,咱们先来看看本文示例最终实现成果:页面主体内容是一个手握乒乓球拍的模型和一个乒乓球 🏓
,对球拍像现实生活中一样进行颠球施力操作,乒乓球能够在球拍上弹起,乒乓球弹起的高度随着施加在球拍上的力的大小的变动而变动,球拍地方显示的是间断颠球次数 5️⃣
,当乒乓球从球拍掉落时一局游戏完结,球拍上的数字归零 0️⃣
。快来试试你一次能够颠多少个球吧 😏
。
关上以下链接,在线预览成果,大屏拜访成果更佳。
👁🗨
在线预览地址:https://dragonir.github.io/physics-pingpong/
本专栏系列代码托管在 Github
仓库【threejs-odessey】,后续所有目录也都将在此仓库中更新。
🔗
代码仓库地址:git@github.com:dragonir/threejs-odessey.git
原理
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>
🔗
源码地址: https://github.com/dragonir/threejs-odessey
总结
本文中次要蕴含的知识点包含:
- 理解什么是
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
发表回复