threejs人物控制-进阶篇

概述

上次用较为基础的方式实现了人物控制模块,但整体性能不佳,有很大的优化和改进空间。 这次我们来使用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代码

相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang2 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、5 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui