功能概述
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. 计算屏幕坐标
- 根据
screenSize与size、position计算出小地图中心在屏幕空间上的 位置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 中分三步操作:
- 绘制主视图
gl.autoClear = truegl.render(scene, camera)
- 小地图图层准备
- 获取主摄像机逆矩阵,让
bgSprite与playerSprite朝向摄像机 - 如果已有
env与player:- 将玩家世界坐标减去环境中心,归一化后映射到小地图坐标系
- 根据玩家朝向向量更新图标旋转
- 获取主摄像机逆矩阵,让
- 叠加渲染
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 与正交相机
注意 Hud 的renderPriority不要设置为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>
);
}