threejs 实现3D游戏(8)——通过配置文件管理场景模型

422 阅读5分钟

概述

有些时候,你的用户可能会频繁变更模型,及模型的位置。假如你每次都在代码中进行修改那么显然过于浪费我们宝贵的时间。所以我们给出一个config.json的静态文件,方便我们在其中配置模型的相关参数。

配置文件的结构

先确定我们的文件是json文件,然后添加和模型的相关配置,当然你也可添加其他相关的配置如页面标题、tab栏的logo等。

{
    "models": [
        {"name":"玩家","position":[1,6,3],"path":"./models/player.glb","link":"","debug":true},
        {"name":"建筑","position":[2,6,5],"path":"./models/room.glb","link":"","debug":true}
    ]
}

这里我们为models添加数组,每个数组中的元素都是一个模型对象。它的参数包括模型名称、模型位置、加载路径、以及是否处于debug模式。

这里的 debug 模式,启用后,玩家就可以自己拖动和旋转模型,并获取其位置。

如果用户想自己定义和添加修改模型,他只需要往public目录下的models文件夹中添加模型,并在这里的path中填写

理论上你可以把所有模型有关的文件都放到这个配置文件中进行集中管理,再在代码中调用。

加载配置文件

受到浏览器安全策略的影响,我们不能在js中主动读取本地的文件。所以我们实际上是从服务端获取config.json配置文件,然后加载相应模型。

为了方便使用,我们使用hook式的方式来创建获取函数。

export type Config = {
  name: string;
  link: string;
  path: string;
  position: Array<number>;
  rotation?: Array<number>;
  debug?: boolean;
  mode?: "translate" | "rotate" | "scale";
};
export function useConfig(){
    const [config, setConfig] = useState<{models:Array<Config>}>({ models:[] });
    useEffect(() => {
    fetchConfig();
    }, []);

     
  async function fetchConfig() {
    try {
      const res = await fetch("./config.json");
      const configData = await res.json();
      setConfig(configData);
    } catch (error) {
      console.error("Fetch 【config.json】 error:", error);
    }
  }
  
  return config
}

这是一个hooks函数,这意味着我们可以在任何地方调用 useConfig 获取配置文件了。 这是我推荐的方式,如果你不习惯的话,可以在模型加载阶段请求配置数据,然后存到store里或缓存在本地。

根据配置来加载及调试模型

我们专门实现一个组件用来统一加载和调试由配置文件提供的模型。

获取配置,遍历加载

我们在models文件夹下新建configModel.tsx文件。

import { Config,useConfig } from '@src/hook'

export default function ConfigModel() {
  const { models } = useConfig();

  return (
    <group dispose={null}>
      {models?.map((config:Config) => (
        <LoadModels
          key={path}
          {...config}
        />
      ))}
    </group>
  );
}

我们通过 useConfig 获取到配置文件内容,解构获取到models的配置数组,遍历加载每一个模型,将配置传递到下一级组件。

根据配置加载模型

我们根据传递的参数逐个处理就可以了。 使用path加载模型,position和rotation赋给模型的实体,根据debug决定是否启动拖拽组件,mode模式决定启用拖拽组件的移动、旋转、缩放模式。

其中旋转、调试、组件模式 是可选参数,具有默认值。

// 单个模型的加载函数
function LoadModels({path,position,link, 
  rotation = [0, 0, 0],
  debug = false,
  mode = "translate"}: Config) {
  
  // 加载模型
  const obj = useGLTF(path);
  const meshRef = useRef<Object3D>(null!);

  // 当拖动模型时
  function handleChange() {
    console.log("position", meshRef.current?.position);
    console.log("rotation", meshRef.current?.rotation);
  }
   // 点击模型时的事件,这里假定:模型上被绑定了要跳转的网页
  function onClick() {
    !debug && window.open(link);
  }

  return (
    <group>
      <mesh onClick={onClick}>
        <primitive object={obj.scene} position={position} ref={meshRef} />
      </mesh>
      {debug && (
        <TransformControls
          object={obj.scene}
          mode="translate"
          onChange={handleChange}
        />
      )}
    </group>
  );
}

思考和优化

有了配置文件,和相关的加载组件。我们对于模型放置的问题,基本可以靠着配置解决。但是这个拖拽组件有一个问题。它不能同时具有移动、旋转、缩放的功能,而是每次只能进行其中一个操作。这让我们在放置模型时,显得缓慢而笨拙。

我从 react-three/drei 找到了一个同时具有三者的组件PivotControls

并实现了一个demo如下

export default function TestModel() {
  // 加载模型
  const obj = useGLTF("./models/player.glb");
  const meshRef = useRef<Object3D>(null!);

  useEffect(() => {
    // 接受阴影
    obj.scene.traverse((child) => {
      if (
        child instanceof THREE.Mesh &&
        child.material instanceof THREE.MeshStandardMaterial
      ) {
        child.receiveShadow = true;
      }
    });
  }, []);

  // 变换矩阵是以自己为中心的变化,如果计算世界坐标和旋转,需要加上自身的世界坐标
  function getPosition(matrix: THREE.Matrix4) {
    const position = new THREE.Vector3();
    position.x = matrix.elements[12];
    position.y = matrix.elements[13];
    position.z = matrix.elements[14];
    return position.add(meshRef.current.position);
  }
  function getRotation(matrix: THREE.Matrix4) {
    const euler = new THREE.Euler();
    const quaternion = new THREE.Quaternion();
    const scale = new THREE.Vector3();

    // 将矩阵分解为位置、旋转(以四元数表示)和缩放
    matrix.decompose(new THREE.Vector3(), quaternion, scale);
    // 将四元数转换为欧拉角
    euler.setFromQuaternion(quaternion);
    euler.x += meshRef.current.rotation.x;
    euler.y += meshRef.current.rotation.y;
    euler.z += meshRef.current.rotation.z;
    return euler;
  }

  return (
    <group>
      <PivotControls
        depthTest={false}
        anchor={[0, 0, 0]}
        onDrag={(matrix) => {
          console.log("Position:", getPosition(matrix));
          console.log("rotation:", getRotation(matrix));
        }}
      >
        <Bounds fit clip observe margin={1}>
          <primitive object={obj.scene} position={[0, 2, 0]} ref={meshRef} />
        </Bounds>
      </PivotControls>
      <OrbitControls makeDefault />
    </group>
  );
}

它能同时兼有三者,但它只能在 OrbitControls 控制器下工作。如果要使用它的话我们不能实现对单个模型的debug控制。而是进行整体的debug控制

{
    "models": [
        {"name":"玩家","position":[1,6,3],"path":"./models/player.glb","link":""},
        {"name":"建筑","position":[2,6,5],"path":"./models/room.glb","link":""}
    ],
    "debug":true
}

然后通过加载配置,根据debug进行条件判断:debug?玩家控制:行星控制。 以这样的模式来进行统一的配置。

比较和优劣

二者使用方法是一样的,都是通过拖拽组件获取移动后的相关参数,再回填到config.json文件中。

对于开发来说PivotControls可能更实用。但是考虑复杂性,可能TransformControls组件更适合一般用户。

PivotControls: 只能在 行星控制模式下使用,功能集合一身使用较为复杂,需要一定的3D基础。

image.png

TransformControls:: 在任意模式可用,玩家可以在移动中控制模型。 每次只显示单一功能,使用其他功能需要切换。 image.png

结语

其实TransformControl的模式切换可以不通过config.json文件,可以判断debug模式在左上角放一个切换状态按钮,或设立一个快捷切换键,把提示文本显示在右上角。让用户自己切换。

在b站看到战国终结的最后一战有所感慨:

战国志·石田三成
幼龄志高胆气豪,愿随鞍马博功劳。
君威天下转眼消,太阁遗志一梦遥。