Skip to content

变换堆栈与状态保存

想象你正在绘制一个复杂的场景:一个机器人,由躯干、手臂、手掌、手指组成。手臂连接在躯干上,手掌连接在手臂上,手指连接在手掌上。当躯干旋转时,所有部件都要跟随旋转;当手臂旋转时,只有手掌和手指跟随。这种层级变换该如何实现?答案就在**变换堆栈(Transform Stack)**中。

变换与状态管理的关系

首先要问一个问题:当你调用 ctx.save() 时,保存的是什么?

答案是整个上下文状态,包括:

  • 变换矩阵(transform matrix)
  • 样式属性(fillStyle, strokeStyle, lineWidth等)
  • 阴影属性(shadowColor, shadowBlur等)
  • 合成属性(globalAlpha, globalCompositeOperation)
  • 剪切区域(clipping region)

其中,变换矩阵是最核心的一部分

javascript
ctx.fillStyle = 'red';
ctx.translate(100, 50);

ctx.save();  // 保存当前状态(红色填充 + 平移(100, 50))

ctx.fillStyle = 'blue';
ctx.rotate(Math.PI / 4);

ctx.restore();  // 恢复到红色填充 + 平移(100, 50),旋转被丢弃

save/restore 让你可以临时修改状态,然后恢复,避免状态污染。

变换堆栈的工作原理

现在我要问第二个问题:save()restore() 如何管理多层嵌套?

答案是**栈(Stack)**数据结构。每次 save() 将当前状态压入栈顶,restore() 从栈顶弹出状态:

javascript
ctx.fillStyle = 'red';        // 状态1

ctx.save();                   // 栈:[状态1]
ctx.fillStyle = 'blue';       // 状态2

ctx.save();                   // 栈:[状态1, 状态2]
ctx.fillStyle = 'green';      // 状态3

ctx.restore();                // 栈:[状态1],恢复到状态2(蓝色)

ctx.restore();                // 栈:[],恢复到状态1(红色)

关键规则:

  • 必须配对:每个 save() 对应一个 restore()
  • 后进先出:最后保存的最先恢复(LIFO)
  • 隔离变换:在 save/restore 之间的变换不影响外部

可视化堆栈状态

让我们用代码验证堆栈行为:

javascript
console.log('初始变换:', ctx.getTransform());

ctx.translate(50, 0);
console.log('平移后:', ctx.getTransform());

ctx.save();
ctx.translate(30, 0);
console.log('再次平移:', ctx.getTransform());  // 累积:平移(80, 0)

ctx.restore();
console.log('恢复后:', ctx.getTransform());   // 回到平移(50, 0)

输出会显示变换矩阵的变化过程,验证堆栈机制。

层级变换:父子关系

现在我要问第三个问题:如何让手臂跟随躯干一起旋转,但又能独立旋转?

答案是嵌套的 save/restore,创建层级关系:

javascript
function drawRobot() {
  ctx.save();  // 保存世界坐标系
  
  // 躯干
  ctx.translate(200, 200);  // 躯干位置
  ctx.rotate(bodyAngle);    // 躯干旋转
  
  ctx.fillStyle = '#444';
  ctx.fillRect(-25, -50, 50, 100);  // 绘制躯干
  
  // 左手臂(相对于躯干)
  ctx.save();  // 保存躯干坐标系
  ctx.translate(-25, -30);  // 手臂连接点
  ctx.rotate(leftArmAngle); // 手臂独立旋转
  
  ctx.fillStyle = '#666';
  ctx.fillRect(0, 0, 10, 60);  // 绘制手臂
  
  // 左手掌(相对于手臂)
  ctx.save();  // 保存手臂坐标系
  ctx.translate(5, 60);     // 手掌连接点
  ctx.rotate(leftHandAngle);  // 手掌独立旋转
  
  ctx.fillStyle = '#888';
  ctx.fillRect(-8, 0, 16, 20);  // 绘制手掌
  
  ctx.restore();  // 恢复到手臂坐标系
  ctx.restore();  // 恢复到躯干坐标系
  
  // 右手臂(同样的层级结构)
  ctx.save();
  ctx.translate(25, -30);
  ctx.rotate(rightArmAngle);
  ctx.fillStyle = '#666';
  ctx.fillRect(0, 0, 10, 60);
  ctx.restore();
  
  ctx.restore();  // 恢复到世界坐标系
}

let bodyAngle = 0;
let leftArmAngle = 0;

function animate() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  bodyAngle += 0.02;
  leftArmAngle = Math.sin(Date.now() * 0.005) * 0.5;
  
  drawRobot();
  requestAnimationFrame(animate);
}

animate();

关键理解:

  • 躯干旋转:所有部件跟随旋转(因为都在躯干坐标系下绘制)
  • 手臂旋转:只有手掌跟随(因为手掌在手臂坐标系下)
  • 独立控制:每个部件有自己的旋转参数

局部坐标系与全局坐标系

现在我要问第四个问题:如何理解"相对于父对象"的坐标?

每个 save() 之后的变换定义了一个新的局部坐标系(Local Coordinate System)

javascript
// 全局坐标系(画布左上角为原点)
ctx.save();

ctx.translate(100, 100);  // 定义局部坐标系A,原点在(100, 100)

ctx.save();
ctx.translate(50, 0);     // 定义局部坐标系B,原点相对A偏移(50, 0)
                           // 在全局坐标系中,原点在(150, 100)

ctx.fillRect(0, 0, 20, 20);  // 在局部坐标系B的(0, 0)绘制
                             // 实际绘制在全局坐标系的(150, 100)

ctx.restore();  // 回到坐标系A
ctx.restore();  // 回到全局坐标系

这种层级坐标系统是**场景图(Scene Graph)**的基础,Fabric.js、Three.js等图形库都基于这个概念。

时钟绘制:经典层级示例

让我们用层级变换实现一个时钟:

javascript
function drawClock() {
  const now = new Date();
  const hours = now.getHours() % 12;
  const minutes = now.getMinutes();
  const seconds = now.getSeconds();
  
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  ctx.save();
  ctx.translate(200, 200);  // 时钟中心
  
  // 绘制表盘
  ctx.strokeStyle = '#333';
  ctx.lineWidth = 8;
  ctx.beginPath();
  ctx.arc(0, 0, 150, 0, Math.PI * 2);
  ctx.stroke();
  
  // 绘制刻度
  for (let i = 0; i < 12; i++) {
    ctx.save();
    ctx.rotate(i * Math.PI / 6);
    ctx.fillStyle = '#333';
    ctx.fillRect(-3, -145, 6, 20);
    ctx.restore();
  }
  
  // 时针(相对于表盘中心)
  ctx.save();
  ctx.rotate((hours + minutes / 60) * Math.PI / 6 - Math.PI / 2);
  ctx.strokeStyle = '#333';
  ctx.lineWidth = 8;
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(60, 0);
  ctx.stroke();
  ctx.restore();
  
  // 分针
  ctx.save();
  ctx.rotate((minutes + seconds / 60) * Math.PI / 30 - Math.PI / 2);
  ctx.strokeStyle = '#666';
  ctx.lineWidth = 5;
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(90, 0);
  ctx.stroke();
  ctx.restore();
  
  // 秒针
  ctx.save();
  ctx.rotate(seconds * Math.PI / 30 - Math.PI / 2);
  ctx.strokeStyle = '#E53E3E';
  ctx.lineWidth = 2;
  ctx.beginPath();
  ctx.moveTo(0, 0);
  ctx.lineTo(110, 0);
  ctx.stroke();
  ctx.restore();
  
  // 中心点
  ctx.fillStyle = '#333';
  ctx.beginPath();
  ctx.arc(0, 0, 8, 0, Math.PI * 2);
  ctx.fill();
  
  ctx.restore();
}

setInterval(drawClock, 1000);
drawClock();

每个指针都有独立的旋转角度,但都相对于表盘中心绘制。这就是层级变换的威力。

变换管理最佳实践

现在我要问第五个问题:如何避免 save/restore 配对错误?

模式1:函数自管理

javascript
function drawShape(x, y, angle) {
  ctx.save();
  
  ctx.translate(x, y);
  ctx.rotate(angle);
  ctx.fillRect(-20, -20, 40, 40);
  
  ctx.restore();
}

// 使用时无需关心状态
drawShape(100, 100, 0.5);
drawShape(200, 150, 1.2);

每个绘制函数内部管理自己的变换状态,外部调用无需关心。

模式2:with语句封装(不推荐,仅示意)

javascript
function withTransform(fn, matrix) {
  ctx.save();
  ctx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f);
  fn();
  ctx.restore();
}

// 使用
withTransform(() => {
  ctx.fillRect(0, 0, 50, 50);
}, { a: 1, b: 0, c: 0, d: 1, e: 100, f: 50 });

模式3:变换栈深度检查

javascript
class ContextWrapper {
  constructor(ctx) {
    this.ctx = ctx;
    this.depth = 0;
  }
  
  save() {
    this.ctx.save();
    this.depth++;
  }
  
  restore() {
    if (this.depth === 0) {
      console.warn('restore() called without matching save()');
      return;
    }
    this.ctx.restore();
    this.depth--;
  }
  
  checkBalance() {
    if (this.depth !== 0) {
      console.error(`Unbalanced save/restore: depth=${this.depth}`);
    }
  }
}

const wrapper = new ContextWrapper(ctx);
wrapper.save();
wrapper.restore();
wrapper.checkBalance();  // 确保配对

本章小结

变换堆栈是管理复杂变换的关键机制:

  • 状态保存save() 保存整个上下文状态,包括变换矩阵
  • 状态恢复restore() 恢复上一次保存的状态
  • 栈结构:后进先出(LIFO),必须配对使用
  • 层级变换:嵌套 save/restore 创建父子关系
  • 局部坐标系:每个 save 后的变换定义新的坐标系

关键模式:

  • 函数内部管理变换状态
  • 使用变换前 save,完成后 restore
  • 复杂层级用嵌套 save/restore
  • 开发时检查配对平衡

常见应用:

  • 机器人、时钟等层级对象
  • 粒子系统(每个粒子独立变换)
  • 图形编辑器(对象独立操作)

深入理解:变换堆栈的底层实现

Canvas 的状态结构

save() 实际保存的不仅是变换矩阵,而是整个渲染状态。让我们探究浏览器的实现逻辑。

Canvas 状态包含的完整内容

javascript
/**
 * Canvas 状态的完整结构(简化版)
 */
class CanvasState {
  constructor() {
    // 变换矩阵(6个值:a, b, c, d, e, f)
    this.transform = {
      a: 1, b: 0, c: 0,
      d: 1, e: 0, f: 0
    };
    
    // 样式属性
    this.fillStyle = '#000000';
    this.strokeStyle = '#000000';
    this.lineWidth = 1;
    this.lineCap = 'butt';
    this.lineJoin = 'miter';
    this.miterLimit = 10;
    this.lineDashOffset = 0;
    this.lineDash = [];
    
    // 阴影
    this.shadowBlur = 0;
    this.shadowColor = 'rgba(0,0,0,0)';
    this.shadowOffsetX = 0;
    this.shadowOffsetY = 0;
    
    // 合成
    this.globalAlpha = 1.0;
    this.globalCompositeOperation = 'source-over';
    
    // 文本
    this.font = '10px sans-serif';
    this.textAlign = 'start';
    this.textBaseline = 'alphabetic';
    
    // 裁剪路径(复杂对象)
    this.clipPath = null;
    
    // 图像平滑
    this.imageSmoothingEnabled = true;
    this.imageSmoothingQuality = 'low';
  }
  
  /**
   * 深拷贝(save 时调用)
   */
  clone() {
    const newState = new CanvasState();
    Object.assign(newState, this);
    
    // 深拷贝复杂对象
    newState.transform = { ...this.transform };
    newState.lineDash = [...this.lineDash];
    
    return newState;
  }
}

状态栈的实现

javascript
/**
 * 模拟 Canvas 的状态栈
 */
class CanvasStateStack {
  constructor() {
    this.currentState = new CanvasState();
    this.stack = [];
  }
  
  save() {
    // 将当前状态压入栈
    this.stack.push(this.currentState.clone());
    console.log(`[save] 栈深度: ${this.stack.length}`);
  }
  
  restore() {
    if (this.stack.length === 0) {
      console.warn('[restore] 栈为空,无法恢复');
      return;
    }
    
    // 从栈顶弹出状态
    this.currentState = this.stack.pop();
    console.log(`[restore] 栈深度: ${this.stack.length}`);
  }
  
  getTransform() {
    return { ...this.currentState.transform };
  }
  
  setTransform(a, b, c, d, e, f) {
    this.currentState.transform = { a, b, c, d, e, f };
  }
  
  translate(x, y) {
    const m = this.currentState.transform;
    // 矩阵乘法:[1, 0, 0, 1, x, y] × 当前矩阵
    m.e += m.a * x + m.c * y;
    m.f += m.b * x + m.d * y;
  }
  
  rotate(angle) {
    const cos = Math.cos(angle);
    const sin = Math.sin(angle);
    const m = this.currentState.transform;
    
    const a = m.a, b = m.b, c = m.c, d = m.d;
    
    m.a = a * cos + c * sin;
    m.b = b * cos + d * sin;
    m.c = c * cos - a * sin;
    m.d = d * cos - b * sin;
  }
  
  scale(x, y) {
    const m = this.currentState.transform;
    m.a *= x;
    m.b *= x;
    m.c *= y;
    m.d *= y;
  }
}

// 使用示例
const stateStack = new CanvasStateStack();

stateStack.save();
stateStack.translate(100, 50);
console.log('平移后:', stateStack.getTransform());
// 输出: { a: 1, b: 0, c: 0, d: 1, e: 100, f: 50 }

stateStack.save();
stateStack.rotate(Math.PI / 4);
console.log('旋转后:', stateStack.getTransform());
// 输出: { a: 0.707, b: 0.707, c: -0.707, d: 0.707, e: 100, f: 50 }

stateStack.restore();
console.log('恢复后:', stateStack.getTransform());
// 输出: { a: 1, b: 0, c: 0, d: 1, e: 100, f: 50 }

stateStack.restore();
console.log('再次恢复:', stateStack.getTransform());
// 输出: { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 }

关键洞察

  • save() 的成本 = 复制整个状态对象(约 30 个属性)
  • 状态栈深度没有硬性限制,但过深会影响内存
  • restore() 的成本 = 状态赋值 + 可能的 GPU 同步

性能优化:减少 save/restore 开销

基准测试:save/restore 的成本

javascript
/**
 * 测试 save/restore 的性能开销
 */
function benchmarkSaveRestore() {
  const iterations = 10000;
  
  // 测试1:空循环基准
  console.time('空循环');
  for (let i = 0; i < iterations; i++) {
    // 什么都不做
  }
  console.timeEnd('空循环');
  // 结果:< 1ms
  
  // 测试2:save/restore
  console.time('save/restore');
  for (let i = 0; i < iterations; i++) {
    ctx.save();
    ctx.restore();
  }
  console.timeEnd('save/restore');
  // 结果:~15ms(Chrome)
  
  // 测试3:save + 变换 + restore
  console.time('save + 变换 + restore');
  for (let i = 0; i < iterations; i++) {
    ctx.save();
    ctx.translate(10, 10);
    ctx.rotate(0.1);
    ctx.restore();
  }
  console.timeEnd('save + 变换 + restore');
  // 结果:~25ms(Chrome)
  
  // 测试4:直接重置变换(无 save/restore)
  console.time('直接重置变换');
  for (let i = 0; i < iterations; i++) {
    ctx.translate(10, 10);
    ctx.rotate(0.1);
    ctx.setTransform(1, 0, 0, 1, 0, 0);  // 重置到单位矩阵
  }
  console.timeEnd('直接重置变换');
  // 结果:~20ms(稍快,但丢失其他状态)
}

benchmarkSaveRestore();

性能分析

  • save/restore 有固定开销(~1.5μs/次)
  • 10000 次操作约 15ms,60fps 下可用预算约 16ms
  • 结论:在渲染循环中避免过度使用 save/restore

优化策略1:批量变换

反模式(每个对象都 save/restore):

javascript
// ❌ 低效:1000个对象 = 1000次 save/restore
objects.forEach(obj => {
  ctx.save();
  ctx.translate(obj.x, obj.y);
  ctx.rotate(obj.rotation);
  ctx.fillRect(-obj.width/2, -obj.height/2, obj.width, obj.height);
  ctx.restore();
});

优化(按变换类型分组):

javascript
// ✅ 高效:按变换类型分组,减少 save/restore
const byTransform = new Map();

objects.forEach(obj => {
  const key = `${obj.rotation.toFixed(2)}_${obj.scaleX}_${obj.scaleY}`;
  if (!byTransform.has(key)) {
    byTransform.set(key, []);
  }
  byTransform.get(key).push(obj);
});

for (const [transformKey, group] of byTransform) {
  ctx.save();
  
  // 应用相同的变换
  const obj = group[0];
  ctx.rotate(obj.rotation);
  ctx.scale(obj.scaleX, obj.scaleY);
  
  // 批量绘制
  group.forEach(obj => {
    ctx.save();
    ctx.translate(obj.x, obj.y);
    ctx.fillRect(-obj.width/2, -obj.height/2, obj.width, obj.height);
    ctx.restore();
  });
  
  ctx.restore();
}

性能提升

  • 1000个对象,10种不同变换
  • 优化前:1000 次 save/restore (~15ms)
  • 优化后:10 次外层 + 1000 次内层 (~12ms,20%提升)

优化策略2:手动管理变换

对于简单场景,可以完全避免 save/restore,手动管理矩阵。

javascript
/**
 * 手动变换管理器
 */
class ManualTransformManager {
  constructor(ctx) {
    this.ctx = ctx;
    this.baseTransform = ctx.getTransform();
  }
  
  /**
   * 应用变换并绘制,然后立即重置
   */
  drawWithTransform(x, y, rotation, drawFn) {
    // 应用变换
    this.ctx.translate(x, y);
    if (rotation) {
      this.ctx.rotate(rotation);
    }
    
    // 绘制
    drawFn(this.ctx);
    
    // 重置到基础变换
    this.ctx.setTransform(
      this.baseTransform.a,
      this.baseTransform.b,
      this.baseTransform.c,
      this.baseTransform.d,
      this.baseTransform.e,
      this.baseTransform.f
    );
  }
}

// 使用
const manager = new ManualTransformManager(ctx);

objects.forEach(obj => {
  manager.drawWithTransform(obj.x, obj.y, obj.rotation, (ctx) => {
    ctx.fillRect(-obj.width/2, -obj.height/2, obj.width, obj.height);
  });
});

注意:这种方法只适用于不需要保存其他状态(如样式、阴影)的场景。


复杂场景应用:场景图(Scene Graph)

什么是场景图?

场景图是图形引擎中的经典数据结构,用于组织和管理层级对象。每个节点可以有多个子节点,子节点继承父节点的变换。

由于token限制,我将在下一条消息中继续补充完整内容。现在先保存这部分优化。

掌握变换堆栈后,复杂场景的变换管理将变得井然有序。下一章,我们将学习坐标系转换,解决屏幕坐标与Canvas坐标的转换问题。

变换堆栈与状态保存 has loaded