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

550 阅读4分钟

功能概述

Minimap 是一个基于 react-three-fiber@react-three/drei 的通用小地图组件。它在 HUD 层(独立于主场景)渲染一个固定尺寸的正交投影视图,并实时显示玩家在大世界中的位置与朝向。主要功能包括:

  • 自定义小地图大小与屏幕位置
  • 加载并渲染底图与玩家图标
  • 每帧更新玩家在小地图上的坐标和朝向
  • 与主摄像机渲染互不干扰,保证小地图始终叠加在最上层

组件参数

interface MinimapProps {
  /** 小地图的边长(像素) */
  size?: number;
  /** 在 HUD 层上的位置 */
  position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
  /** 小地图底图的 URL */
  mapUrl?: string;
  /** 玩家图标的 URL */
  cursorUrl?: string;
}
 */

使用示例

import { Canvas } from "@react-three/fiber";
import { Minimap } from "./components/Minimap";

function App() {
  return (
    <Canvas>
      {/* 主场景摄像机、光源、环境等 */}
      <ambientLight intensity={0.5} />
      <mesh name="env">{/* ... */}</mesh>
      <mesh name="player">{/* ... */}</mesh>

      {/* 小地图 */}
      <Minimap
        size={150}
        position="bottom-left"
        mapUrl="/assets/ui/minimap-bg.png"
        cursorUrl="/assets/ui/player-icon.png"
      />
    </Canvas>
  );
}

实现细节

1. 获取场景对象

和设置env模型一样,我们也为玩家所在的模型上添加了name="player"的属性,然后通过getObjectByName获得模型。

  • useEffect 中不断尝试从主场景 scene 中获取名称为 "env""player" 的对象
  • 成功获取后存入组件状态,停止轮询
useEffect(() => {
  let frameId: number;
  const tryFind = () => {
    const envObj = scene.getObjectByName("env");
    const playerObj = scene.getObjectByName("player");
    if (envObj) setEnv(envObj);
    if (playerObj) setPlayer(playerObj);
  };
  const check = () => {
    tryFind();
    if (!env || !player) frameId = requestAnimationFrame(check);
  };
  check();
  return () => cancelAnimationFrame(frameId);
}, [scene, env, player]);

2. 计算屏幕坐标

  • 根据 screenSizesizeposition 计算出小地图中心在屏幕空间上的 位置Vector3
  • 支持四角定位,预留 margin 可进一步扩展
  • 你可以把margin也放入组件可接受参数中,这里默认是0
const screenPosition = useMemo(() => {
const margin = 0
  const halfW = screenSize.width / 2;
  const halfH = screenSize.height / 2;
  const offset = size / 2;
  let x = -halfW + offset + margin;
  let y = halfH - offset - margin;
  if (position.includes("right"))  x =  halfW - offset - margin;
  if (position.includes("bottom")) y = -halfH + offset + margin;
  return new THREE.Vector3(x, y, 0);
}, [screenSize, size, position]);

3. 每帧渲染与更新

useFrame 中分三步操作:

  1. 绘制主视图
    • gl.autoClear = true
    • gl.render(scene, camera)
  2. 小地图图层准备
    • 获取主摄像机逆矩阵,让 bgSpriteplayerSprite 朝向摄像机
    • 如果已有 envplayer
      • 将玩家世界坐标减去环境中心,归一化后映射到小地图坐标系
      • 根据玩家朝向向量更新图标旋转
  3. 叠加渲染
    • gl.autoClear = false 并清除深度缓存,保证小地图在最上层正确渲染
useFrame(() => {
  // 1. 渲染主场景
  gl.autoClear = true;
  gl.render(scene, camera);

  // 2. 小地图朝向调整
  const inv = camera.matrix.clone().invert();
  bgSprite.current?.quaternion.setFromRotationMatrix(inv);
  playerSprite.current?.quaternion.setFromRotationMatrix(inv);

  // 3. 计算并更新玩家图标
  if (player && env) {
    const worldPos = player.getWorldPosition(tempVec);
    const rel = worldPos.sub(envCenter);
    const x = screenPosition.x + (rel.x / envSize.x) * size;
    const y = screenPosition.y - (rel.z / envSize.z) * size;
    playerSprite.current!.position.set(x, y, 0);
    player.getWorldDirection(tempDir);
    playerSprite.current!.material.rotation =
      -Math.atan2(tempDir.z, tempDir.x) + Math.PI / 2;
  }

  // 4. 准备下一层渲染
  gl.autoClear = false;
  gl.clearDepth();
});

这里如果不想将玩家的朝向作为图标的朝向,而希望将当前相机的朝向作为图标朝向可以替换代码player.getWorldDirection(tempDir)camera.getWorldDirection(tempDir),你也可以添加一个参数用于决定使用哪个旋转比如:

function Minimap({
  rotateCam = false,
  ...
  }){
  ...
(rotateCam?camera:player).getWorldDirection(tempDir)
...
}

4. HUD 与正交相机

注意 HudrenderPriority不要设置为1,它会导致主场景被清空。Hud中的其他元素会被默认渲染到被它包裹的相机上(必须有一个相机用于渲染)。

return (
  <Hud renderPriority={10}>
    {/* 使用正交相机渲染 UI 层 */}
    <OrthographicCamera
      makeDefault
      position={[0, 0, 10]}
      near={0.1}
      far={100}
    />
    {/* 小地图底图与玩家图标 */}
    <sprite ref={bgSprite} position={screenPosition} scale={[size, size, 1]}>
      <spriteMaterial
        map={map}
        transparent={false}
        depthTest={false}
        blending={THREE.CustomBlending}
      />
    </sprite>
    <sprite
      ref={playerSprite}
      position={screenPosition}
      scale={[size / 15, size / 15, 1]}
    >
      <spriteMaterial map={cursor} transparent depthTest={false} />
    </sprite>
  </Hud>
);
  • Hud:来自 @react-three/drei,用于将子元素渲染到独立的 UI 层(overlay HUD),不受主场景干扰。非常适合渲染小地图、血条、UI 图标等。
    • renderPriority 控制渲染顺序,数值越大越晚渲染,确保叠加在主场景之上。
  • OrthographicCamera:创建一个正交投影相机(无透视缩放),适合 UI 层渲染,避免因距离远近产生缩放效果。
    • makeDefault 设置为当前默认相机,影响 HUD 区域内的渲染。

这段设置常用于构建静态或 2D UI(如小地图、固定按钮等),与主场景逻辑分离、互不影响。需要注意的是 Hud 内部内容只会响应其内部相机与渲染上下文。

全量代码

const tempVec = new THREE.Vector3();
const tempDir = new THREE.Vector3();

export function Minimap({
  size = 200,
  position = "top-right",
  mapUrl = "./textures/map.png",
  cursorUrl = "./textures/cursor.svg",
}: MinimapProps) {
  const [map, cursor] = useTexture([mapUrl, cursorUrl], (textures) => {
    textures[0].minFilter = THREE.LinearFilter;
    textures[0].generateMipmaps = false;
  });

  const { gl, camera, scene, size: screenSize } = useThree();
  const bgSprite = useRef<THREE.Sprite>(null);
  const playerSprite = useRef<THREE.Sprite>(null);

  const [env, setEnv] = useState<THREE.Object3D | null>(null);
  const [player, setPlayer] = useState<THREE.Object3D | null>(null);
  const [, envCenter, envSize] = useObjBorder(env);

  const screenPosition = useMemo(() => {
    const margin = 0;
    const halfW = screenSize.width / 2;
    const halfH = screenSize.height / 2;
    const offset = size / 2;
    let x = -halfW + offset + margin;
    let y = halfH - offset - margin;
    if (position.includes("right")) x = halfW - offset - margin;
    if (position.includes("bottom")) y = -halfH + offset + margin;
    return new THREE.Vector3(x, y, 0);
  }, [screenSize, size, position]);

  useEffect(() => {
    let frameId: number;
    const tryFind = () => {
      const envObj = scene.getObjectByName("env");
      const playerObj = scene.getObjectByName("player");
       envObj &&setEnv(envObj);
       playerObj && setPlayer(playerObj);
    };
    const check = () => {
      tryFind();
      if (!env || !player) frameId = requestAnimationFrame(check);
    };
    check();
    return () => cancelAnimationFrame(frameId);
  }, [scene, env, player]);

  useFrame(() => {
    gl.autoClear = true;
    gl.render(scene, camera);

    const inv = camera.matrix.clone().invert();
    bgSprite.current?.quaternion.setFromRotationMatrix(inv);
    playerSprite.current?.quaternion.setFromRotationMatrix(inv);

    if (player && env) {
      const worldPos = player.getWorldPosition(tempVec);
      const relative = worldPos.sub(envCenter);
      const xPos = screenPosition.x + (relative.x / envSize.x) * size;
      const yPos = screenPosition.y - (relative.z / envSize.z) * size;
      if (playerSprite.current) {
        playerSprite.current.position.set(xPos, yPos, 0);
        player.getWorldDirection(tempDir);
        playerSprite.current.material.rotation =
          -Math.atan2(tempDir.z, tempDir.x) + Math.PI / 2;
      }
    }

    gl.autoClear = false;
    gl.clearDepth();
  });

  return (
    <Hud renderPriority={10}>
      <OrthographicCamera
        position={[0, 0, 10]}
        near={0.1}
        far={100}
        makeDefault
      />
      <sprite ref={bgSprite} position={screenPosition} scale={[size, size, 1]}>
        <spriteMaterial
          map={map}
          transparent={false}
          depthTest={false}
          blending={THREE.CustomBlending}
        />
      </sprite>
      <sprite
        ref={playerSprite}
        position={screenPosition}
        scale={[size / 15, size / 15, 1]}
      >
        <spriteMaterial map={cursor} transparent depthTest={false} />
      </sprite>
    </Hud>
  );
}

效果

compress.gif