用threejs实现人物控制-基础篇

功能分析

在游戏相关的threejs项目中,经常需要用到游戏的人物控制模块。
而尽管游戏不同,但关于人物控制的模块其实是相一致的。
它包括以下几个小模块:

  • 人物的移动转向逻辑
  • 镜头相机的跟随逻辑
  • 物理效果,如碰撞重力等
  • 人物交互效果(如跳跃动、拾取、攻击动画等)
  • 手机端适配,摇感控制

搭建框架

  • 技术选型

  • 初始化项目

    vite官网查看文档,考虑代码的编译添加swc,为了类型安全和补全提示使用ts
    用threejs实现人物控制-基础篇

  • vite获取项目模板

npm create vite@latest playerCtr -- --template react-swc-ts
  • 添加react-three依赖
  npm install three @types/three @react-three/fiber 
  • 添加react-three的工具包
  npm install @react-three/drei 

用threejs实现人物控制-基础篇

  • 添加物理效果的依赖
  npm install @react-three/rapier
  • 添加骨骼动画的依赖
  npm install three-stdlib

构建项目

项目结构

我们将ui和模型进行分离,让模型层处于ui视图的下方,ui视图浮在模型上。

  • 在src下新建文件夹models, 我们将所有的3d模型相关逻辑放在这里,以便和其他一般的纯tsx页面区分
  • src下新建pages文件夹,放置非模型的ui视图,如hub、加载页、弹窗页等。并让pages所代表的ui视图整体浮在models上。

初始化canvas

models下新建index.tsx文件

  • 使用Canvas组件作为根节点展示所有的3d内容
import { Canvas } from '@react-three/fiber'

function Models() {
 return <Canvas style={{ width: '100%', height: '100%' }}>
 </Canvas>
 }
 export default Models
  • 从react-three/rapier 中导出Physics,Physics的子节点将会产生物理效果
import { Canvas } from '@react-three/fiber'
import { Physics } from '@react-three/rapier'

function Models() {
  return <Canvas style={{ width: '100%', height: '100%' }}>
        <Physics>
        </Physics>
    </Canvas>
}
export default Models
  • 添加行星控制器、添加辅助线、设置基本光照(环境光、直线光、反射光)添加天空盒
import { Canvas } from '@react-three/fiber'
import { Physics } from '@react-three/rapier'
import { Environment, OrbitControls, Sky } from '@react-three/drei'

function Models() {
  return <Canvas style={{ width: '100%', height: '100%' }}>
        <Physics>
        </Physics>
      <OrbitControls {...controlConfig} makeDefault />
      <Sky sunPosition={[100, 20, 100]} distance={1000} />
      <Environment preset="city" /> // 环境光的反射
      
      <ambientLight intensity={1} />
      <directionalLight
        position={[100, 100, 100]}
        intensity={1.5}
      />
       <axesHelper args={[500]} />
    </Canvas>
}
export default Models

最后在app.tsx中引入Models

import Models from './models'

function App() {
  return (
      <Models />
  )
}

export default App

现在应该可以在页面上看到蓝色的天空和辅助线

用threejs实现人物控制-基础篇

加载模型

  • 加载环境模型
    去下载
    models下新建ground.tsx文件
    使用 @react-three/drei 导出的 useGLTF加载模型,这里的加载路径是public目录中的

使用RigidBody 刚体组件将模型包裹,这样才会被外部的Physics识别为刚体。因为环境是固定不动,刚体类型为fixed,碰撞类型为trimesh,即该模型的本身为可碰撞(可选有box、sphere、cylinder)注意仅限于简单模型,否则应该使用box。

import { useGLTF } from "@react-three/drei";
import { RigidBody } from "@react-three/rapier";

useGLTF.preload("./world.glb"); // 预加载
function Ground({ log = true }) {
  // 加载模型
  const ground = useGLTF("./world.glb"); // 地面
  return (
    <group dispose={null}>
      <RigidBody
        name="环境"
        type="fixed"
        colliders="trimesh"
        position={[0, 0, 0]}
      >
        <primitive object={ground.scene} />
      </RigidBody>
    </group>
  );
}
export default Ground
  • 加载人物模型
    去下载
    加载过程同理,刚体类型为动态,刚体的碰撞与旋转都关闭。这2者我们手动控制。人物的碰撞模型,使用胶囊碰撞组件CapsuleCollider替代(人物的建模线面太多,生成碰撞体有性能问题)。你可以在Physic上将debug属性设为true,查看碰撞线面的多寡。
import { useGLTF } from "@react-three/drei";
import { CapsuleCollider, RigidBody } from "@react-three/rapier";

function Player() {
  // 加载模型
  const { scene } = useGLTF("./player/actor.gltf");

  return <group dispose={null}>
    <RigidBody
      colliders={false}
      type="dynamic"
      enabledRotations={[false, false, false]}
    >
      <primitive object={scene} position={[0, -1, 0]} />
      <CapsuleCollider args={[0.6, 0.3]} position={[0, -0.1, 0]} />
    </RigidBody>
  </group>
}

export default Player;

将上面的2个模型组件加载到Physics中,并将debug打开,查看碰撞体

...
<Physics debug={ture}>
    <Player />
    <Ground />
</Physics>
...

如果前面都正确无误的话这个时候应该能看到环境和人物了如下图

用threejs实现人物控制-基础篇

人物控制

键盘映射

从工具包中导入KeyboardControls 来定义用户的键盘输入, 定义键盘映射(一般在游戏中这个是可配置的,这里简单写死)上、下、左、右、跳5个动作。

import {  KeyboardControls } from '@react-three/drei'
  // 键盘事件
  const actions = [
    { name: "forward", keys: ["ArrowUp", "w", "W"] },
    { name: "backward", keys: ["ArrowDown", "s", "S"] },
    { name: "left", keys: ["ArrowLeft", "a", "A"] },
    { name: "right", keys: ["ArrowRight", "d", "D"] },
    { name: "jump", keys: ["Space"] },
  ];
  
  <Canvas style={{ width: '100%', height: '100%' }}>
        <KeyboardControls map={actions}>
            ...
        </KeyboardControls>
    </Canvas>

人物移动

进入player.tsx,获取玩家的引用, 检测用户的键盘输入,改变玩家的速度

import { useKeyboardControls } from "@react-three/drei";
import { RapierRigidBody} from "@react-three/rapier";
   ...
 const player = useRef<RapierRigidBody>(null); // 玩家的引用
 useKeyboardControls((state) => move(state)) // 监听自定义键盘事件
 
 function move(){...}
  
   <RigidBody
      ref={player}
      ...
      >
      ...

移动函数主要移动逻辑,toFixed是为了处理浮点数,保留三位小数


const SPEED = 4; // 移动速度
const JUMP = 7; // 跳跃速度
const velocity = new THREE.Vector3(); // 方向
// 移动逻辑
function move(state: {
    [key: string]: boolean;
  }): boolean {
    if (!camera || !player.current) return false
    const { forward, backward, left, right, jump } = state
    // 获取移动方向
    velocity.set(Number(right) - Number(left), 0, Number(backward) - Number(forward))
      .normalize()
      .multiplyScalar(SPEED)
      .applyEuler(camera.rotation); // 以相机方向为基准,应用欧拉角,保证前后左右的位置始终相对于你的镜头方向

    const target = {
      x: toFixed(velocity.x),
      y: player.current.linvel().y, // 镜头方向的y方向需要舍弃掉,人物在垂直方向不会获得速度,只会继承自己的原速度
      z: toFixed(velocity.z),
    };
    player.current.setLinvel(target, true);
    // 跳跃
    if (jump) {
      player.current.setLinvel({ x: 0, y: JUMP, z: 0 }, true);
    }
    return true
  }
 // 处理浮点数
function toFixed(num: number, digit = 3) {
  const scale = 10 ** digit;
  return Math.floor(num * scale) / scale;
}

现在你的人物可以平移了,但是没有动画,镜头也没有跟随移动。

用threejs实现人物控制-基础篇

移动动画

从drei导入useAnimations 再将动画与模型双向绑定。获得actions和names分别是动画数组和动画索引的数组(通常是动画的名字)

import { useAnimations, useGLTF } from "@react-three/drei";

const { scene, animations } = useGLTF("./player/actor.gltf");
const { ref, actions, names } = useAnimations(animations, scene);
  
<RigidBody
>
  <primitive ref={ref} ... />
 
</RigidBody>

在move函数中判断动画的播放情况

// 生成枚举值,对应names中的动画索引
enum STATUS {
  walk,
  idle,
  run
}

 const [status, setStatus] = useState(names[STATUS.idle])
  
  useEffect(() => {
    if (!names || !actions) return
    // 退出上次的动画
    names?.forEach(name => {
      actions[name]?.fadeOut(0.2).stop();
    });
    // 切换当前动画,从头播放,0.2s切换动画
    actions[status]?.reset().fadeIn(0.2).play()
  }, [actions, names, status])
  
  
funciton move(){
    ...
       // 当速度不为0或跳跃时播放 run动画
      let key = names[STATUS.idle]
      if (velocity.x !== 0 || velocity.z !== 0 || jump) {
        key = names[STATUS.run]
      }
      if (status != key) setStatus(key)
}

要注意生命周期的顺序执行,如果一切顺利应该可以在移动时触发动画了,但是人物还不会转弯,十分生硬。

用threejs实现人物控制-基础篇

人物旋转

需要从rapier中导出一些角度的数学转换工具, 我们根据当前的速度方向计算人物的旋转角度。再转成欧拉角,最后转成四元数。使用lerp插值变化四元数赋值给玩家。

import { euler,quat} from "@react-three/rapier";

  // 帧渲染
  useFrame((state: RootState, delta) => {
    if (!player.current) return;
    // 人物移动时进行旋转
    if (direction.x !== 0 || direction.z !== 0) {
      rotation();
    }
  })

  // 人物旋转
  function rotation() {
    const rotationAngle = Math.atan2(velocity.x, velocity.z); // 旋转角度
    const rotationEuler = euler().set(0, rotationAngle, 0); // 旋转欧拉角
    const rotationQuaternion = quat().setFromEuler(rotationEuler); // 目标旋转四元数
    const startQuaternion = quat().copy(player.current.rotation()); // 当前旋转四元数

    startQuaternion.slerp(rotationQuaternion, 0.2); // 0.2s的过渡时间
    player.current.setRotation(startQuaternion); // 应用此四元数
  }

如果到现在正常的话人物可以正常转向了,但是人物会移动到视野之外。

用threejs实现人物控制-基础篇

镜头追随

我们要让摄像机的位置始终处在以玩家为中心的位置。我们建立以玩家为中心的球形坐标系。在球形坐标系中,要确定一个点的位置则需要3个参数:半径,及点与垂直和水平面的个夹角。
获得球形坐标后,后再把球形坐标转换回世界坐标系,在此基础上向量加和玩家自身的位置,就得到了相机更新后的世界坐标。

...
  // 帧渲染
  useFrame((state: RootState, delta) => {
    if (!player.current) return;

    // 玩家距离相机的位置
    const distance = toFixed((state.controls as CustomEventDispatcher).getDistance());
    // 更新相机位置
    updateCamera(state, distance, delta);
      ...
  })
  
    function updateCamera(state: RootState, distance: number, delta: number) {
    if (!player.current) return

    const playerPos = player.current.translation(); // 玩家世界坐标

    const rotateDelta = (direction.x / 100) * delta;

    const { camera, controls } = state;
    controls.target.copy(playerPos);
    const spherical = new THREE.Spherical(
      distance,
      controls.getPolarAngle(),
      controls.getAzimuthalAngle() - rotateDelta
    );
    const position = new THREE.Vector3().setFromSpherical(spherical);
    camera.position.copy(playerPos).add(position);
  }
...

如果顺利的话,这时应该已实现了镜头追随。
用threejs实现人物控制-基础篇

结语

我把代码放在了gitee上,有不明白的可以自行git clone:
地址

这一路写下来,其实你也发现了,这种3d模型的项目对于各种数学公式的使用十分频繁。对空间向量,坐标转换,四元数组等数学知识有一定的要求,如果接着往后面写的话,如自动寻路等会涉及到各种算法。
这段代码有很多性能可以优化的地方,但是基本实现思路都是一样的。
前段时间我发现 react-three-fiber的作者pmndrs开源了一个新的react-three库,专门用来做角色控制的。
下次有空的时候,我将在进阶篇,使用这个库来重新实现玩家控制逻辑。
我已经大致阅读过他的源码,代码很优雅,性能十分出色。我相信随着迭代,将来我们不再需要这样一步步自己实现相关的功能。

那么下次再会了,游戏世界的造物主们!

原文链接:https://juejin.cn/post/7351642274273918986 作者:代码之味

(0)
上一篇 2024年3月30日 上午10:32
下一篇 2024年3月30日 上午10:38

相关推荐

发表回复

登录后才能评论