深入学习离屏Canvas技术,通过预渲染提升Canvas应用性能 离屏Canvas(Offscreen Canvas)是一种重要的性能优化技术,通过在内存中预渲染内容,减少主Canvas的绘制操作,显著提升渲染性能。
离屏Canvas是指不直接显示在页面上的Canvas元素,用于在内存中进行绘制操作,然后将结果复制到主Canvas上显示。
class OffscreenCanvasManager {
constructor(width, height) {
this.width = width
this.height = height
this.canvas = null
this.ctx = null
this.init()
}
init() {
if (typeof OffscreenCanvas !== 'undefined') {
this.canvas = new OffscreenCanvas(this.width, this.height)
} else {
this.canvas = document.createElement('canvas')
this.canvas.width = this.width
this.canvas.height = this.height
}
this.ctx = this.canvas.getContext('2d')
}
getContext() {
return this.ctx
}
getCanvas() {
return this.canvas
}
resize(width, height) {
this.width = width
this.height = height
this.canvas.width = width
this.canvas.height = height
}
clear() {
this.ctx.clearRect(0, 0, this.width, this.height)
}
drawTo(targetCtx, x = 0, y = 0) {
targetCtx.drawImage(this.canvas, x, y)
}
drawToWithScale(targetCtx, x, y, width, height) {
targetCtx.drawImage(this.canvas, x, y, width, height)
}
clone() {
const clone = new OffscreenCanvasManager(this.width, this.height)
clone.ctx.drawImage(this.canvas, 0, 0)
return clone
}
toDataURL(type = 'image/png', quality = 1) {
if (this.canvas instanceof HTMLCanvasElement) {
return this.canvas.toDataURL(type, quality)
}
return null
}
toBlob(type = 'image/png', quality = 1) {
return new Promise((resolve, reject) => {
if (this.canvas instanceof HTMLCanvasElement) {
this.canvas.toBlob(resolve, type, quality)
} else if (this.canvas.convertToBlob) {
this.canvas.convertToBlob({ type, quality }).then(resolve, reject)
} else {
reject(new Error('Blob conversion not supported'))
}
})
}
}
class PrerenderedSprite {
constructor(renderFunction, width, height) {
this.width = width
this.height = height
this.offscreen = new OffscreenCanvasManager(width, height)
this.cached = false
this.renderFunction = renderFunction
}
prerender() {
this.offscreen.clear()
this.renderFunction(this.offscreen.getContext(), this.width, this.height)
this.cached = true
}
draw(ctx, x, y) {
if (!this.cached) {
this.prerender()
}
this.offscreen.drawTo(ctx, x, y)
}
drawWithTransform(ctx, x, y, rotation = 0, scaleX = 1, scaleY = 1) {
if (!this.cached) {
this.prerender()
}
ctx.save()
ctx.translate(x + this.width / 2, y + this.height / 2)
ctx.rotate(rotation)
ctx.scale(scaleX, scaleY)
ctx.translate(-this.width / 2, -this.height / 2)
this.offscreen.drawTo(ctx, 0, 0)
ctx.restore()
}
invalidate() {
this.cached = false
}
}
class SpriteCache {
constructor() {
this.cache = new Map()
}
get(key, renderFunction, width, height) {
if (!this.cache.has(key)) {
const sprite = new PrerenderedSprite(renderFunction, width, height)
sprite.prerender()
this.cache.set(key, sprite)
}
return this.cache.get(key)
}
has(key) {
return this.cache.has(key)
}
invalidate(key) {
if (this.cache.has(key)) {
this.cache.get(key).invalidate()
}
}
remove(key) {
this.cache.delete(key)
}
clear() {
this.cache.clear()
}
getStats() {
return {
count: this.cache.size,
keys: Array.from(this.cache.keys())
}
}
}
const spriteCache = new SpriteCache()
function createTreeSprite() {
return spriteCache.get('tree', (ctx, w, h) => {
ctx.fillStyle = '#8B4513'
ctx.fillRect(w * 0.4, h * 0.6, w * 0.2, h * 0.4)
ctx.fillStyle = '#228B22'
ctx.beginPath()
ctx.moveTo(w * 0.5, h * 0.1)
ctx.lineTo(w * 0.1, h * 0.7)
ctx.lineTo(w * 0.9, h * 0.7)
ctx.closePath()
ctx.fill()
}, 100, 150)
}
class TextCache {
constructor() {
this.cache = new Map()
this.maxSize = 100
}
getKey(text, font, fillStyle, strokeStyle = null, lineWidth = 0) {
return `${text}|${font}|${fillStyle}|${strokeStyle}|${lineWidth}`
}
get(text, font, fillStyle, strokeStyle = null, lineWidth = 0) {
const key = this.getKey(text, font, fillStyle, strokeStyle, lineWidth)
if (!this.cache.has(key)) {
this.create(key, text, font, fillStyle, strokeStyle, lineWidth)
}
return this.cache.get(key)
}
create(key, text, font, fillStyle, strokeStyle, lineWidth) {
const measureCtx = document.createElement('canvas').getContext('2d')
measureCtx.font = font
const metrics = measureCtx.measureText(text)
const width = Math.ceil(metrics.width + lineWidth * 2)
const height = Math.ceil(metrics.actualBoundingBoxAscent +
metrics.actualBoundingBoxDescent + lineWidth * 2)
const offscreen = new OffscreenCanvasManager(width, height)
const ctx = offscreen.getContext()
ctx.font = font
ctx.textBaseline = 'top'
if (strokeStyle) {
ctx.strokeStyle = strokeStyle
ctx.lineWidth = lineWidth
ctx.strokeText(text, lineWidth, lineWidth)
}
ctx.fillStyle = fillStyle
ctx.fillText(text, lineWidth, lineWidth)
this.cache.set(key, {
canvas: offscreen,
width,
height
})
if (this.cache.size > this.maxSize) {
const firstKey = this.cache.keys().next().value
this.cache.delete(firstKey)
}
}
draw(ctx, text, x, y, font, fillStyle, strokeStyle = null, lineWidth = 0) {
const cached = this.get(text, font, fillStyle, strokeStyle, lineWidth)
cached.canvas.drawTo(ctx, x, y)
}
clear() {
this.cache.clear()
}
}
class ImageCache {
constructor() {
this.cache = new Map()
this.loading = new Map()
this.pendingCallbacks = new Map()
}
load(src) {
return new Promise((resolve, reject) => {
if (this.cache.has(src)) {
resolve(this.cache.get(src))
return
}
if (this.loading.has(src)) {
if (!this.pendingCallbacks.has(src)) {
this.pendingCallbacks.set(src, [])
}
this.pendingCallbacks.get(src).push({ resolve, reject })
return
}
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => {
this.cache.set(src, img)
this.loading.delete(src)
resolve(img)
if (this.pendingCallbacks.has(src)) {
this.pendingCallbacks.get(src).forEach(cb => cb.resolve(img))
this.pendingCallbacks.delete(src)
}
}
img.onerror = (err) => {
this.loading.delete(src)
reject(err)
if (this.pendingCallbacks.has(src)) {
this.pendingCallbacks.get(src).forEach(cb => cb.reject(err))
this.pendingCallbacks.delete(src)
}
}
this.loading.set(src, img)
img.src = src
})
}
get(src) {
return this.cache.get(src)
}
has(src) {
return this.cache.has(src)
}
preload(sources) {
return Promise.all(sources.map(src => this.load(src)))
}
remove(src) {
this.cache.delete(src)
}
clear() {
this.cache.clear()
this.loading.clear()
this.pendingCallbacks.clear()
}
getStats() {
return {
cached: this.cache.size,
loading: this.loading.size,
pending: this.pendingCallbacks.size
}
}
}
const imageCache = new ImageCache()
class SpriteAtlas {
constructor() {
this.atlases = new Map()
this.sprites = new Map()
}
async loadAtlas(name, src, definitions) {
const img = await imageCache.load(src)
const atlas = {
image: img,
definitions: definitions
}
this.atlases.set(name, atlas)
Object.entries(definitions).forEach(([spriteName, def]) => {
this.sprites.set(`${name}:${spriteName}`, {
atlas: name,
x: def.x,
y: def.y,
width: def.width,
height: def.height
})
})
}
draw(ctx, spriteName, x, y, options = {}) {
const sprite = this.sprites.get(spriteName)
if (!sprite) return
const atlas = this.atlases.get(sprite.atlas)
if (!atlas) return
const { width = sprite.width, height = sprite.height } = options
ctx.drawImage(
atlas.image,
sprite.x, sprite.y, sprite.width, sprite.height,
x, y, width, height
)
}
drawWithTransform(ctx, spriteName, x, y, options = {}) {
const sprite = this.sprites.get(spriteName)
if (!sprite) return
const atlas = this.atlases.get(sprite.atlas)
if (!atlas) return
const {
width = sprite.width,
height = sprite.height,
rotation = 0,
scaleX = 1,
scaleY = 1,
alpha = 1
} = options
ctx.save()
ctx.globalAlpha = alpha
ctx.translate(x + width / 2, y + height / 2)
ctx.rotate(rotation)
ctx.scale(scaleX, scaleY)
ctx.drawImage(
atlas.image,
sprite.x, sprite.y, sprite.width, sprite.height,
-width / 2, -height / 2, width, height
)
ctx.restore()
}
getSpriteSize(spriteName) {
const sprite = this.sprites.get(spriteName)
if (!sprite) return null
return { width: sprite.width, height: sprite.height }
}
}
class OffscreenRenderer {
constructor(workerScript) {
this.worker = new Worker(workerScript)
this.pendingRequests = new Map()
this.requestId = 0
this.setupWorker()
}
setupWorker() {
this.worker.onmessage = (e) => {
const { id, type, data } = e.data
if (this.pendingRequests.has(id)) {
const { resolve } = this.pendingRequests.get(id)
this.pendingRequests.delete(id)
resolve(data)
}
}
this.worker.onerror = (e) => {
console.error('Worker error:', e)
}
}
init(width, height) {
return this.sendRequest('init', { width, height })
}
render(renderData) {
return this.sendRequest('render', renderData)
}
getImageData() {
return this.sendRequest('getImageData', {})
}
sendRequest(type, data) {
return new Promise((resolve, reject) => {
const id = this.requestId++
this.pendingRequests.set(id, { resolve, reject })
this.worker.postMessage({ id, type, data })
})
}
terminate() {
this.worker.terminate()
this.pendingRequests.clear()
}
}
const workerScript = `
let offscreen = null
let ctx = null
self.onmessage = function(e) {
const { id, type, data } = e.data
switch (type) {
case 'init':
offscreen = new OffscreenCanvas(data.width, data.height)
ctx = offscreen.getContext('2d')
self.postMessage({ id, type: 'init', data: { success: true } })
break
case 'render':
if (!ctx) return
ctx.clearRect(0, 0, offscreen.width, offscreen.height)
data.shapes.forEach(shape => {
ctx.fillStyle = shape.color
ctx.fillRect(shape.x, shape.y, shape.width, shape.height)
})
self.postMessage({ id, type: 'render', data: { success: true } })
break
case 'getImageData':
if (!ctx) return
const imageData = ctx.getImageData(0, 0, offscreen.width, offscreen.height)
self.postMessage({ id, type: 'getImageData', data: imageData }, [imageData.data.buffer])
break
}
}
`
const workerBlob = new Blob([workerScript], { type: 'application/javascript' })
const workerUrl = URL.createObjectURL(workerBlob)
<!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;
}
</style>
</head>
<body>
<div class="container">
<h1>离屏Canvas性能对比</h1>
<div class="canvas-container">
<div class="canvas-wrapper">
<h3>直接渲染</h3>
<canvas id="directCanvas" width="400" height="300"></canvas>
<div class="stats" id="directStats">FPS: 0</div>
</div>
<div class="canvas-wrapper">
<h3>离屏渲染</h3>
<canvas id="offscreenCanvas" width="400" height="300"></canvas>
<div class="stats" id="offscreenStats">FPS: 0</div>
</div>
</div>
<div class="controls">
<button onclick="toggleAnimation()">暂停/继续</button>
<button onclick="changeParticleCount(500)">500粒子</button>
<button onclick="changeParticleCount(1000)">1000粒子</button>
<button onclick="changeParticleCount(2000)">2000粒子</button>
</div>
</div>
<script>
class Particle {
constructor(canvasWidth, canvasHeight) {
this.reset(canvasWidth, canvasHeight)
}
reset(canvasWidth, canvasHeight) {
this.x = Math.random() * canvasWidth
this.y = Math.random() * canvasHeight
this.vx = (Math.random() - 0.5) * 2
this.vy = (Math.random() - 0.5) * 2
this.size = Math.random() * 4 + 2
this.hue = Math.random() * 360
}
update(canvasWidth, canvasHeight) {
this.x += this.vx
this.y += this.vy
if (this.x < 0 || this.x > canvasWidth) this.vx *= -1
if (this.y < 0 || this.y > canvasHeight) this.vy *= -1
}
}
class DirectRenderer {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.particles = []
this.fps = 0
this.frameCount = 0
this.lastTime = performance.now()
}
init(count) {
this.particles = []
for (let i = 0; i < count; i++) {
this.particles.push(new Particle(this.canvas.width, this.canvas.height))
}
}
render() {
this.ctx.fillStyle = 'rgba(15, 15, 35, 0.1)'
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height)
this.particles.forEach(p => {
p.update(this.canvas.width, this.canvas.height)
this.ctx.beginPath()
this.ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
this.ctx.fillStyle = `hsl(${p.hue}, 100%, 60%)`
this.ctx.fill()
})
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 OffscreenRenderer {
constructor(canvas) {
this.canvas = canvas
this.ctx = canvas.getContext('2d')
this.offscreen = document.createElement('canvas')
this.offscreen.width = canvas.width
this.offscreen.height = canvas.height
this.offCtx = this.offscreen.getContext('2d')
this.particles = []
this.fps = 0
this.frameCount = 0
this.lastTime = performance.now()
this.particleCache = this.createParticleCache()
}
createParticleCache() {
const cache = new Map()
const sizes = [2, 3, 4, 5, 6]
const hues = [0, 60, 120, 180, 240, 300]
sizes.forEach(size => {
hues.forEach(hue => {
const key = `${size}-${hue}`
const offscreen = document.createElement('canvas')
offscreen.width = size * 2
offscreen.height = size * 2
const ctx = offscreen.getContext('2d')
const gradient = ctx.createRadialGradient(size, size, 0, size, size, size)
gradient.addColorStop(0, `hsla(${hue}, 100%, 70%, 1)`)
gradient.addColorStop(1, `hsla(${hue}, 100%, 50%, 0)`)
ctx.fillStyle = gradient
ctx.beginPath()
ctx.arc(size, size, size, 0, Math.PI * 2)
ctx.fill()
cache.set(key, offscreen)
})
})
return cache
}
init(count) {
this.particles = []
for (let i = 0; i < count; i++) {
this.particles.push(new Particle(this.canvas.width, this.canvas.height))
}
}
render() {
this.offCtx.fillStyle = 'rgba(15, 15, 35, 0.1)'
this.offCtx.fillRect(0, 0, this.offscreen.width, this.offscreen.height)
this.particles.forEach(p => {
p.update(this.canvas.width, this.canvas.height)
const size = Math.round(p.size)
const hue = Math.round(p.hue / 60) * 60
const key = `${size}-${hue}`
const cached = this.particleCache.get(key)
if (cached) {
this.offCtx.drawImage(cached, p.x - size, p.y - size)
}
})
this.ctx.drawImage(this.offscreen, 0, 0)
this.updateFPS()
}
updateFPS() {
this.frameCount++
const now = performance.now()
if (now - this.lastTime >= 1000) {
this.fps = this.frameCount
this.frameCount = 0
this.lastTime = now
}
}
}
const directCanvas = document.getElementById('directCanvas')
const offscreenCanvas = document.getElementById('offscreenCanvas')
const directStats = document.getElementById('directStats')
const offscreenStats = document.getElementById('offscreenStats')
const directRenderer = new DirectRenderer(directCanvas)
const offscreenRenderer = new OffscreenRenderer(offscreenCanvas)
let particleCount = 1000
let isRunning = true
let animationId = null
function init() {
directRenderer.init(particleCount)
offscreenRenderer.init(particleCount)
}
function animate() {
if (!isRunning) return
directRenderer.render()
offscreenRenderer.render()
directStats.textContent = `FPS: ${directRenderer.fps}`
offscreenStats.textContent = `FPS: ${offscreenRenderer.fps}`
animationId = requestAnimationFrame(animate)
}
function toggleAnimation() {
isRunning = !isRunning
if (isRunning) {
animate()
} else {
cancelAnimationFrame(animationId)
}
}
function changeParticleCount(count) {
particleCount = count
init()
}
init()
animate()
</script>
</body>
</html>
const offscreenUseCases = {
recommended: [
{
scenario: '静态背景',
example: '游戏地图、UI面板',
benefit: '只绘制一次,大幅减少重绘'
},
{
scenario: '复杂图形',
example: '渐变、阴影、复杂路径',
benefit: '预渲染避免重复计算'
},
{
scenario: '精灵动画',
example: '角色动画、特效',
benefit: '快速切换预渲染帧'
},
{
scenario: '文本渲染',
example: '大量相同文本',
benefit: '避免字体解析开销'
}
],
notRecommended: [
{
scenario: '频繁变化的内容',
example: '实时数据图表',
reason: '每次变化都需要重新渲染'
},
{
scenario: '简单图形',
example: '少量矩形、圆形',
reason: '开销大于收益'
},
{
scenario: '全屏动态效果',
example: '全屏粒子',
reason: '无法利用局部更新优势'
}
]
}
class OffscreenCacheManager {
constructor(maxMemory = 100 * 1024 * 1024) {
this.cache = new Map()
this.maxMemory = maxMemory
this.currentMemory = 0
}
add(key, offscreen, priority = 0) {
const size = offscreen.width * offscreen.height * 4
if (this.currentMemory + size > this.maxMemory) {
this.evict(size)
}
this.cache.set(key, {
offscreen,
size,
priority,
lastAccess: Date.now()
})
this.currentMemory += size
}
get(key) {
const item = this.cache.get(key)
if (item) {
item.lastAccess = Date.now()
return item.offscreen
}
return null
}
evict(requiredSize) {
const items = Array.from(this.cache.entries())
.sort((a, b) => {
if (a[1].priority !== b[1].priority) {
return a[1].priority - b[1].priority
}
return a[1].lastAccess - b[1].lastAccess
})
let freed = 0
for (const [key, item] of items) {
this.cache.delete(key)
this.currentMemory -= item.size
freed += item.size
if (freed >= requiredSize) break
}
return freed
}
remove(key) {
const item = this.cache.get(key)
if (item) {
this.cache.delete(key)
this.currentMemory -= item.size
}
}
clear() {
this.cache.clear()
this.currentMemory = 0
}
getStats() {
return {
items: this.cache.size,
memory: this.currentMemory,
maxMemory: this.maxMemory,
utilization: (this.currentMemory / this.maxMemory * 100).toFixed(2) + '%'
}
}
}