Three.js实现3D乒乓球小游戏(物理效果)

作者:状态漫游

时间:2026-10-26

浏览:540

本文介绍如何使用React Three Fiber和Cannon.js开发一个具有物理特性的3D乒乓球小游戏。内容涵盖R3F基础场景搭建、物理世界配置、模型加载、状态管理及音效实现,帮助开发者掌握React生态下的3D游戏开发技巧。

文章目录

摘要

本文运用 React Three Fiber 与 Cannon.js 打造具备物理特性的乒乓球游戏。阅读后,读者将掌握 React Three Fiber 及其生态、利用 R3F 构建基础三维场景、为场景对象添加物理特性等技能,并最终完成简易乒乓球游戏开发。

摘要

声明:本文涉及的图文及模型素材仅用于个人学习、研究与欣赏。严禁二次修改、非法传播、转载、出版、商用或进行其他获利行为。

摘要

在专栏前文《three.js利用射线Raycaster进行碰撞检测》基础上,本文采用新技术栈 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 开发一个乒乓球小游戏,学会如何场景构建、模型加载、物理世界关联、全局状态管理等。

到此这篇关于Three.js实现3D乒乓球小游戏(物理效果)的文章就介绍到这了,更多相关Three.js 3D乒乓球小游戏内容请搜索本站以前的文章或继续浏览下面的相关文章希望大家以后多多支持本站!

实用工具推荐

电子包浆

绿化、压缩、噪点和虚拟水印,自由调节做旧程度。

使用 ›
图片压缩

选择画质和输出格式,预览压缩前后的大小。

使用 ›
相关文章