World 与 Camera 坐标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

  1. 记录一个节点显示的 World 坐标;
  2. 平移 Camera,观察节点的 Canvas Local 坐标变化而 World 坐标不变;
  3. 将 zoom 从 1 调到 2,观察可见 World 宽高减半;
  4. 点击 Canvas,检查 Canvas → World → Canvas 往返误差;
  5. 回到原点,确认 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 时实际扩张的世界范围。