world-camera浏览器里永远只有一块有限的显示区域。“无限”来自稳定的 World、可移动的 Camera,以及两者之间可逆的坐标变换。
学完这课,你应该能解释什么
- World、Canvas Local、浏览器 Client 三套坐标分别解决什么问题;
- 为什么平移和缩放只能修改 Camera,不能批量改写节点;
- 如何从 Camera 状态算出当前可见的 World Bounds;
- 如何把鼠标位置反算成 World 坐标;
- 为什么 DPR 影响清晰度,却不应该改变业务坐标。
1. 先纠正“无限”的错误直觉
一种错误实现是创建一块极大的 Canvas,例如 100000 × 100000,再把所有内容画进去。它很快会遇到像素内存、浏览器尺寸上限、重绘成本和浮点精度问题。
真正的结构是:
固定 World
→ Camera 选择当前观察范围
→ Renderer 投影到有限 Viewport
Canvas DOM 元素仍然只有窗口那么大。用户把画布拖到很远,只是 Camera 的世界中心发生变化,并没有扩张一张位图。
2. 三套坐标不要混在一起
| 坐标 | 原点 | 用途 | 是否写入 Document |
|---|---|---|---|
| World | 人为规定的稳定 World(0,0) |
保存节点真实位置 | 是 |
| Canvas Local | Canvas CSS 区域左上角 | 绘制、局部命中 | 否 |
| Client | 浏览器可视区域左上角 | 接收 PointerEvent | 否 |
本课程约定 X 向右为正、Y 向下为正。World(100,-105) 位于世界原点右上方。
World 原点不是 Canvas 左上角,也不一定在当前屏幕里。它只是所有节点共同使用的稳定参照。
3. Camera 保存状态,不保存节点
最小 Camera:
interface Camera {
centerX: number
centerY: number
zoom: number
}
它表达:Canvas 中心正看着哪个 World 点,以及 1 个世界单位显示成多少 CSS 像素。
Camera(centerX=0, centerY=0, zoom=1)
= Canvas 中心正对 World(0,0)
= 1 world unit 显示为 1 CSS px
Camera 不保存可见宽高,因为它们可以从 Viewport CSS 尺寸和 zoom 推导出来。
4. 手算一次 World → Canvas
固定数据:
Viewport CSS:800 × 600
Viewport 中心:(400, 300)
Camera 中心:World(100, 50)
zoom:2
节点 P:World(160, 90)
投影公式:
canvasX = (worldX - camera.centerX) * camera.zoom + viewport.width / 2
canvasY = (worldY - camera.centerY) * camera.zoom + viewport.height / 2
代入 P:
canvasX = (160 - 100) × 2 + 400 = 520
canvasY = ( 90 - 50) × 2 + 300 = 380
所以 World(160,90) 显示在 Canvas Local(520,380)。
公式可以记成三步:先减 Camera 中心,得到相对距离;再乘 zoom,换成像素距离;最后加 Viewport 中心,把相对坐标放回 Canvas。
5. 鼠标如何反算回 World
PointerEvent 给的是 Client 坐标。先减去 Canvas 的 DOM Rect:
const canvasX = event.clientX - rect.left
const canvasY = event.clientY - rect.top
再做 Camera 逆变换:
worldX = (canvasX - viewport.width / 2) / camera.zoom + camera.centerX
worldY = (canvasY - viewport.height / 2) / camera.zoom + camera.centerY
把刚才的 Canvas(520,380) 代回去:
worldX = (520 - 400) / 2 + 100 = 160
worldY = (380 - 300) / 2 + 50 = 90
正向与逆向得到同一点,这是坐标实现最重要的单元测试。
6. Camera 如何推导可见世界范围
halfWorldWidth = viewport.width / camera.zoom / 2
halfWorldHeight = viewport.height / camera.zoom / 2
当前例子:
halfWorldWidth = 800 / 2 / 2 = 200
halfWorldHeight = 600 / 2 / 2 = 150
Visible Bounds = [100-200, 50-150, 100+200, 50+150]
= [-100, -100, 300, 200]
zoom 从 2 降到 0.5 时,屏幕能看到的 World 范围扩大 4 倍。这个 Bounds 后面会直接交给空间索引,而不是拿 Canvas 的 0~800 去查询。
7. 平移和缩放为什么不改 Document
| 操作 | Camera | 节点 World 坐标 | Document revision |
|---|---|---|---|
| 平移视图 | 改 | 不变 | 通常不变 |
| 缩放视图 | 改 | 不变 | 通常不变 |
| 拖动节点 | 不一定改 | 改 | 改 |
如果平移一次就修改 6,000 个节点:
- 每个 pointermove 都变成 O(N) 写入;
- History 会混入大量并不存在的作品修改;
- 协作端收到的是 6,000 个节点移动,而不是一个本地视图变化;
- 节点原本的稳定 World 位置被破坏。
正确做法是对 Canvas2D 使用统一坐标函数,或对 Konva 根 World Group 一次性设置 position 和 scale。
8. DPR 只管清晰度
高分屏可能是:
CSS size:800 × 600
DPR:2
Backing store:1600 × 1200
Camera、Pointer 和 World Bounds 使用 800 × 600 的 CSS 尺寸;Backing Store 只用于提升像素密度。若把 1600 当成业务视口宽度,坐标会被重复放大。
9. 操作 Demo
- 记录一个节点显示的 World 坐标;
- 平移 Camera,观察节点的 Canvas Local 坐标变化而 World 坐标不变;
- 将 zoom 从 1 调到 2,观察可见 World 宽高减半;
- 点击 Canvas,检查 Canvas → World → Canvas 往返误差;
- 回到原点,确认 Camera 变化没有产生 Document revision。
讲课时可以先问:“拖动画布时,到底是谁移动了?”再用 Demo 证明答案是 Camera。
10. 常见错误与验收
常见错误
- 把
event.screenX当成 Canvas 坐标; - 用 Backing Store 尺寸计算 Camera;
- 平移时逐节点修改 World 坐标;
- zoom 围绕错误的锚点,导致鼠标指向的内容跳动;
- 保存临时 Canvas Local 坐标,刷新后位置错乱。
最小验收
worldToCanvas(canvasToWorld(point)) ≈ point
Camera 平移后 Document 节点数据不变
zoom 放大 2 倍后可见 World 宽高减半
CSS size 与 DPR 改变不会破坏业务坐标
课后实验
在 createViewState 中分别设置 0 和 240px overscan。注意 overscan 是屏幕像素,查询前要除以 zoom 转成 World 单位。记录 zoom 为 0.5、1、2 时实际扩张的世界范围。