离屏渲染

深入学习离屏Canvas技术,通过预渲染提升Canvas应用性能 离屏Canvas(Offscreen Canvas)是一种重要的性能优化技术,通过在内存中预渲染内容,减少主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 }
  }
}

Web Worker 中的离屏渲染

Worker 离屏渲染

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()
  }
}

Worker 脚本示例

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>

最佳实践

何时使用离屏Canvas

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) + '%'
    }
  }
}