threejs 实现3D游戏(14) —— 通用小地图组件

877 阅读7分钟

概述

游戏中使用小地图来显示玩家当前的位置,免于迷路是个很常见的需求。上次我们在 小地图那期介绍过,实现小地图的2种思路:

  • 用静态图片并手动转换坐标
  • 使用正交相机渲染特定图层实现小地图

这次我们来实现一个通用的小地图组件,可以跨场景使用。我们期望这个组件,只要放到场景中设置需要的参数,它就能正常的显示地图和更新玩家位置和方向,不需要额外的代码编写行为。

方案分析

静态图片

实现方式:

  • 整张地图提前做成一张贴图
  • 使用 CSS 或 Canvas 绘制小地图 UI;
  • 玩家位置通过计算世界坐标到贴图坐标的映射关系来显示。

优点:

  • 性能开销极低,因为不需要额外渲染,只是 UI 层面的更新;
  • 更易控制样式,兼容性强,可直接集成到 HTML/CSS 布局中;
  • 对移动端/低端设备更友好;
  • 对像素风格或手绘风格的 2D 游戏尤其合适(本身就是从2d游戏中演化而来);

缺点:

  • 地图数据是静态的,动态内容(敌人、事件)需额外逻辑更新;
  • 需要你提前生成准确的小地图贴图,并同步地图大小比例;
  • 世界坐标→贴图坐标转换需要精确调试;
  • 不支持 3D 地形、上下楼层、遮挡等复杂空间感表达;

b5ab9ed73c924cdea0c4b94694bbebd6~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.webp

正交相机

实现方式:

  • 使用一个独立的正交相机 OrthographicCamera
  • 设置该相机只渲染指定图层(如 Layer 1);
  • 将小地图元素(玩家、关键点、地图轮廓等)放在该图层;
  • 在一个小视口中用 viewportWebGLRenderer.setScissor() 来渲染这张小地图。

优点:

  • 实时反映游戏场景变化(例如动态地图、移动物体、敌人、事件标记);
  • 可无缝使用现有 3D 场景数据,无需额外生成小地图贴图;
  • 支持动画、缩放、旋转等交互式小地图效果;
  • 对于大型地图或多人同步游戏,非常适合动态更新。

缺点:

  • 性能消耗较高,因为是额外一次渲染过程
  • 需要对图层管理、摄像机控制和渲染优化有一定掌控;
  • 对美术不友好的项目可能会有冗余渲染资源;

image.png

二者结合

综上,我们如果选择静态图的方式,每次都要想办法生成图片,没法通用。如果使用正交相机性能消耗又太高。我们将二者结合:

  • 使用正交相机生成静态图作为小地图底图;
  • 正交相机只渲染动态元素(玩家、敌人)合成上去;
  • 这样既减少渲染压力,又能实现部分动态性,也方便作为通用组件使用。

image.png

获取地图底图

我们需要一个组件:它能从场景中拍摄出我们需要的小地图底图。为此我们需要:

  1. 小地图所显示场景的模型
  2. 获得小地图所显示场景的边界和中心
  3. 根据前面2步的数据,设置正交相机的拍摄范围
  4. 获取底图纹理图片

1.小地图需要显示的模型

这一步一般的做法是专门设置一个图层(layer)用于小地图展示,将需要显示的模型设置为该图层,最后在正交相机中应用该图层。

这里因为后面要计算边界,所以我没使用这种办法,而是把小地图需要显示的模型放在一个组中并起了个名字(如下图,group的名字是env),需要注意的是你的名字必须是独一无二的。。 image.png

直接通过代码获得小地图需要显示的模型组,下面代码的作用是从场景中每帧渲染前检测当前场景中是否找到名为env的模型,如果找到则退出。

  const [env, setEnv] = useState<THREE.Object3D>();
  const { gl } = useThree();
  
  // 场景加载后寻找env模型
  useEffect(() => {
    let frameId = 0;
    const tryFind = () => {
      const envObj = scene.getObjectByName("env");
      envObj && setEnv(envObj);
    };
    const check = () => {
      tryFind();
      if (!env) frameId = requestAnimationFrame(check);
    };
    requestAnimationFrame(check);
    return () => cancelAnimationFrame(frameId);
  }, [scene]);

2.获取场景的边界和中心

我们实现一个hook,它接受一个3D模型,并返回该模型的包围盒、包围盒中心、包围盒尺寸。threejs中的包围盒默认是轴对齐包围盒(AABB如图)。

download.jpg

export function useObjBorder(obj?: Object3D | null): [Box3, Vector3, Vector3] {
  const boxRef = useRef(new Box3());
  const centerRef = useRef(new Vector3());
  const sizeRef = useRef(new Vector3());
  useLayoutEffect(() => {
    if (!obj) return;
    boxRef.current.setFromObject(obj);
    boxRef.current.getCenter(centerRef.current);
    boxRef.current.getSize(sizeRef.current);
  }, [obj]);

  return [boxRef.current, centerRef.current, sizeRef.current];
}

3.设置正交相机的拍摄范围

我们先获取正交相机的引用,并结合前面获得的 env 模型以及封装好的 useObjBorder Hook,获取模型的边界尺寸、中心点和包围盒。

然后,基于模型的尺寸来设置正交相机的视锥体尺寸(leftrighttopbottom)。

899901-20160726154509481-162482424.png 将相机的位置设置在模型中心的正上方,Y 轴方向位于模型最高点再向上偏移 50 个单位,以确保整个模型能被完整拍摄下来。

  const camera = useRef<THREE.OrthographicCamera>(null);
  const [envBox, envCenter, envSize] = useObjBorder(env);
  ...
 function updateCamera() {
    if (!camera.current) return;
    const cam = camera.current;
    cam.near = 0.1;
    cam.far = Math.max(envSize.y, envSize.z) + 100;
    cam.left = -envSize.x / 2;
    cam.right = envSize.x / 2;
    cam.bottom = -envSize.z / 2;
    cam.top = envSize.z / 2;
    cam.position.set(envCenter.x, envCenter.y + envBox.max.y + 50, envCenter.z);
    cam.lookAt(envCenter);
    cam.updateProjectionMatrix();
  }
  ...
  
  return ( 
     <>
      <OrthographicCamera
            ref={camera}
            rotation={[-Math.PI / 2, 0, 0]}
            zoom={1}
          />
      ...
     </>
   )

4.渲染模型的纹理

我们监听env模型,当获得env模型后:

  1. 创建帧缓冲对象用于存放纹理
  2. 设置相机调用updateCamera
  3. 克隆env模型,创建一个独立的静态场景并添加克隆模型
  4. 设置小地图的背景色,将静态场景渲染到正交相机

这里创建的存放纹理对象的buffer要设置类型为无符号字节(最常见的纹理类型),方便后续直接转换为静态图片。

为了导出的图片亮度,使用sprite及spriteMaterial来优化图片显示。你也可以不使用,这样的图片会比较暗沉(如图是使用及不使用的效果)。 minimap.png

const buffer = useFBO(size * 2, size * 2, {
    type: THREE.UnsignedByteType, // 明确指定类型,避免兼容和性能问题
});
const hasRenderedEnv = useRef(false);

  useEffect(() => {
    if (env && !hasRenderedEnv.current) { // 只渲染一次
      updateCamera(); // 设置相机
      const clone = env.clone();
      const staticScene = new THREE.Scene();
      staticScene.add(clone);
      staticScene.add(new THREE.AmbientLight(0xffffff, 1));

      const prevClearColor = gl.getClearColor(new THREE.Color());
      const prevClearAlpha = gl.getClearAlpha();

      gl.setRenderTarget(buffer);
      gl.setClearColor(new THREE.Color(waterColor));
      gl.clear(true, true, true);
      gl.render(staticScene, camera.current!);
      gl.setRenderTarget(null);

      gl.setClearColor(prevClearColor, prevClearAlpha);
      hasRenderedEnv.current = true;
      requestAnimationFrame(downloadImg);
    }
  }, [env]);
  ...
  
  return (
      ...
       <sprite scale={[size, size, 1]} visible={false}>
        <spriteMaterial
          map={buffer.texture}
          transparent={false}
          depthTest={false}
          blending={THREE.CustomBlending}
        />
      </sprite>
  )
  

5.纹理图片的下载

这一步主要是将buffer缓存的纹理转换成图片,而实际上,你也可以不转换,就这样把buffer.texture提供出去,供小地图组件使用,这种做法就是不使用静态图片了,直接获得正交相机所渲染的纹理。

  function downloadImg() {
    const dataUrl = texToImg(buffer, gl);
    if (!dataUrl) return;
    console.log("envSize", envSize);
    download(dataUrl, "minimap.png");
  }
// utils.ts
export function texToImg(buffer: WebGLRenderTarget, gl: WebGLRenderer) {
  const width = buffer.width;
  const height = buffer.height;

  // 创建一个读取像素的 buffer
  const pixels = new Uint8Array(width * height * 4);
  gl.readRenderTargetPixels(buffer, 0, 0, width, height, pixels);
  // 创建离屏 canvas
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  const ctx = canvas.getContext("2d");
  if (!ctx) return;

  // 将像素数据写入 ImageData
  const imageData = ctx.createImageData(width, height);
  imageData.data.set(pixels);
  // WebGL 像素是从左下角开始的,需要翻转
  flipImageDataVertically(imageData);
  ctx.putImageData(imageData, 0, 0);

  // 生成 dataURL
  const dataURL = canvas.toDataURL("image/png");
  return dataURL;
}
// 左边翻转
function flipImageDataVertically(imageData: ImageData) {
  const width = imageData.width;
  const height = imageData.height;
  const halfHeight = Math.floor(height / 2);
  for (let y = 0; y < halfHeight; y++) {
    for (let x = 0; x < width; x++) {
      for (let c = 0; c < 4; c++) {
        const i1 = (y * width + x) * 4 + c;
        const i2 = ((height - y - 1) * width + x) * 4 + c;
        const temp = imageData.data[i1];
        imageData.data[i1] = imageData.data[i2];
        imageData.data[i2] = temp;
      }
    }
  }
}

export function download(file: Blob | string, filename: string) {
  const a = document.createElement("a");
  const isBlob = file instanceof Blob;
  const url = isBlob ? window.URL.createObjectURL(file) : file;
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  setTimeout(() => {
    document.body.removeChild(a);
    isBlob && window.URL.revokeObjectURL(url);
  }, 0);
}

全量代码

这个组件是一个工具组件,需要下载图片时把它放入场景中它会自动下载一张图片。获得这张图片后,这个组件就可以从场景中删除了。它并不参与实际游戏逻辑,我们只是通过它获取纹理图片。

// 清澈浅水色(常见于海边、湖泊)
const waterColor = "#4FC3F7";

/**
 * 自动拍摄获取小地图的图片
 * @param size 小地图的尺寸
 */
export function MapTexture({ size = 200 }: { size?: number }) {
  const buffer = useFBO(size * 2, size * 2, {
    type: THREE.UnsignedByteType,
  });
  const [env, setEnv] = useState<THREE.Object3D>();
  const { gl, scene } = useThree();
  const camera = useRef<THREE.OrthographicCamera>(null);
  const [envBox, envCenter, envSize] = useObjBorder(env);
  const hasRenderedEnv = useRef(false);

  // 场景加载后寻找env模型
  useEffect(() => {
    let frameId = 0;
    const tryFind = () => {
      const envObj = scene.getObjectByName("env");
      envObj && setEnv(envObj);
    };
    const check = () => {
      tryFind();
      if (!env) frameId = requestAnimationFrame(check);
    };
    requestAnimationFrame(check);
    return () => cancelAnimationFrame(frameId);
  }, [scene]);

  useEffect(() => {
    if (env && !hasRenderedEnv.current) {
      updateCamera();
      const clone = env.clone();
      const staticScene = new THREE.Scene();
      staticScene.add(clone);
      staticScene.add(new THREE.AmbientLight(0xffffff, 1));

      const prevClearColor = gl.getClearColor(new THREE.Color());
      const prevClearAlpha = gl.getClearAlpha();

      gl.setRenderTarget(buffer);
      gl.setClearColor(new THREE.Color(waterColor));
      gl.clear(true, true, true);
      gl.render(staticScene, camera.current!);
      gl.setRenderTarget(null);

      gl.setClearColor(prevClearColor, prevClearAlpha);
      hasRenderedEnv.current = true;
      requestAnimationFrame(downloadImg);
    }
  }, [env]);
  function updateCamera() {
    if (!camera.current) return;
    const cam = camera.current;
    cam.near = 0.1;
    cam.far = Math.max(envSize.y, envSize.z) + 100;
    cam.left = -envSize.x / 2;
    cam.right = envSize.x / 2;
    cam.bottom = -envSize.z / 2;
    cam.top = envSize.z / 2;
    cam.position.set(envCenter.x, envCenter.y + envBox.max.y + 50, envCenter.z);
    cam.lookAt(envCenter);
    cam.updateProjectionMatrix();
  }
  function downloadImg() {
    const dataUrl = texToImg(buffer, gl);
    if (!dataUrl) return;
    console.log("envSize", envSize);
    download(dataUrl, "minimap.png");
  }
  return (
    <>
      <sprite scale={[size, size, 1]} visible={false}>
        <spriteMaterial
          map={buffer.texture}
          transparent={false}
          depthTest={false}
          blending={THREE.CustomBlending}
        />
      </sprite>
      <OrthographicCamera
        ref={camera}
        rotation={[-Math.PI / 2, 0, 0]}
        zoom={1}
      />
    </>
  );
}

效果

会在场景加载后自动下载

minimap.png

这个掘金的编辑器有点坑,2万字写不下,小地图组件实现我放到下个文章了。