深入学习Canvas脏矩形技术,通过局部重绘减少重绘区域提升性能 脏矩形(Dirty Rectangle)技术是一种重要的Canvas性能优化方法,通过只重绘发生变化的区域,大幅减少不必要的像素处理。
脏矩形是指需要重绘的矩形区域。通过跟踪变化区域,只清除和重绘这些区域,而不是整个Canvas。
class DirtyRectangle {
constructor() {
this.rects = []
this.mergedRect = null
}
add(x, y, width, height, padding = 0) {
this.rects.push({
x: x - padding,
y: y - padding,
width: width + padding * 2,
height: height + padding * 2
})
this.mergedRect = null
}
addFromBounds(bounds, padding = 0) {
this.add(
bounds.x, bounds.y,
bounds.width, bounds.height,
padding
)
}
merge() {
if (this.rects.length === 0) {
return null
}
if (this.mergedRect) {
return this.mergedRect
}
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
this.rects.forEach(rect => {
minX = Math.min(minX, rect.x)
minY = Math.min(minY, rect.y)
maxX = Math.max(maxX, rect.x + rect.width)
maxY = Math.max(maxY, rect.y + rect.height)
})
this.mergedRect = {
x: minX,
y: minY,
width: maxX - minX,
height: maxY - minY
}
return this.mergedRect
}
clear() {
this.rects = []
this.mergedRect = null
}
isEmpty() {
return this.rects.length === 0
}
getRects() {
return [...this.rects]
}
}
class DirtyRectangleRenderer {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.dirtyRects = new DirtyRectangle()
this.objects = []
this.background = null
}
setBackground(drawFn) {
this.background = drawFn
}
addObject(obj) {
this.objects.push(obj)
this.markDirty(obj)
}
removeObject(obj) {
const index = this.objects.indexOf(obj)
if (index > -1) {
this.markDirty(obj)
this.objects.splice(index, 1)
}
}
updateObject(obj, oldBounds) {
if (oldBounds) {
this.dirtyRects.addFromBounds(oldBounds, 5)
}
this.markDirty(obj)
}
markDirty(obj) {
if (obj.getBounds) {
this.dirtyRects.addFromBounds(obj.getBounds(), 5)
} else {
this.dirtyRects.add(obj.x, obj.y, obj.width || 0, obj.height || 0, 5)
}
}
render() {
const dirtyRect = this.dirtyRects.merge()
if (!dirtyRect) {
return
}
this.ctx.save()
this.ctx.beginPath()
this.ctx.rect(dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height)
this.ctx.clip()
this.ctx.clearRect(dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height)
if (this.background) {
this.background(this.ctx, dirtyRect)
}
this.objects.forEach(obj => {
if (this.intersects(obj, dirtyRect)) {
obj.render(this.ctx)
}
})
this.ctx.restore()
this.dirtyRects.clear()
}
intersects(obj, rect) {
const bounds = obj.getBounds ? obj.getBounds() : obj
return bounds.x < rect.x + rect.width &&
bounds.x + (bounds.width || 0) > rect.x &&
bounds.y < rect.y + rect.height &&
bounds.y + (bounds.height || 0) > rect.y
}
renderAll() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
if (this.background) {
this.background(this.ctx, null)
}
this.objects.forEach(obj => obj.render(this.ctx))
this.dirtyRects.clear()
}
}
class MultiDirtyRectRenderer {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.dirtyRects = []
this.objects = []
this.maxRects = 10
this.mergeThreshold = 0.7
}
addDirtyRect(x, y, width, height) {
this.dirtyRects.push({ x, y, width, height })
if (this.dirtyRects.length > this.maxRects) {
this.optimizeRects()
}
}
optimizeRects() {
const totalArea = this.dirtyRects.reduce((sum, r) => sum + r.width * r.height, 0)
const merged = this.mergeAllRects()
const mergedArea = merged.width * merged.height
if (mergedArea < totalArea * this.mergeThreshold) {
this.dirtyRects = [merged]
}
}
mergeAllRects() {
let minX = Infinity, minY = Infinity
let maxX = -Infinity, maxY = -Infinity
this.dirtyRects.forEach(r => {
minX = Math.min(minX, r.x)
minY = Math.min(minY, r.y)
maxX = Math.max(maxX, r.x + r.width)
maxY = Math.max(maxY, r.y + r.height)
})
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }
}
render() {
if (this.dirtyRects.length === 0) return
this.dirtyRects.forEach(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)
this.objects.forEach(obj => {
if (this.intersects(obj, rect)) {
obj.render(this.ctx)
}
})
this.ctx.restore()
})
this.dirtyRects = []
}
intersects(obj, rect) {
return obj.x < rect.x + rect.width &&
obj.x + obj.width > rect.x &&
obj.y < rect.y + rect.height &&
obj.y + obj.height > rect.y
}
}
class TrackedObject {
constructor(x, y, width, height) {
this.x = x
this.y = y
this.width = width
this.height = height
this._prevX = x
this._prevY = y
this._prevWidth = width
this._prevHeight = height
this.dirtyCallback = null
}
setDirtyCallback(callback) {
this.dirtyCallback = callback
}
setPosition(x, y) {
if (this.x !== x || this.y !== y) {
this.notifyDirty()
this._prevX = this.x
this._prevY = this.y
this.x = x
this.y = y
}
}
setSize(width, height) {
if (this.width !== width || this.height !== height) {
this.notifyDirty()
this._prevWidth = this.width
this._prevHeight = this.height
this.width = width
this.height = height
}
}
notifyDirty() {
if (this.dirtyCallback) {
this.dirtyCallback(this.getPreviousBounds())
this.dirtyCallback(this.getBounds())
}
}
getBounds() {
return { x: this.x, y: this.y, width: this.width, height: this.height }
}
getPreviousBounds() {
return {
x: this._prevX,
y: this._prevY,
width: this._prevWidth,
height: this._prevHeight
}
}
render(ctx) {
ctx.fillStyle = '#e94560'
ctx.fillRect(this.x, this.y, this.width, this.height)
}
}
class TrackedRenderer {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.dirtyRects = new DirtyRectangle()
this.objects = new Set()
}
addObject(obj) {
this.objects.add(obj)
obj.setDirtyCallback((bounds) => {
if (bounds) {
this.dirtyRects.addFromBounds(bounds, 5)
}
})
this.dirtyRects.addFromBounds(obj.getBounds(), 5)
}
removeObject(obj) {
this.dirtyRects.addFromBounds(obj.getBounds(), 5)
obj.setDirtyCallback(null)
this.objects.delete(obj)
}
render() {
const dirtyRect = this.dirtyRects.merge()
if (!dirtyRect) return
this.ctx.save()
this.ctx.beginPath()
this.ctx.rect(dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height)
this.ctx.clip()
this.ctx.clearRect(dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height)
this.objects.forEach(obj => {
if (this.intersects(obj, dirtyRect)) {
obj.render(this.ctx)
}
})
this.ctx.restore()
this.dirtyRects.clear()
}
intersects(obj, rect) {
const bounds = obj.getBounds()
return bounds.x < rect.x + rect.width &&
bounds.x + bounds.width > rect.x &&
bounds.y < rect.y + rect.height &&
bounds.y + bounds.height > rect.y
}
}
class CachedBackgroundRenderer {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.dirtyRects = new DirtyRectangle()
this.objects = []
this.backgroundCanvas = null
this.backgroundCtx = null
this.backgroundValid = false
this.initBackground()
}
initBackground() {
this.backgroundCanvas = document.createElement('canvas')
this.backgroundCanvas.width = this.canvas.width
this.backgroundCanvas.height = this.canvas.height
this.backgroundCtx = this.backgroundCanvas.getContext('2d')
}
setBackground(renderFn) {
this.backgroundRenderFn = renderFn
this.backgroundValid = false
this.renderBackground()
}
renderBackground() {
if (!this.backgroundValid && this.backgroundRenderFn) {
this.backgroundCtx.clearRect(0, 0, this.backgroundCanvas.width, this.backgroundCanvas.height)
this.backgroundRenderFn(this.backgroundCtx)
this.backgroundValid = true
}
}
addObject(obj) {
this.objects.push(obj)
this.markDirty(obj.getBounds())
}
markDirty(bounds) {
this.dirtyRects.addFromBounds(bounds, 5)
}
render() {
const dirtyRect = this.dirtyRects.merge()
if (!dirtyRect) return
this.ctx.save()
this.ctx.beginPath()
this.ctx.rect(dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height)
this.ctx.clip()
this.ctx.drawImage(
this.backgroundCanvas,
dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height,
dirtyRect.x, dirtyRect.y, dirtyRect.width, dirtyRect.height
)
this.objects.forEach(obj => {
if (this.intersects(obj, dirtyRect)) {
obj.render(this.ctx)
}
})
this.ctx.restore()
this.dirtyRects.clear()
}
intersects(obj, rect) {
const bounds = obj.getBounds()
return bounds.x < rect.x + rect.width &&
bounds.x + bounds.width > rect.x &&
bounds.y < rect.y + rect.height &&
bounds.y + bounds.height > rect.y
}
resize(width, height) {
this.canvas.width = width
this.canvas.height = height
this.backgroundCanvas.width = width
this.backgroundCanvas.height = height
this.backgroundValid = false
this.renderBackground()
}
}
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas 脏矩形优化 - 动画示例</title>
<style>
body {
margin: 0;
padding: 20px;
background: #1a1a2e;
font-family: system-ui, sans-serif;
}
.container {
max-width: 900px;
margin: 0 auto;
}
h1 {
color: #eee;
text-align: center;
}
.canvas-container {
display: flex;
gap: 20px;
flex-wrap: wrap;
justify-content: center;
}
.canvas-wrapper {
background: #16213e;
border-radius: 8px;
padding: 10px;
}
.canvas-wrapper h3 {
color: #eee;
margin: 0 0 10px;
text-align: center;
}
canvas {
display: block;
background: #0f0f23;
border-radius: 4px;
}
.stats {
color: #00ff00;
font-family: monospace;
font-size: 12px;
margin-top: 10px;
text-align: center;
}
.controls {
display: flex;
gap: 10px;
justify-content: center;
margin-top: 20px;
}
button {
padding: 10px 20px;
background: #e94560;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
button:hover {
background: #ff6b6b;
}
.show-dirty {
outline: 2px solid #00ff00;
}
</style>
</head>
<body>
<div class="container">
<h1>脏矩形优化对比</h1>
<div class="canvas-container">
<div class="canvas-wrapper">
<h3>全屏重绘</h3>
<canvas id="fullCanvas" width="400" height="300"></canvas>
<div class="stats" id="fullStats">FPS: 0 | 像素: 0</div>
</div>
<div class="canvas-wrapper">
<h3>脏矩形重绘</h3>
<canvas id="dirtyCanvas" width="400" height="300"></canvas>
<div class="stats" id="dirtyStats">FPS: 0 | 像素: 0</div>
</div>
</div>
<div class="controls">
<button onclick="toggleAnimation()">暂停/继续</button>
<button onclick="toggleDirtyRects()">显示脏矩形</button>
<button onclick="changeCount(10)">10对象</button>
<button onclick="changeCount(50)">50对象</button>
<button onclick="changeCount(100)">100对象</button>
</div>
</div>
<script>
class AnimatedObject {
constructor(canvasWidth, canvasHeight) {
this.canvasWidth = canvasWidth
this.canvasHeight = canvasHeight
this.x = Math.random() * (canvasWidth - 40)
this.y = Math.random() * (canvasHeight - 40)
this.width = 30 + Math.random() * 20
this.height = 30 + Math.random() * 20
this.vx = (Math.random() - 0.5) * 4
this.vy = (Math.random() - 0.5) * 4
this.color = `hsl(${Math.random() * 360}, 70%, 60%)`
this.prevX = this.x
this.prevY = this.y
}
update() {
this.prevX = this.x
this.prevY = this.y
this.x += this.vx
this.y += this.vy
if (this.x <= 0 || this.x + this.width >= this.canvasWidth) {
this.vx *= -1
this.x = Math.max(0, Math.min(this.canvasWidth - this.width, this.x))
}
if (this.y <= 0 || this.y + this.height >= this.canvasHeight) {
this.vy *= -1
this.y = Math.max(0, Math.min(this.canvasHeight - this.height, this.y))
}
}
getBounds() {
return { x: this.x, y: this.y, width: this.width, height: this.height }
}
getPreviousBounds() {
return { x: this.prevX, y: this.prevY, width: this.width, height: this.height }
}
render(ctx) {
ctx.fillStyle = this.color
ctx.fillRect(this.x, this.y, this.width, this.height)
}
}
class FullRenderer {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.objects = []
this.pixelsProcessed = 0
this.fps = 0
this.frameCount = 0
this.lastTime = performance.now()
}
init(count) {
this.objects = []
for (let i = 0; i < count; i++) {
this.objects.push(new AnimatedObject(this.canvas.width, this.canvas.height))
}
}
render() {
this.ctx.fillStyle = '#0f0f23'
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
this.objects.forEach(obj => {
obj.update()
obj.render(this.ctx)
})
this.pixelsProcessed = this.canvas.width * this.canvas.height
this.updateFPS()
}
updateFPS() {
this.frameCount++
const now = performance.now()
if (now - this.lastTime >= 1000) {
this.fps = this.frameCount
this.frameCount = 0
this.lastTime = now
}
}
}
class DirtyRenderer {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.objects = []
this.dirtyRects = []
this.pixelsProcessed = 0
this.fps = 0
this.frameCount = 0
this.lastTime = performance.now()
this.showDirtyRects = false
}
init(count) {
this.objects = []
for (let i = 0; i < count; i++) {
this.objects.push(new AnimatedObject(this.canvas.width, this.canvas.height))
}
this.ctx.fillStyle = '#0f0f23'
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
}
render() {
this.dirtyRects = []
this.objects.forEach(obj => {
const prevBounds = obj.getPreviousBounds()
const currBounds = obj.getBounds()
this.addDirtyRect(prevBounds)
this.addDirtyRect(currBounds)
obj.update()
})
const mergedRect = this.mergeDirtyRects()
if (mergedRect) {
this.ctx.save()
this.ctx.beginPath()
this.ctx.rect(mergedRect.x, mergedRect.y, mergedRect.width, mergedRect.height)
this.ctx.clip()
this.ctx.fillStyle = '#0f0f23'
this.ctx.fillRect(mergedRect.x, mergedRect.y, mergedRect.width, mergedRect.height)
this.objects.forEach(obj => {
if (this.intersects(obj.getBounds(), mergedRect)) {
obj.render(this.ctx)
}
})
if (this.showDirtyRects) {
this.ctx.strokeStyle = '#00ff00'
this.ctx.lineWidth = 2
this.ctx.strokeRect(mergedRect.x, mergedRect.y, mergedRect.width, mergedRect.height)
}
this.ctx.restore()
this.pixelsProcessed = mergedRect.width * mergedRect.height
}
this.updateFPS()
}
addDirtyRect(bounds) {
this.dirtyRects.push({
x: bounds.x - 2,
y: bounds.y - 2,
width: bounds.width + 4,
height: bounds.height + 4
})
}
mergeDirtyRects() {
if (this.dirtyRects.length === 0) return null
let minX = Infinity, minY = Infinity
let maxX = -Infinity, maxY = -Infinity
this.dirtyRects.forEach(r => {
minX = Math.min(minX, r.x)
minY = Math.min(minY, r.y)
maxX = Math.max(maxX, r.x + r.width)
maxY = Math.max(maxY, r.y + r.height)
})
return {
x: Math.max(0, minX),
y: Math.max(0, minY),
width: Math.min(this.canvas.width - minX, maxX - minX),
height: Math.min(this.canvas.height - minY, maxY - minY)
}
}
intersects(bounds, rect) {
return bounds.x < rect.x + rect.width &&
bounds.x + bounds.width > rect.x &&
bounds.y < rect.y + rect.height &&
bounds.y + bounds.height > rect.y
}
updateFPS() {
this.frameCount++
const now = performance.now()
if (now - this.lastTime >= 1000) {
this.fps = this.frameCount
this.frameCount = 0
this.lastTime = now
}
}
toggleShowDirtyRects() {
this.showDirtyRects = !this.showDirtyRects
}
}
const fullCanvas = document.getElementById('fullCanvas')
const dirtyCanvas = document.getElementById('dirtyCanvas')
const fullStats = document.getElementById('fullStats')
const dirtyStats = document.getElementById('dirtyStats')
const fullRenderer = new FullRenderer(fullCanvas)
const dirtyRenderer = new DirtyRenderer(dirtyCanvas)
let objectCount = 50
let isRunning = true
function init() {
fullRenderer.init(objectCount)
dirtyRenderer.init(objectCount)
}
function animate() {
if (!isRunning) return
fullRenderer.render()
dirtyRenderer.render()
fullStats.textContent = `FPS: ${fullRenderer.fps} | 像素: ${fullRenderer.pixelsProcessed.toLocaleString()}`
dirtyStats.textContent = `FPS: ${dirtyRenderer.fps} | 像素: ${dirtyRenderer.pixelsProcessed.toLocaleString()}`
requestAnimationFrame(animate)
}
function toggleAnimation() {
isRunning = !isRunning
if (isRunning) animate()
}
function toggleDirtyRects() {
dirtyRenderer.toggleShowDirtyRects()
dirtyCanvas.classList.toggle('show-dirty')
}
function changeCount(count) {
objectCount = count
init()
}
init()
animate()
</script>
</body>
</html>
const dirtyRectUseCases = {
recommended: [
{
scenario: '少量移动对象',
example: '游戏角色、UI元素',
benefit: '大幅减少重绘区域'
},
{
scenario: '局部更新',
example: '数据点变化、进度条',
benefit: '只更新变化部分'
},
{
scenario: '静态背景',
example: '地图、面板',
benefit: '背景无需重绘'
}
],
notRecommended: [
{
scenario: '全屏动态效果',
example: '全屏粒子、波浪',
reason: '脏区域接近全屏'
},
{
scenario: '大量小对象',
example: '数千个小粒子',
reason: '合并后区域过大'
},
{
scenario: '频繁全局变化',
example: '背景动画',
reason: '无法有效减少重绘'
}
]
}
class DirtyRectMonitor {
constructor() {
this.stats = {
totalFrames: 0,
dirtyFrames: 0,
totalDirtyPixels: 0,
totalCanvasPixels: 0,
dirtyRectsPerFrame: []
}
}
recordFrame(canvasWidth, canvasHeight, dirtyRects) {
this.stats.totalFrames++
this.stats.totalCanvasPixels = canvasWidth * canvasHeight
if (dirtyRects && dirtyRects.length > 0) {
this.stats.dirtyFrames++
let framePixels = 0
dirtyRects.forEach(r => {
framePixels += r.width * r.height
})
this.stats.totalDirtyPixels += framePixels
this.stats.dirtyRectsPerFrame.push(dirtyRects.length)
}
}
getReport() {
const avgDirtyPixels = this.stats.totalFrames > 0
? this.stats.totalDirtyPixels / this.stats.totalFrames
: 0
const avgDirtyRatio = this.stats.totalCanvasPixels > 0
? avgDirtyPixels / this.stats.totalCanvasPixels
: 0
const avgDirtyRects = this.stats.dirtyRectsPerFrame.length > 0
? this.stats.dirtyRectsPerFrame.reduce((a, b) => a + b, 0) / this.stats.dirtyRectsPerFrame.length
: 0
return {
totalFrames: this.stats.totalFrames,
dirtyFrames: this.stats.dirtyFrames,
avgDirtyPixels: Math.round(avgDirtyPixels),
avgDirtyRatio: (avgDirtyRatio * 100).toFixed(2) + '%',
avgDirtyRects: avgDirtyRects.toFixed(2),
efficiency: ((1 - avgDirtyRatio) * 100).toFixed(2) + '%'
}
}
reset() {
this.stats = {
totalFrames: 0,
dirtyFrames: 0,
totalDirtyPixels: 0,
totalCanvasPixels: 0,
dirtyRectsPerFrame: []
}
}
}
脏矩形技术是Canvas性能优化的重要手段:
合理运用脏矩形技术,可以显著减少像素处理量,提升Canvas应用的渲染性能。