Appearance
脏矩形渲染基础
首先要问一个问题:如果画布上有 100 个对象,但只有 2 个在移动,是否还需要每帧重绘全部 100 个?
答案是:不需要。通过脏矩形 (Dirty Rectangle) 优化,我们只重绘发生变化的区域,大幅提升性能。
1. 问题分析
全局重绘的性能瓶颈
常见的渲染模式:
javascript
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height); // 清除整个画布
for (const obj of objects) {
obj.draw(ctx); // 重绘所有对象
}
}这种方式的问题:
- 即使 99% 的对象静止不动,也要全部重绘
- 对象数量多时,每帧开销巨大
局部更新的可能性
现在我要问第二个问题:能否只重绘变化的部分?
答案是肯定的!关键思想:
- 标记脏状态:对象移动或改变时,标记为"脏"
- 计算脏矩形:确定需要重绘的区域
- 局部重绘:只清除和重绘脏区域
这就是脏矩形优化的核心。
2. 脏区域标记
脏状态管理
javascript
class DirtyObject {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
this._dirty = true; // 初始为脏(需要首次绘制)
this._previousBounds = null; // 记录旧位置
}
get dirty() {
return this._dirty;
}
markDirty() {
if (!this._dirty) {
// 保存当前位置作为"旧位置"
this._previousBounds = this.getBounds();
this._dirty = true;
}
}
clearDirty() {
this._dirty = false;
this._previousBounds = null;
}
getBounds() {
return {
x: this.x,
y: this.y,
width: this.width,
height: this.height
};
}
// 移动时自动标记脏
setPosition(x, y) {
if (x !== this.x || y !== this.y) {
this.markDirty();
this.x = x;
this.y = y;
}
}
}关键点:
markDirty()时保存旧位置- 任何会影响渲染的操作(移动、缩放、旋转)都要调用
markDirty() - 渲染后调用
clearDirty()清除脏状态
3. 脏矩形计算
包含旧位置和新位置
第三个问题:脏矩形应该是什么?
答案是:旧位置和新位置的并集。
思考一下:对象从位置 A 移动到位置 B,需要重绘哪里?
- 旧位置 A:要清除旧的图像
- 新位置 B:要绘制新的图像
所以脏矩形必须包含这两个区域。
javascript
class Rect {
constructor(x, y, width, height) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
// 矩形合并:返回包含两个矩形的最小矩形
union(other) {
const x = Math.min(this.x, other.x);
const y = Math.min(this.y, other.y);
const right = Math.max(this.x + this.width, other.x + other.width);
const bottom = Math.max(this.y + this.height, other.y + other.height);
return new Rect(x, y, right - x, bottom - y);
}
// 矩形相交检测
intersects(other) {
return !(this.x + this.width < other.x ||
other.x + other.width < this.x ||
this.y + this.height < other.y ||
other.y + other.height < this.y);
}
// 扩展边距(防止边界精度问题)
expand(margin) {
return new Rect(
this.x - margin,
this.y - margin,
this.width + margin * 2,
this.height + margin * 2
);
}
}
// 在 DirtyObject 中添加
class DirtyObject {
// ... 前面的代码 ...
getDirtyRect() {
if (!this._dirty) return null;
const currentBounds = new Rect(this.x, this.y, this.width, this.height);
if (this._previousBounds) {
// 合并旧位置和新位置
const prevRect = new Rect(
this._previousBounds.x,
this._previousBounds.y,
this._previousBounds.width,
this._previousBounds.height
);
return currentBounds.union(prevRect);
}
return currentBounds;
}
}4. 矩形合并策略
如果有多个对象都移动了,会有多个脏矩形。现在问题来了:是分别重绘每个矩形,还是合并后一起重绘?
策略 1:全部合并为一个
javascript
function mergeAll(rects) {
if (rects.length === 0) return null;
let merged = rects[0];
for (let i = 1; i < rects.length; i++) {
merged = merged.union(rects[i]);
}
return merged;
}优点:简单 缺点:如果矩形分散,会重绘大量不必要的区域
策略 2:智能合并相交或接近的矩形
javascript
function mergeOverlapping(rects, threshold = 10) {
const result = [];
const remaining = [...rects];
while (remaining.length > 0) {
let current = remaining.pop().expand(threshold);
let didMerge = true;
// 反复尝试合并,直到无法继续合并
while (didMerge) {
didMerge = false;
for (let i = remaining.length - 1; i >= 0; i--) {
const expanded = remaining[i].expand(threshold);
if (current.intersects(expanded)) {
current = current.union(remaining[i]);
remaining.splice(i, 1);
didMerge = true;
}
}
}
result.push(current);
}
return result;
}这个算法会:
- 扩展矩形边界(threshold)
- 合并相交或接近的矩形
- 返回多个不重叠的矩形
优点:在重绘开销和分散程度之间取得平衡 缺点:算法复杂度较高
5. 局部重绘实现
使用 clip() 限制绘制区域
Canvas 的 clip() 方法可以限制绘制只在指定区域内生效。
javascript
class DirtyRectRenderer {
constructor(canvas, objects) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.objects = objects;
}
render() {
const dirtyRects = this.collectDirtyRects();
if (dirtyRects.length === 0) {
return; // 无需重绘
}
// 对每个脏矩形进行局部重绘
dirtyRects.forEach(rect => {
this.renderRegion(rect);
});
// 清理脏状态
this.objects.forEach(obj => {
if (obj.clearDirty) obj.clearDirty();
});
}
collectDirtyRects() {
const rects = [];
for (const obj of this.objects) {
if (obj.dirty) {
const dirtyRect = obj.getDirtyRect();
if (dirtyRect) {
rects.push(dirtyRect);
}
}
}
// 智能合并
return mergeOverlapping(rects, 10);
}
renderRegion(rect) {
this.ctx.save();
// 裁剪到脏区域
this.ctx.beginPath();
this.ctx.rect(rect.x, rect.y, rect.width, rect.height);
this.ctx.clip();
// 清除脏区域
this.ctx.clearRect(rect.x, rect.y, rect.width, rect.height);
// 重绘与脏区域相交的所有对象
for (const obj of this.objects) {
const bounds = obj.getBounds ?
new Rect(obj.getBounds().x, obj.getBounds().y, obj.getBounds().width, obj.getBounds().height) :
new Rect(obj.x, obj.y, obj.width, obj.height);
if (bounds.intersects(rect)) {
obj.draw(this.ctx);
}
}
this.ctx.restore();
}
}关键点:
- save/restore:保护 Canvas 状态
- clip():限制绘制区域
- 只重绘相交对象:遍历所有对象,只绘制与脏区域相交的
6. 完整示例
javascript
// 可移动的对象
class MovableBox extends DirtyObject {
constructor(x, y, width, height, color) {
super(x, y, width, height);
this.color = color;
this.velocityX = (Math.random() - 0.5) * 100;
this.velocityY = (Math.random() - 0.5) * 100;
}
update(deltaTime) {
const newX = this.x + this.velocityX * (deltaTime / 1000);
const newY = this.y + this.velocityY * (deltaTime / 1000);
// 边界反弹
if (newX < 0 || newX + this.width > canvas.width) {
this.velocityX = -this.velocityX;
}
if (newY < 0 || newY + this.height > canvas.height) {
this.velocityY = -this.velocityY;
}
this.setPosition(
Math.max(0, Math.min(newX, canvas.width - this.width)),
Math.max(0, Math.min(newY, canvas.height - this.height))
);
}
draw(ctx) {
ctx.fillStyle = this.color;
ctx.fillRect(this.x, this.y, this.width, this.height);
// 调试:绘制边界
ctx.strokeStyle = 'rgba(255,0,0,0.5)';
ctx.strokeRect(this.x, this.y, this.width, this.height);
}
}
// 性能对比演示
class PerformanceDemo {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
// 创建对象
this.objects = [];
for (let i = 0; i < 50; i++) {
const x = Math.random() * (canvas.width - 50);
const y = Math.random() * (canvas.height - 50);
const color = `hsl(${Math.random() * 360}, 70%, 50%)`;
this.objects.push(new MovableBox(x, y, 50, 50, color));
}
// 只让 5 个对象移动
this.movingObjects = this.objects.slice(0, 5);
this.staticObjects = this.objects.slice(5);
// 渲染器
this.dirtyRenderer = new DirtyRectRenderer(canvas, this.objects);
// 性能统计
this.useDirtyRect = true;
this.frameCount = 0;
this.totalTime = 0;
this.lastTime = performance.now();
}
update(deltaTime) {
// 只更新移动的对象
this.movingObjects.forEach(obj => obj.update(deltaTime));
}
render() {
const startTime = performance.now();
if (this.useDirtyRect) {
// 脏矩形渲染
this.dirtyRenderer.render();
} else {
// 全局重绘
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.objects.forEach(obj => obj.draw(this.ctx));
this.objects.forEach(obj => {
if (obj.clearDirty) obj.clearDirty();
});
}
const renderTime = performance.now() - startTime;
this.totalTime += renderTime;
this.frameCount++;
// 显示统计
this.drawStats(renderTime);
}
drawStats(renderTime) {
const avgTime = this.totalTime / this.frameCount;
this.ctx.fillStyle = 'black';
this.ctx.font = '14px monospace';
this.ctx.fillText(`Mode: ${this.useDirtyRect ? 'Dirty Rect' : 'Full Redraw'}`, 10, 20);
this.ctx.fillText(`Render Time: ${renderTime.toFixed(2)}ms`, 10, 40);
this.ctx.fillText(`Avg Time: ${avgTime.toFixed(2)}ms`, 10, 60);
this.ctx.fillText(`Objects: ${this.objects.length} (${this.movingObjects.length} moving)`, 10, 80);
}
toggleMode() {
this.useDirtyRect = !this.useDirtyRect;
this.frameCount = 0;
this.totalTime = 0;
}
loop(time) {
const deltaTime = time - this.lastTime;
this.lastTime = time;
this.update(deltaTime);
this.render();
requestAnimationFrame((t) => this.loop(t));
}
start() {
this.lastTime = performance.now();
requestAnimationFrame((t) => this.loop(t));
}
}
// 运行
const canvas = document.getElementById('canvas');
const demo = new PerformanceDemo(canvas);
demo.start();
// 切换模式按钮
document.getElementById('toggle').addEventListener('click', () => {
demo.toggleMode();
});7. 性能评估
何时使用脏矩形优化?
适合的场景:
- 大量静态对象,少量动态对象(如地图编辑器)
- 对象集中在某些区域
- 对象移动距离较小
不适合的场景:
- 大部分对象都在移动(如粒子系统)
- 对象分散在整个画布
- 对象数量很少(优化开销大于收益)
收益计算
全局重绘成本 = 对象数量 × 单个对象绘制时间
脏矩形重绘成本 = 脏标记开销 + 矩形合并开销 + 相交对象数量 × 绘制时间
当:脏矩形重绘成本 < 全局重绘成本,优化才有意义有个经验法则:如果少于 30% 的画布需要重绘,脏矩形优化通常是值得的。
8. 进阶:处理复杂变换
旋转和缩放的脏矩形
对于旋转的对象,脏矩形应该是轴对齐包围盒 (AABB)。
javascript
class RotatableObject extends DirtyObject {
constructor(x, y, width, height) {
super(x, y, width, height);
this.rotation = 0;
}
getBounds() {
if (this.rotation === 0) {
return { x: this.x, y: this.y, width: this.width, height: this.height };
}
// 计算旋转后的 AABB
const cx = this.x + this.width / 2;
const cy = this.y + this.height / 2;
const corners = [
{ x: this.x, y: this.y },
{ x: this.x + this.width, y: this.y },
{ x: this.x, y: this.y + this.height },
{ x: this.x + this.width, y: this.y + this.height }
];
const cos = Math.cos(this.rotation);
const sin = Math.sin(this.rotation);
const rotated = corners.map(corner => ({
x: cx + (corner.x - cx) * cos - (corner.y - cy) * sin,
y: cy + (corner.x - cx) * sin + (corner.y - cy) * cos
}));
const minX = Math.min(...rotated.map(p => p.x));
const maxX = Math.max(...rotated.map(p => p.x));
const minY = Math.min(...rotated.map(p => p.y));
const maxY = Math.max(...rotated.map(p => p.y));
return {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
};
}
}本章小结
本章介绍了脏矩形优化的基础原理和实现:
核心思想:
- 只重绘发生变化的区域,避免全局重绘
- 通过标记、计算、合并、局部重绘四个步骤实现
关键技术:
- 脏标记:记录旧位置和新位置
- 脏矩形计算:旧位置和新位置的并集
- 智能合并:合并相交或接近的矩形
- 局部重绘:使用 clip() 限制绘制区域
性能评估:
- 适合静态对象多、动态对象少的场景
- 脏区域 < 30% 时优势明显
- 需要权衡管理开销和重绘成本
复杂变换处理:
- 旋转对象使用 AABB(轴对齐包围盒)
- 确保脏矩形包含所有影响区域
下一章,我们将探讨脏矩形的性能权衡、企业级实现和真实项目案例,帮助你在生产环境中正确使用这项优化技术。