撤销与场景图
GPU 没有 undo()。历史记录的是 CPU 文档操作(加节点、改 TRS、改可见性)。readPixels / toDataURL 整张压栈:内存按步数 × drawingBuffer × DPR 爆炸,还和相机缠死;上下文一丢,像素快照也救不回对象 id。buffer 是投影,丢失后从文档重建。
1. 场景怎么切
文档(CPU)← Command.apply/revert
↓ dirty 标记
GPU 资源表:按 nodeId 上传 / 废掉
export interface Command {
readonly label: string;
apply(): void;
revert(): void;
}
export class History {
private undoStack: Command[] = [];
private redoStack: Command[] = [];
private coalescing: Command | undefined;
execute(cmd: Command): void {
cmd.apply();
this.undoStack.push(cmd);
this.redoStack.length = 0;
}
coalesce(cmd: Command): void {
this.coalescing?.revert();
cmd.apply();
this.coalescing = cmd;
}
commitCoalesce(): void {
if (!this.coalescing) return;
this.undoStack.push(this.coalescing);
this.coalescing = undefined;
this.redoStack.length = 0;
}
undo(): void {
const cmd = this.undoStack.pop();
if (!cmd) return;
cmd.revert();
this.redoStack.push(cmd);
}
redo(): void {
const cmd = this.redoStack.pop();
if (!cmd) return;
cmd.apply();
this.undoStack.push(cmd);
}
}
export type NodeDoc = { id: number; x: number; y: number };
export class MoveNodeCommand implements Command {
readonly label = "move";
constructor(
private readonly nodes: Map<number, NodeDoc>,
private readonly id: number,
private readonly from: { x: number; y: number },
private readonly to: { x: number; y: number },
) {}
apply(): void {
const n = this.nodes.get(this.id);
if (!n) return;
n.x = this.to.x;
n.y = this.to.y;
}
revert(): void {
const n = this.nodes.get(this.id);
if (!n) return;
n.x = this.from.x;
n.y = this.from.y;
}
}
拖节点:pointerdown 记下世界坐标,move 用 coalesce,up 时 commitCoalesce()。撤销一步回到拖之前。命令认 nodeId,不认 WebGLBuffer 句柄。对照 Canvas 命令栈 与 SVG 文档模型。
适用:编辑器、查看器里的显隐/变换、散点标注。
不适用:把后处理 FBO、粒子 SoA、drawing buffer 当可撤销文档。
2. 哪层必须 CPU,哪层进 GPU
| 层 | 归属 |
|---|---|
| 节点树、字段、History | CPU,source of truth |
| instance / VBO / 纹理 | GPU 投影;apply 后标 dirty 再上传 |
| 像素橡皮类工具 | 若必须位图,只对 该层 CPU ImageBitmap 做 patch,仍不是 readPixels 屏幕 |
webglcontextlost 后所有 GL 对象作废。恢复时按文档重建资源表,undo 栈仍然有效。若历史里存了旧 WebGLTexture,恢复后句柄是僵尸,见 丢失恢复。
禁止:热路径全屏读回当快照;preserveDrawingBuffer: true 常开只为 undo。跨源 taint 的表面本来就不能 readPixels / toBlob。协作下撤销是再发逆操作,不是弹本地栈。
相机矩阵不属于文档也可以:视口 undo 另开「视图历史」,不要和物体 TRS 混一个栈。世界单位仍是节点字段;GPU 只吃当前帧的投影。
3. 失败形态
| 症状 | 原因 |
|---|---|
| 撤销一次画面空 | 快照的是 clear 后的默认缓冲 |
| 拖一下 undo 50 次 | 没 coalesce |
| 丢失后 undo 花屏 | 命令持有 GL 句柄 |
| 内存涨到几百 MB | drawingBuffer × 步数 × DPR |
| redo 丢了 | execute 时没清 redo |
权威资料
核对日期:2026-08-26