概述
上次用较为基础的方式实现了人物控制模块,但整体性能不佳,有很大的优化和改进空间。 这次我们来使用react-three-fiber的作者 pmndrs 在github上发布的新开源库 ecctrl 来改进和优化这一点。
搭建项目
本次是在前次基础上做的工作,所以还是使用的和前次一样的框架。详细见我的 threejs人物控制-基础篇 。 只是在之前的基础上多下载一个包,以下命令逐行输入执行。
shell
npm create vite@latest playerCtr -- --template react-swc-ts
npm install three @types/three @react-three/fiber
npm install @react-three/drei
npm install ecctrl
项目结构
和基础篇一样,我们在src下建立models文件夹和pages文件夹分别用来存放3d相关逻辑与普通的页面逻辑。 文件结构如下:
project
|---public 模型资源存放位置
|---src
|----|---models 三维模型相关
|----|---pages 二维ui视图相关
|----|---utils 通用工具类
初始化
models文件夹中新建index.tsx文件
index.tsx
import { Canvas } from '@react-three/fiber'
import { Suspense } from 'react'
import { Physics } from '@react-three/rapier'
export default function Models() {
return <Canvas style={{ width: '100%', height: '100%' }} shadows
camera={{fov: 65,near: 0.1,far: 1000,}}
>
<Suspense fallback={null}>
<Physics debug={false}>
</Physics>
</Suspense>
</Canvas>
}
在app.tsx中挂载models
app.tsx
import Models from './models'
export default function App() {
return <Models />
}
搭建3d场景
由于什么都没加载,这时页面应当一片空白。你可以在Physics中挂载模型作为子节点。
我们在models目录下新建floor.tsx文件,用boxGeometry创造一个长宽300,高5的长方形作为地板。
floor.tsx
import { RigidBody } from "@react-three/rapier";
export default function Floor() {
return (
<RigidBody type="fixed">
<mesh receiveShadow position={[0, -3.5, 0]}>
<boxGeometry args={[300, 5, 300]} />
<meshStandardMaterial color="lightblue" />
</mesh>
</RigidBody>
);
}
此时挂载后,应该看到的地板是一片黑色,我们需要添加光线。
在models下新建lights.tsx,添加直线光(此光范围很小周围只有50长宽,但会跟随玩家,以便减少阴影的计算量)和环境光
lights.tsx
import { useHelper } from "@react-three/drei";
import { useRef } from "react";
import * as THREE from "three";
export default function Lights() {
const directionalLightRef = useRef<THREE.DirectionalLight>();
useHelper(directionalLightRef, THREE.DirectionalLightHelper, 1);
return (
<>
<directionalLight
castShadow
shadow-normalBias={0.06}
position={[20, 30, 10]}
intensity={1.5}
shadow-mapSize={[1024, 1024]}
shadow-camera-near={1}
shadow-camera-far={50}
shadow-camera-top={50}
shadow-camera-right={50}
shadow-camera-bottom={-50}
shadow-camera-left={-50}
name="followLight"
ref={directionalLightRef}
/>
<ambientLight intensity={0.5} />
</>
);
}
挂载光线和地板,同时为了防止背景单独我在body上加了一个自上而下的线性渐变色css用来模拟天空的效果(这次不再使用天空盒Sky)。
index.tsx
import Floor from "./floor.tsx"
...
<Lights />
<Physics debug={false}>
<Floor>
</Physics>
...
}
人物控制器
此时尚未加载控制器,故镜头不能移动。添加ecctrl库中的人物控制器,与按键映射。为方便观察移动,添加了辅助网格Grid。
index.tsx
import { Grid,KeyboardControls } from '@react-three/drei'
import Ecctrl from 'ecctrl'
// 按键映射
const keyboardMap = [
{ name: "forward", keys: ["ArrowUp", "KeyW"] },
{ name: "backward", keys: ["ArrowDown", "KeyS"] },
{ name: "leftward", keys: ["ArrowLeft", "KeyA"] },
{ name: "rightward", keys: ["ArrowRight", "KeyD"] },
{ name: "jump", keys: ["Space"] },
{ name: "run", keys: ["Shift"] },
{ name: "action1", keys: ["1"] },
{ name: "action2", keys: ["2"] },
{ name: "action3", keys: ["3"] },
{ name: "action4", keys: ["KeyF"] },
];
...
<Grid args={[300, 300]}
sectionColor={"lightgray"} // 分割线颜色 亮灰色
cellColor={"gray"} // 网格颜色灰色
position={[0, -0.99, 0]}
userData={{ camExcludeCollision: true }} // 不会与相机碰撞
/>
<Physics debug={false}>
<KeyboardControls map={keyboardMap}>
<Ecctrl followLight >
</Ecctrl>
</KeyboardControls>
<Floor>
</Physics>
...
这个时候,可以通过WSAD键来移动镜头了,同时直线光会追随移动。你可以在Ecctrl上添加debug属性来调试人物控制器的内置参数来进行测试。
玩家模块
加载模型
models下新建player.tsx文件,public目录下新建models文件夹添加player.glb资源。 初始化模型的阴影设置。
player.tsx
import { useGLTF } from "@react-three/drei";
useGLTF.preload("./models/player.glb");
export default function Player() {
const { scene, animations } = useGLTF("./models/player.glb")
// 阴影配置
useEffect(() => {
scene.traverse(
(obj) => obj.isMesh && (obj.receiveShadow = obj.castShadow = true)
);
});
return <Suspense fallback={<capsuleGeometry args={[0.3, 0.7]} />}>
<mesh castShadow>
<primitive object={scene} position={[0, -0.65, 0]} />
</mesh>
</Suspense>
}
动画设置
将动画提供的ref与模型绑定,方便动画控制模型。
player.tsx
import { useAnimations, useGLTF } from "@react-three/drei";
const { scene, animations } = useGLTF("./models/player.glb")
const { ref, actions } = useAnimations(animations, scene);
...
<mesh castShadow>
<primitive ref={ref} object={scene} position={[0, -0.65, 0]} />
...
从ecctrl库对动画的名称进行设置(与按键配置一一对应)。
player.tsx
import { useGame } from "ecctrl";
...
const initializeAnimationSet = useGame(
(state) => state.initializeAnimationSet
);
// 重命名动画
const animationSet = {
idle: "Idle",
walk: "Walk",
run: "Run",
jump: "Jump_Start",
jumpIdle: "Jump_Idle",
jumpLand: "Jump_Land",
fall: "Climbing",
action1: "Wave",
action2: "Dance",
action3: "Cheer",
action4: "Attack(1h)",
};
useEffect(() => {
// 初始化动画设置
initializeAnimationSet(animationSet);
}, []);
...
播放动画
从useGame获取当前的动画,监听当前动画的变化,并播放。对于某些动画执行特定逻辑。
player.tsx
...
const curAnimation = useGame((state) => state.curAnimation);
const resetAnimation = useGame((state) => state.reset);
useEffect(() => {
// 播放动画
const action: any = actions[curAnimation ? curAnimation : animationSet.jumpIdle];
if (!action) return
// 某些动画只执行一次
if (
curAnimation === animationSet.jump ||
curAnimation === animationSet.action1 ||
curAnimation === animationSet.action2 ||
curAnimation === animationSet.action3 ||
curAnimation === 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]);
...
最后是player.tsx的完整代码:
player.tsx
import { useAnimations, useGLTF } from "@react-three/drei";
import { useGame } from "ecctrl";
import { Suspense, useEffect } from "react";
import * as THREE from "three";
useGLTF.preload("./models/player.glb");
export default function Player() {
const { scene, animations } = useGLTF("./models/player.glb")
const { ref, actions } = useAnimations(animations, scene);
const curAnimation = useGame((state) => state.curAnimation);
const resetAnimation = useGame((state) => state.reset);
const initializeAnimationSet = useGame(
(state) => state.initializeAnimationSet
);
// 重命名动画
const animationSet = {
idle: "Idle",
walk: "Walk",
run: "Run",
jump: "Jump_Start",
jumpIdle: "Jump_Idle",
jumpLand: "Jump_Land",
fall: "Climbing",
action1: "Wave",
action2: "Dance",
action3: "Cheer",
action4: "Attack(1h)",
};
useEffect(() => {
// 初始化动画设置
initializeAnimationSet(animationSet);
}, []);
// 阴影配置
useEffect(() => {
scene.traverse(
(obj) => obj.isMesh && (obj.receiveShadow = obj.castShadow = true)
);
});
useEffect(() => {
// 播放动画
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const action: any = actions[curAnimation ? curAnimation : animationSet.jumpIdle];
if (!action) return
// 某些动画只执行一次
if (
curAnimation === animationSet.jump ||
curAnimation === animationSet.action1 ||
curAnimation === animationSet.action2 ||
curAnimation === animationSet.action3 ||
curAnimation === 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={<capsuleGeometry args={[0.3, 0.7]} />}>
<mesh castShadow>
<primitive ref={ref} object={scene} position={[0, -0.6, 0]} />
</mesh>
</Suspense>
}
这样看来代码似乎很多,其实有些业务,ecctrl库内部已经做了,我们引用库简化下代码:
tsx
import { useGLTF } from "@react-three/drei";
import { EcctrlAnimation } from "ecctrl";
import { Suspense, useEffect } from "react";
useGLTF.preload("./models/player.glb");
export default function Player() {
const { scene } = useGLTF("./models/player.glb")
// 重命名动画
const animationSet = {
idle: "Idle",
walk: "Walk",
run: "Run",
jump: "Jump_Start",
jumpIdle: "Jump_Idle",
jumpLand: "Jump_Land",
fall: "Climbing",
action1: "Wave",
action2: "Dance",
action3: "Cheer",
action4: "Attack(1h)",
};
// 阴影配置
useEffect(() => {
scene.traverse(
(obj) => obj.isMesh && (obj.receiveShadow = obj.castShadow = true)
);
});
return <Suspense fallback={<capsuleGeometry args={[0.3, 0.7]} />}>
<EcctrlAnimation animationSet={animationSet} characterURL={'./models/player.glb'}>
<primitive castShadow object={scene} position={[0, -0.6.5, 0]} />
</EcctrlAnimation>
</Suspense>
}
这样我们只要关注模型的加载和模型动画的名称,你依然可以监听curAnimation来执行你的其他动画生命周期内的事件。
二维视图相关
pages页面初始化
pages文件夹下新建index.tsx
index.tsx
import Loading from "./loading.tsx";
import Joystick from "./joystick.tsx";
export default function Pages() {
return (
<div style={{
position: 'absolute',
inset: '0',
overflow: 'hidden',
pointerEvents: 'none',
zIndex: 9,
}} >
<Loading />
<Joystick />
</div >
);
}
在app.tsx中挂载
app.tsx
import Models from './models'
import Pages from './pages'
export default function App() {
return (
<>
<Models />
<Pages />
</>
)
}
加载页
在pages文件夹下新建loading.tsx
loading.tsx
import { useEffect, useState } from "react";
import { useProgress } from "@react-three/drei";
import "./loading.css";
// 模型加载时显示的页面
export default function Loading() {
const { progress } = useProgress();
const [isLoad, setIsLoad] = useState(false);
const [load, setLoad] = useState(true);
useEffect(() => {
if (progress >= 100) {
setIsLoad(true);
}
}, [progress]);
return (
load && (
<div className={`loading`}>
<div className="load">加载进度: {progress.toFixed()} %</div>
{isLoad && (
<div className="start" onClick={() => setLoad(false)}>
开始
</div>
)}
</div>
)
);
}
.loading.css
.loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 99999;
width: 100%;
height: 100%;
background-color: #242424;
pointer-events: painted;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
color: white;
text-align: center;
}
.load {
padding: 5px;
border-radius: 10px;
background-color: rgba(0, 0, 0, 0.5);
}
.start {
margin-top: 10px;
width: 100px;
height: 45px;
font-size: 18px;
cursor: pointer;
}
触摸屏摇杆操作
pages下新建joystic.tsx, 直接从ecctrl中导出EcctrlJoystick组件。判断当前是不是触摸屏,如果是的话显示摇杆(这里是把摇杆的5个按钮全部显示了)。
joystic.tsx
import { useEffect, useState } from "react";
import { EcctrlJoystick } from "ecctrl";
export default function Joystick() {
const [isTouchScreen, setIsTouchScreen] = useState(false)
useEffect(() => {
if (('ontouchstart' in window) ||
(navigator.maxTouchPoints > 0)) {
setIsTouchScreen(true)
} else {
setIsTouchScreen(false)
}
}, [])
return (
isTouchScreen && <EcctrlJoystick buttonNumber={5} />
);
}
结语
最后的效果:
ecctrl库简化了人物控制的逻辑和流程,仅仅使用几行代码即可实现人物的控制,此外还提供触摸屏的摇杆操纵,极大简化代码。但这个库毕竟才出来不久,大家也可以自己用一下看看,如果发现什么问题可以去github上给这个库 ecctrl 提issues。
我把代码上传到了gitee, 你可以自行查看: 点我check代码