小地图
小地图是 第二个相机,不是把主画布 drawImage 缩小贴角落。主视图 2x DPR 全屏再缩一次,等于每帧多付一次全场景光栅,还发糊。
Camera、cameraToCtm、viewToWorld、worldToView 见 相机。
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
小地图可以是第二张 <canvas>,也可以是一张 <img> + SVG 框。交互热区小、要读屏时,用 button + 可视框的 DOM,canvas 只画底。见 无障碍。
touch-action: none 只打在小地图节点上,别打到整页。
5. 失败形态
| 症状 | 原因 |
|---|---|
| 开小地图掉一半帧 | 每帧把主 scene 按 mini 相机重画 |
| 框和主视图对不上 | 两套 world 原点,或一边忘了 letterbox padding |
| 点击跳转偏一截 | mini 有 letterbox,用了 css 坐标当世界 |
| 概览越来越糊又越来越卡 | 烘的是主视图 backing 缩略,还每帧烘 |
| 小地图抢滚动 | 没单独 touch-action |
权威资料
核对日期:2026-08-26