跳到主要内容

小地图

小地图是 第二个相机,不是把主画布 drawImage 缩小贴角落。主视图 2x DPR 全屏再缩一次,等于每帧多付一次全场景光栅,还发糊。

CameracameraToCtmviewToWorldworldToView相机

1. 两个相机

export interface MiniMapConfig {
/** 小地图 CSS 尺寸 */
cssWidth: number;
cssHeight: number;
/** 世界范围(整局地图 / 文档包围盒) */
world: { minX: number; minY: number; maxX: number; maxY: number };
}

export function miniMapCamera(config: MiniMapConfig): Camera {
const w = config.world.maxX - config.world.minX;
const h = config.world.maxY - config.world.minY;
const scale = Math.min(config.cssWidth / w, config.cssHeight / h);
const viewW = w * scale;
const viewH = h * scale;
const padX = (config.cssWidth - viewW) / 2 / scale;
const padY = (config.cssHeight - viewH) / 2 / scale;
return {
scale,
panX: config.world.minX - padX,
panY: config.world.minY - padY,
};
}

主相机随便 pan/zoom;小地图相机只在 世界包围盒变了 时重算(地图扩边、画布增长)。每帧只更新视口框。

2. 底图怎么来

场景底图
地图/游戏单独烘一张低清 ImageBitmap(世界全貌),尺寸约小地图 CSS × dpr,不要用主场景节点重画
白板/设计稿节流(300ms)把静态层缩绘到 mini 离屏;拖节点期间只动视口框
阅读器用已有最低 z 瓦片拼,缺块画灰
export async function bakeOverview(
paintWorld: (ctx: OffscreenCanvasRenderingContext2D) => void,
world: MiniMapConfig["world"],
cssW: number,
cssH: number,
dpr: number,
): Promise<ImageBitmap> {
const cam = miniMapCamera({ cssWidth: cssW, cssHeight: cssH, world });
const canvas = new OffscreenCanvas(
Math.max(1, Math.round(cssW * dpr)),
Math.max(1, Math.round(cssH * dpr)),
);
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("2d unavailable");
ctx.setTransform(cameraToCtm(cam, dpr));
paintWorld(ctx);
return canvas.transferToImageBitmap();
}

paintWorld 必须是 简化绘制:隐藏手柄、文字改成色块、粒子去掉。全细节烘进 200×150 是浪费。

3. 视口框与点击跳转

主相机视口在世界里是 AABB,投到小地图 view:

export function viewportRectOnMini(
main: Camera,
mini: Camera,
cssW: number,
cssH: number,
): { x: number; y: number; w: number; h: number } {
const a = viewToWorld(main, 0, 0);
const b = viewToWorld(main, cssW, cssH);
const p1 = worldToView(mini, a.x, a.y);
const p2 = worldToView(mini, b.x, b.y);
return {
x: Math.min(p1.x, p2.x),
y: Math.min(p1.y, p2.y),
w: Math.abs(p2.x - p1.x),
h: Math.abs(p2.y - p1.y),
};
}

export function panMainToMiniClick(
mini: Camera,
main: Camera,
viewX: number,
viewY: number,
viewW: number,
viewH: number,
): Camera {
const world = viewToWorld(mini, viewX, viewY);
return {
...main,
panX: world.x - viewW / (2 * main.scale),
panY: world.y - viewH / (2 * main.scale),
};
}

点击:小地图坐标 → 世界点 → 主相机把该点放到视口中心。拖视口框:跟 指针状态机 同一套,delta 用 mini 的 viewToWorld 差。

框可能比小地图还大(主视图缩得很小):夹紧绘制,用不同描边表示「视口比世界还大」。不要把框 clip 没让用户点不到。

4. DOM 还是 canvas

小地图可以是第二张 &lt;canvas>,也可以是一张 <img> + SVG 框。交互热区小、要读屏时,用 button + 可视框的 DOM,canvas 只画底。见 无障碍

touch-action: none 只打在小地图节点上,别打到整页。

5. 失败形态

症状原因
开小地图掉一半帧每帧把主 scene 按 mini 相机重画
框和主视图对不上两套 world 原点,或一边忘了 letterbox padding
点击跳转偏一截mini 有 letterbox,用了 css 坐标当世界
概览越来越糊又越来越卡烘的是主视图 backing 缩略,还每帧烘
小地图抢滚动没单独 touch-action

权威资料

核对日期:2026-08-26