使用react-three实现3D游戏(1)——模型替换

概述

当我们上次利用ecctrl库对人物控制进行了优化后,我们意识到在一个基本的可交互的3D游戏中,人物和环境是不可或缺的元素。接下来,我们将逐步迭代我们的项目,使其更像一个真正的可交互的3D游戏。

以下是我们的大致里程碑,展示了接下来的工作方向。如果你认为还有任何遗漏或可以添加的内容,请在评论区指出,我们将不胜感激。

项目里程碑

  • 添加物理效果
  • 添加人物控制
  • 替换人物模型
  • 添加人物动画
  • 加载场景模型
  • 添加音乐、音效
  • 环境添加粒子效果
  • 添加光照特效
  • 添加小地图
  • 添加hub界面(抬头显示)
  • 添加gui界面(用户输入)

项目技术栈

后续我们使用的技术栈始终如一,就是r3f及其相关的库。 相关内容参见我写的这一篇文章 threejs人物控制-基础篇

以下是当前项目安装的所有npm包

sh 复制代码
npm create vite@latest projectName -- --template react-swc-ts
npm install three @types/three @react-three/fiber
npm install @react-three/drei 
npm install @react-three/rapier
npm install ecctrl

获取人物模型

你可以从Mixamo网站获取一些你想要的模型和动画。

获取你想要的人物和动画,再使用blender将动画混合到你的模型中。最后为了方便将fbx或obj统一转成glb格式。(glb是模型的二进制格式,解析和加载性能更高。)

替换人物模型

这里在之前代码基础上做如下修改:

  1. 新模型文件命名为robotLady.glb,放到public目录下的models文件夹中。
  2. 去除冗余的动画设置,只配置3个动画:idle、walk、run。
  3. 将文件的加载路径提取作为常量,便于管理和配置。
  4. 更改切换模型后,修改人物的碰撞体(胶囊的半径和圆柱的长度)

进入到之前的player.tsx文件修改如下:

player.tsx 复制代码
const PATH = "./models/robotLady.glb"
export default function Player() {
  const { scene } = useGLTF(PATH)
  const curAnimation = useGame((state) => state.curAnimation);
 
 // 重命名动画
  const animationSet: AnimationSet = {
    idle: "idle",
    walk: "walk",
    run: "run",
  };

  return <Ecctrl
    animated
    followLight
    camMaxDis={-1000}
    floatHeight={0.01}
    capsuleHalfHeight={0.5}
    capsuleRadius={0.3}
  >
    <Suspense fallback={<capsuleGeometry args={[0.3, 1]} />} >
      <EcctrlAnimation characterURL={PATH} animationSet={animationSet}>
        <primitive castShadow object={scene} position={[0, -0.8, 0]} />
      </EcctrlAnimation>
    </Suspense>
  </Ecctrl>
}

动画模块封装

之前使用 EcctrlAnimation 组件来省略代码,实际使用时,动画自定义的部分颇多。此外它重新再记载了一次模型,总体上降低了性能。所以参考原来的组件,我们实现自己的自定义动画组件,方便自己管理。

animation.tsx 复制代码
import { useAnimations } from "@react-three/drei";
import { useEffect, useRef, Suspense } from "react";
import * as THREE from "three";
import { useGame } from "ecctrl";
/**
  * 人物动画设置
  * 参数:
  * animations: 动画集合
  * animationSet: 动画设置(按键对应触发的动画)
  */
export function Animation(props: AnimationProps) {
  const groupRef = useRef(null);
  const { actions } = useAnimations(props.animations, groupRef);
  const curAnimation = useGame((state) => state.curAnimation);
  const resetAnimation = useGame((state) => state.reset);
  const initializeAnimationSet = useGame(
    (state) => state.initializeAnimationSet
  );

  // 初始化动画设置
  useEffect(() => {
    initializeAnimationSet(props.animationSet);
  }, []);

  // 播放动画
  useEffect(() => {
    const action: any =
      actions[curAnimation ? curAnimation : props.animationSet.idle];
    if (!action) return
    // 某些动画只执行一次
    if (
      curAnimation === props.animationSet.jump ||
      curAnimation === props.animationSet.jumpLand ||
      curAnimation === props.animationSet.action1 ||
      curAnimation === props.animationSet.action2 ||
      curAnimation === props.animationSet.action3 ||
      curAnimation === props.animationSet.action4
    ) {
      action
        .reset()
        .fadeIn(0.2)
        .setLoop(THREE.LoopOnce, 0)
        .play();
      // clamp 处理,动画播放完之后,停止在最后一帧
      action.clampWhenFinished = true;
    } else {
      action.reset().fadeIn(0.2).play();
    }
    // 只执行一次的动画,重置到原始状态
    action._mixer.addEventListener("finished", () => resetAnimation());
    return () => {
      // 退出上一个动作,避免动画重叠
      action.fadeOut(0.2);
      // 去除混合器的监听
      action._mixer.removeEventListener("finished", () =>
        resetAnimation()
      );
      action._mixer._listeners = [];
    };
  }, [curAnimation]);

  return (
    <Suspense fallback={null}>
      <group ref={groupRef} dispose={null} userData={{ camExcludeCollision: true }}>
        {props.children}
      </group>
    </Suspense>
  );
}

和我们之前的写法基本逻辑是一致的,将原有逻辑拆分为独立的组件,便于管理。将动画集合和动画设置作为参数传进来,它将包裹在玩家模型外,作为玩家的父元素。

修改player.tsx,去除原来的EcctrlAnimation组件,使用自定义的Animation替代。

player.tsx 复制代码
  const { scene, animations } = useGLTF(PATH)
...
<Animation animations={animations} animationSet={animationSet}>
     <primitive castShadow object={scene} position={[0, -0.8, 0]} />
 </Animation>
...

加载环境模型

进入models文件夹下的floor,我们将使用加载的环境模型,将原来代码修改如下。

floor.tsx 复制代码
import { useLayoutEffect } from "react";
import { useGLTF } from "@react-three/drei";
import { RigidBody } from "@react-three/rapier";
import { openShadow } from "../../utils";

const LAND_PATH = './models/land/SM_Landscape_MG01.gltf'
const ISLAND_PATH = './models/island/SM_Islands_01.gltf'

useGLTF.preload(ISLAND_PATH);

export default function Floor() {
  // 加载模型
  const land = useGLTF(LAND_PATH); // 地面
  const island = useGLTF(ISLAND_PATH); // 岛屿建筑
  // dom更新后渲染前
  useLayoutEffect(() => {
    if (!island.scene || !land.scene) return
    openShadow(land.scene);
    openShadow(island.scene);
  }, [island.scene, land.scene]);

  return (
    <group dispose={null}>
      <RigidBody
        name="环境"
        type="fixed"
        colliders="trimesh"
        position={[-20, -10, 80]}
      >
        <primitive object={land.scene} />
        <primitive object={island.scene} />
      </RigidBody>
    </group>
  );
}

你可以使用自己的其他环境模型来替代。 但有4点需要注意:

  1. 模型的线面越多,加载越慢,进行物理计算时,计算量越大。
  2. RigidBody使用的碰撞模式,为 trimesh时,会使用三角网格碰撞体来逼近物体的形状,提高了碰撞精度的同时,由于计算复杂性较高,页面将会变得卡顿。
  3. 纠正基础篇的错误,RigidBody的collider接受 ball(球形)、cuboid(长方体)、hull(多边形)、trimesh(三角网格)及false(不使用自动计算的碰撞体)。
  4. 最佳方案:如果允许的话,你可以让建模师,专门在模型中放置一套用来计算碰撞体的简单线面结构(就像人物模型总是使用胶囊来生成碰撞体一样)。

添加辅助工具

将我们之前添加的Grid辅助网格线去除,添加<axesHelper args={[500]} />辅助坐标线。安装r3f-perf,添加Perf组件。

sh 复制代码
npm install --save-dev r3f-perf

这是一个性能监控器库,可以轻松看到当前项目的gpu、cpu、fps、线面数量等实时参数,但只在开发阶段使用。 代码修改如下:

index.tsx 复制代码
import { Perf } from "r3f-perf";
...
  <Perf position="top-right" />
  <axesHelper args={[500]} />
</Suspense>
...

调试可能遇到的bug

1.人物疯狂旋转

当环境线面较多时,由于Ecctrl库会根据周围环境的法线计算人物的平衡性。这会导致人物疯狂旋转,如果你的人物也疯狂旋转。在Ecctrl的配置项上将autoBalance设置为false。或者将autoBalanceDampingCautoBalanceDampingOnY设置的更小些。

2.人物穿过地面无限掉落

当环境模型太大时,物理效果生效时,环境模型尚未加载。这时人物会掉落到地面以下,解决方案也很简单,只要让物理效果的生效时间往后延迟一段时间就好。

index.tsx 复制代码
   ...
 // 延时启动物理引擎
  const [pausedPhysics, setPausedPhysics] = useState(true);
  useEffect(() => {
    const timeout = setTimeout(() => {
      setPausedPhysics(false);
    }, 2500);
    
...
<Physics debug timeStep="vary" paused={pausedPhysics}>
...

3. 卡顿

如果遇到性能问题,以下方法可以快速解决一部分卡顿问题。如果还是存在,则需要使用性能监控工具,逐段检测代码,识别造成性能瓶颈的原因。

  1. 减少模型的线面
  2. 将光线产生阴影的功能关掉。
  3. 关掉physics的debug模式
  4. 使用useMemo和useCallback避免不必要的渲染
  5. 在index.tsx添加以下代码
index.tsx 复制代码
import { Preload, AdaptiveDpr} from "@react-three/drei";
    ...
    {/* 预加载可见对象 */}
    <Preload />
    {/* 允许暂时降低视觉质量以换取更多性能,例如当相机移动时 */}
    <AdaptiveDpr pixelated />
   </Suspense>
   ...

结语

本次完成

  • 替换人物模型
  • 添加人物动画
  • 加载场景模型

项目地址

本次项目地址

往期-进阶控制地址
往期-基础控制地址

最终效果

后续我们将会完善各种环境的效果,包括一些音效,云朵、水流等。并且考虑添加一些可交互的物品和人物,及hub显示。

我将会持续不断的更新,让这个项目逐渐完善,直到可以作为一个简单的3d游戏框架。

也希望大家能参与进来,与我进行更多的交流,提出修改意见。让我们互相学习共同进步。

这类的代码调试起来总是不容易的,每一篇都是用心考量写就的。希望诸位多多点赞和收藏,让我有更多写下去的动力!

如果你有什么疑问,也可以在评论区提出,我们会尽力解答。

相关推荐
学习使我快乐012 小时前
JS进阶 3——深入面向对象、原型
开发语言·前端·javascript
bobostudio19952 小时前
TypeScript 设计模式之【策略模式】
前端·javascript·设计模式·typescript·策略模式
黄尚圈圈3 小时前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水4 小时前
简洁之道 - React Hook Form
前端
正小安6 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch8 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光8 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   8 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   8 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web8 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery