概述
当我们上次利用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是模型的二进制格式,解析和加载性能更高。)
替换人物模型
这里在之前代码基础上做如下修改:
- 新模型文件命名为robotLady.glb,放到public目录下的models文件夹中。
- 去除冗余的动画设置,只配置3个动画:idle、walk、run。
- 将文件的加载路径提取作为常量,便于管理和配置。
- 更改切换模型后,修改人物的碰撞体(胶囊的半径和圆柱的长度)
进入到之前的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点需要注意:
- 模型的线面越多,加载越慢,进行物理计算时,计算量越大。
- RigidBody使用的碰撞模式,为 trimesh时,会使用三角网格碰撞体来逼近物体的形状,提高了碰撞精度的同时,由于计算复杂性较高,页面将会变得卡顿。
- 纠正基础篇的错误,RigidBody的collider接受 ball(球形)、cuboid(长方体)、hull(多边形)、trimesh(三角网格)及false(不使用自动计算的碰撞体)。
- 最佳方案:如果允许的话,你可以让建模师,专门在模型中放置一套用来计算碰撞体的简单线面结构(就像人物模型总是使用胶囊来生成碰撞体一样)。
添加辅助工具
将我们之前添加的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。或者将autoBalanceDampingC
和autoBalanceDampingOnY
设置的更小些。
2.人物穿过地面无限掉落
当环境模型太大时,物理效果生效时,环境模型尚未加载。这时人物会掉落到地面以下,解决方案也很简单,只要让物理效果的生效时间往后延迟一段时间就好。
index.tsx
...
// 延时启动物理引擎
const [pausedPhysics, setPausedPhysics] = useState(true);
useEffect(() => {
const timeout = setTimeout(() => {
setPausedPhysics(false);
}, 2500);
...
<Physics debug timeStep="vary" paused={pausedPhysics}>
...
3. 卡顿
如果遇到性能问题,以下方法可以快速解决一部分卡顿问题。如果还是存在,则需要使用性能监控工具,逐段检测代码,识别造成性能瓶颈的原因。
- 减少模型的线面
- 将光线产生阴影的功能关掉。
- 关掉physics的debug模式
- 使用useMemo和useCallback避免不必要的渲染
- 在index.tsx添加以下代码
index.tsx
import { Preload, AdaptiveDpr} from "@react-three/drei";
...
{/* 预加载可见对象 */}
<Preload />
{/* 允许暂时降低视觉质量以换取更多性能,例如当相机移动时 */}
<AdaptiveDpr pixelated />
</Suspense>
...
结语
本次完成
- 替换人物模型
- 添加人物动画
- 加载场景模型
项目地址
最终效果
后续我们将会完善各种环境的效果,包括一些音效,云朵、水流等。并且考虑添加一些可交互的物品和人物,及hub显示。
我将会持续不断的更新,让这个项目逐渐完善,直到可以作为一个简单的3d游戏框架。
也希望大家能参与进来,与我进行更多的交流,提出修改意见。让我们互相学习共同进步。
这类的代码调试起来总是不容易的,每一篇都是用心考量写就的。希望诸位多多点赞和收藏,让我有更多写下去的动力!
如果你有什么疑问,也可以在评论区提出,我们会尽力解答。