分层渲染

深入学习Canvas分层渲染技术,通过多Canvas分层减少重绘区域提升性能 分层渲染是一种重要的Canvas性能优化技术,通过将不同类型的内容绘制在不同的Canvas层上,减少不必要的重绘操作。

基本概念

为什么需要分层渲染

在单Canvas架构中,任何内容的变化都需要重绘整个Canvas。分层渲染通过将内容分配到不同的层,只重绘发生变化的层,从而提升性能。

class LayeredCanvas {
  constructor(container, options = {}) {
    this.container = typeof container === 'string' 
      ? document.querySelector(container) 
      : container
    
    this.options = {
      width: 800,
      height: 600,
      layers: ['background', 'main', 'ui'],
      ...options
    }
    
    this.layers = new Map()
    this.layerOrder = []
    
    this.init()
  }
  
  init() {
    this.options.layers.forEach((name, index) => {
      this.createLayer(name, index)
    })
  }
  
  createLayer(name, zIndex) {
    const canvas = document.createElement('canvas')
    canvas.width = this.options.width
    canvas.height = this.options.height
    canvas.style.cssText = `
      position: absolute;
      top: 0;
      left: 0;
      z-index: ${zIndex};
      pointer-events: ${name === 'ui' ? 'auto' : 'none'};
    `
    
    this.container.style.position = 'relative'
    this.container.appendChild(canvas)
    
    this.layers.set(name, {
      canvas,
      ctx: canvas.getContext('2d'),
      zIndex,
      visible: true,
      dirty: true
    })
    
    this.layerOrder.push(name)
  }
  
  getLayer(name) {
    return this.layers.get(name)
  }
  
  getContext(name) {
    const layer = this.layers.get(name)
    return layer ? layer.ctx : null
  }
  
  markDirty(name) {
    const layer = this.layers.get(name)
    if (layer) {
      layer.dirty = true
    }
  }
  
  clearLayer(name) {
    const layer = this.layers.get(name)
    if (layer) {
      layer.ctx.clearRect(0, 0, layer.canvas.width, layer.canvas.height)
      layer.dirty = true
    }
  }
  
  setLayerVisibility(name, visible) {
    const layer = this.layers.get(name)
    if (layer) {
      layer.visible = visible
      layer.canvas.style.display = visible ? 'block' : 'none'
    }
  }
  
  resize(width, height) {
    this.options.width = width
    this.options.height = height
    
    this.layers.forEach(layer => {
      layer.canvas.width = width
      layer.canvas.height = height
      layer.dirty = true
    })
  }
  
  destroy() {
    this.layers.forEach(layer => {
      layer.canvas.remove()
    })
    this.layers.clear()
    this.layerOrder = []
  }
}

分层策略

按更新频率分层

class FrequencyBasedLayering {
  constructor(container) {
    this.layeredCanvas = new LayeredCanvas(container, {
      layers: ['static', 'dynamic', 'overlay']
    })
    
    this.staticLayer = this.layeredCanvas.getLayer('static')
    this.dynamicLayer = this.layeredCanvas.getLayer('dynamic')
    this.overlayLayer = this.layeredCanvas.getLayer('overlay')
    
    this.staticContent = []
    this.dynamicContent = []
    this.overlayContent = []
    
    this.staticRendered = false
  }
  
  addStaticContent(renderFn) {
    this.staticContent.push(renderFn)
    this.staticRendered = false
  }
  
  addDynamicContent(renderFn) {
    this.dynamicContent.push(renderFn)
  }
  
  addOverlayContent(renderFn) {
    this.overlayContent.push(renderFn)
  }
  
  render() {
    if (!this.staticRendered) {
      this.renderStatic()
      this.staticRendered = true
    }
    
    this.renderDynamic()
    this.renderOverlay()
  }
  
  renderStatic() {
    const ctx = this.staticLayer.ctx
    ctx.clearRect(0, 0, this.staticLayer.canvas.width, this.staticLayer.canvas.height)
    
    this.staticContent.forEach(renderFn => renderFn(ctx))
  }
  
  renderDynamic() {
    const ctx = this.dynamicLayer.ctx
    ctx.clearRect(0, 0, this.dynamicLayer.canvas.width, this.dynamicLayer.canvas.height)
    
    this.dynamicContent.forEach(renderFn => renderFn(ctx))
  }
  
  renderOverlay() {
    const ctx = this.overlayLayer.ctx
    ctx.clearRect(0, 0, this.overlayLayer.canvas.width, this.overlayLayer.canvas.height)
    
    this.overlayContent.forEach(renderFn => renderFn(ctx))
  }
  
  invalidateStatic() {
    this.staticRendered = false
  }
}

按功能分层

class GameLayerManager {
  constructor(container) {
    this.layeredCanvas = new LayeredCanvas(container, {
      layers: ['background', 'terrain', 'entities', 'effects', 'ui']
    })
    
    this.initLayers()
  }
  
  initLayers() {
    this.layers = {
      background: this.layeredCanvas.getContext('background'),
      terrain: this.layeredCanvas.getContext('terrain'),
      entities: this.layeredCanvas.getContext('entities'),
      effects: this.layeredCanvas.getContext('effects'),
      ui: this.layeredCanvas.getContext('ui')
    }
  }
  
  renderBackground(drawFn) {
    const ctx = this.layers.background
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
    drawFn(ctx)
  }
  
  renderTerrain(drawFn) {
    const ctx = this.layers.terrain
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
    drawFn(ctx)
  }
  
  renderEntities(entities) {
    const ctx = this.layers.entities
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
    
    entities.forEach(entity => {
      entity.render(ctx)
    })
  }
  
  renderEffects(effects) {
    const ctx = this.layers.effects
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
    
    effects.forEach(effect => {
      effect.render(ctx)
    })
  }
  
  renderUI(ui) {
    const ctx = this.layers.ui
    ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
    
    ui.forEach(element => {
      element.render(ctx)
    })
  }
}

视差滚动

多层视差效果

class ParallaxLayer {
  constructor(canvas, options = {}) {
    this.canvas = canvas
    this.ctx = canvas.getContext('2d')
    
    this.options = {
      speedFactor: 1,
      direction: 1,
      repeat: true,
      ...options
    }
    
    this.scrollX = 0
    this.scrollY = 0
    this.content = null
    this.contentWidth = 0
    this.contentHeight = 0
  }
  
  setContent(drawable, width, height) {
    this.content = drawable
    this.contentWidth = width
    this.contentHeight = height
  }
  
  update(deltaX, deltaY) {
    this.scrollX += deltaX * this.options.speedFactor * this.options.direction
    this.scrollY += deltaY * this.options.speedFactor * this.options.direction
  }
  
  render() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    
    if (!this.content) return
    
    if (this.options.repeat) {
      this.renderRepeating()
    } else {
      this.renderSingle()
    }
  }
  
  renderRepeating() {
    const startX = this.scrollX % this.contentWidth - this.contentWidth
    const startY = this.scrollY % this.contentHeight - this.contentHeight
    
    for (let x = startX; x < this.canvas.width + this.contentWidth; x += this.contentWidth) {
      for (let y = startY; y < this.canvas.height + this.contentHeight; y += this.contentHeight) {
        this.ctx.drawImage(this.content, x, y)
      }
    }
  }
  
  renderSingle() {
    this.ctx.drawImage(this.content, -this.scrollX, -this.scrollY)
  }
}

class ParallaxScene {
  constructor(container) {
    this.container = container
    this.layers = []
    this.canvasWidth = 0
    this.canvasHeight = 0
    
    this.init()
  }
  
  init() {
    const rect = this.container.getBoundingClientRect()
    this.canvasWidth = rect.width
    this.canvasHeight = rect.height
  }
  
  addLayer(options = {}) {
    const canvas = document.createElement('canvas')
    canvas.width = this.canvasWidth
    canvas.height = this.canvasHeight
    canvas.style.cssText = 'position: absolute; top: 0; left: 0;'
    
    this.container.style.position = 'relative'
    this.container.appendChild(canvas)
    
    const layer = new ParallaxLayer(canvas, options)
    this.layers.push(layer)
    
    this.updateLayerOrder()
    
    return layer
  }
  
  updateLayerOrder() {
    this.layers.forEach((layer, index) => {
      layer.canvas.style.zIndex = index
    })
  }
  
  update(deltaX, deltaY) {
    this.layers.forEach(layer => {
      layer.update(deltaX, deltaY)
      layer.render()
    })
  }
  
  resize(width, height) {
    this.canvasWidth = width
    this.canvasHeight = height
    
    this.layers.forEach(layer => {
      layer.canvas.width = width
      layer.canvas.height = height
      layer.render()
    })
  }
}

层间通信

事件穿透

class InteractiveLayerManager {
  constructor(container) {
    this.layeredCanvas = new LayeredCanvas(container, {
      layers: ['background', 'game', 'ui']
    })
    
    this.eventHandlers = new Map()
    this.interactiveObjects = new Map()
    
    this.setupEvents()
  }
  
  setupEvents() {
    const container = this.layeredCanvas.container
    
    container.addEventListener('click', (e) => this.handleClick(e))
    container.addEventListener('mousemove', (e) => this.handleMouseMove(e))
    container.addEventListener('mousedown', (e) => this.handleMouseDown(e))
    container.addEventListener('mouseup', (e) => this.handleMouseUp(e))
  }
  
  getCanvasCoordinates(e, canvas) {
    const rect = canvas.getBoundingClientRect()
    return {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top
    }
  }
  
  handleClick(e) {
    const uiLayer = this.layeredCanvas.getLayer('ui')
    const coords = this.getCanvasCoordinates(e, uiLayer.canvas)
    
    if (this.checkUIInteraction(coords)) {
      return
    }
    
    const gameLayer = this.layeredCanvas.getLayer('game')
    const gameCoords = this.getCanvasCoordinates(e, gameLayer.canvas)
    this.checkGameInteraction(gameCoords)
  }
  
  handleMouseMove(e) {
    const gameLayer = this.layeredCanvas.getLayer('game')
    const coords = this.getCanvasCoordinates(e, gameLayer.canvas)
    
    this.interactiveObjects.forEach((obj, key) => {
      if (obj.layer === 'game' && obj.containsPoint(coords.x, coords.y)) {
        obj.onHover?.(coords)
      }
    })
  }
  
  handleMouseDown(e) {
    const gameLayer = this.layeredCanvas.getLayer('game')
    const coords = this.getCanvasCoordinates(e, gameLayer.canvas)
    
    this.interactiveObjects.forEach((obj, key) => {
      if (obj.layer === 'game' && obj.containsPoint(coords.x, coords.y)) {
        obj.onPress?.(coords)
      }
    })
  }
  
  handleMouseUp(e) {
    const gameLayer = this.layeredCanvas.getLayer('game')
    const coords = this.getCanvasCoordinates(e, gameLayer.canvas)
    
    this.interactiveObjects.forEach((obj, key) => {
      if (obj.layer === 'game') && obj.containsPoint(coords.x, coords.y)) {
        obj.onRelease?.(coords)
      }
    })
  }
  
  checkUIInteraction(coords) {
    let handled = false
    
    this.interactiveObjects.forEach((obj, key) => {
      if (obj.layer === 'ui' && obj.containsPoint(coords.x, coords.y)) {
        obj.onClick?.(coords)
        handled = true
      }
    })
    
    return handled
  }
  
  checkGameInteraction(coords) {
    this.interactiveObjects.forEach((obj, key) => {
      if (obj.layer === 'game' && obj.containsPoint(coords.x, coords.y)) {
        obj.onClick?.(coords)
      }
    })
  }
  
  registerInteractiveObject(key, obj) {
    this.interactiveObjects.set(key, obj)
  }
  
  unregisterInteractiveObject(key) {
    this.interactiveObjects.delete(key)
  }
}

实战示例

游戏场景分层

<!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;
    }
    
    .game-container {
      position: relative;
      width: 800px;
      height: 500px;
      margin: 0 auto;
      border-radius: 8px;
      overflow: hidden;
      box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
    }
    
    .game-container canvas {
      position: absolute;
      top: 0;
      left: 0;
    }
    
    .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;
    }
    
    .info {
      color: #aaa;
      text-align: center;
      margin-top: 10px;
      font-size: 14px;
    }
  </style>
</head>
<body>
  <div class="container">
    <h1>游戏场景分层渲染</h1>
    
    <div class="game-container" id="gameContainer"></div>
    
    <div class="controls">
      <button onclick="toggleAnimation()">暂停/继续</button>
      <button onclick="addEffect()">添加特效</button>
      <button onclick="toggleUI()">显示/隐藏UI</button>
    </div>
    
    <div class="info">使用方向键移动角色</div>
  </div>
  
  <script>
    class GameScene {
      constructor(container) {
        this.container = container
        this.width = 800
        this.height = 500
        
        this.layers = {}
        this.createLayers()
        
        this.player = {
          x: this.width / 2,
          y: this.height / 2,
          width: 40,
          height: 40,
          speed: 5,
          color: '#e94560'
        }
        
        this.enemies = []
        this.effects = []
        this.stars = []
        
        this.keys = {}
        this.isRunning = true
        this.uiVisible = true
        
        this.score = 0
        this.time = 0
        
        this.init()
      }
      
      createLayers() {
        const layerNames = ['background', 'game', 'effects', 'ui']
        
        layerNames.forEach((name, index) => {
          const canvas = document.createElement('canvas')
          canvas.width = this.width
          canvas.height = this.height
          canvas.style.zIndex = index
          canvas.style.pointerEvents = name === 'ui' ? 'auto' : 'none'
          
          this.container.appendChild(canvas)
          this.layers[name] = {
            canvas,
            ctx: canvas.getContext('2d'),
            dirty: true
          }
        })
      }
      
      init() {
        this.createStars()
        this.createEnemies()
        this.setupInput()
        this.renderBackground()
      }
      
      createStars() {
        for (let i = 0; i < 100; i++) {
          this.stars.push({
            x: Math.random() * this.width,
            y: Math.random() * this.height,
            size: Math.random() * 2 + 1,
            speed: Math.random() * 0.5 + 0.1,
            brightness: Math.random()
          })
        }
      }
      
      createEnemies() {
        for (let i = 0; i < 5; i++) {
          this.enemies.push({
            x: Math.random() * (this.width - 30),
            y: Math.random() * (this.height - 30),
            width: 30,
            height: 30,
            vx: (Math.random() - 0.5) * 3,
            vy: (Math.random() - 0.5) * 3,
            color: '#4ecdc4'
          })
        }
      }
      
      setupInput() {
        document.addEventListener('keydown', (e) => {
          this.keys[e.key] = true
        })
        
        document.addEventListener('keyup', (e) => {
          this.keys[e.key] = false
        })
      }
      
      update() {
        this.time += 1/60
        
        if (this.keys['ArrowLeft']) this.player.x -= this.player.speed
        if (this.keys['ArrowRight']) this.player.x += this.player.speed
        if (this.keys['ArrowUp']) this.player.y -= this.player.speed
        if (this.keys['ArrowDown']) this.player.y += this.player.speed
        
        this.player.x = Math.max(0, Math.min(this.width - this.player.width, this.player.x))
        this.player.y = Math.max(0, Math.min(this.height - this.player.height, this.player.y))
        
        this.enemies.forEach(enemy => {
          enemy.x += enemy.vx
          enemy.y += enemy.vy
          
          if (enemy.x <= 0 || enemy.x >= this.width - enemy.width) enemy.vx *= -1
          if (enemy.y <= 0 || enemy.y >= this.height - enemy.height) enemy.vy *= -1
        })
        
        this.stars.forEach(star => {
          star.y += star.speed
          if (star.y > this.height) {
            star.y = 0
            star.x = Math.random() * this.width
          }
          star.brightness = 0.5 + Math.sin(this.time * 3 + star.x) * 0.5
        })
        
        this.effects = this.effects.filter(effect => {
          effect.life -= 1/60
          effect.particles.forEach(p => {
            p.x += p.vx
            p.y += p.vy
            p.alpha -= 0.02
          })
          return effect.life > 0
        })
      }
      
      renderBackground() {
        const ctx = this.layers.background.ctx
        const gradient = ctx.createLinearGradient(0, 0, 0, this.height)
        gradient.addColorStop(0, '#0f0f23')
        gradient.addColorStop(1, '#1a1a2e')
        ctx.fillStyle = gradient
        ctx.fillRect(0, 0, this.width, this.height)
      }
      
      renderGame() {
        const ctx = this.layers.game.ctx
        ctx.clearRect(0, 0, this.width, this.height)
        
        this.stars.forEach(star => {
          ctx.fillStyle = `rgba(255, 255, 255, ${star.brightness})`
          ctx.beginPath()
          ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2)
          ctx.fill()
        })
        
        this.enemies.forEach(enemy => {
          ctx.fillStyle = enemy.color
          ctx.fillRect(enemy.x, enemy.y, enemy.width, enemy.height)
          
          ctx.fillStyle = '#fff'
          ctx.fillRect(enemy.x + 5, enemy.y + 8, 6, 6)
          ctx.fillRect(enemy.x + 19, enemy.y + 8, 6, 6)
        })
        
        ctx.fillStyle = this.player.color
        ctx.fillRect(this.player.x, this.player.y, this.player.width, this.player.height)
        
        ctx.fillStyle = '#fff'
        ctx.beginPath()
        ctx.arc(this.player.x + 12, this.player.y + 15, 5, 0, Math.PI * 2)
        ctx.arc(this.player.x + 28, this.player.y + 15, 5, 0, Math.PI * 2)
        ctx.fill()
        
        ctx.fillStyle = '#333'
        ctx.beginPath()
        ctx.arc(this.player.x + 12, this.player.y + 15, 2, 0, Math.PI * 2)
        ctx.arc(this.player.x + 28, this.player.y + 15, 2, 0, Math.PI * 2)
        ctx.fill()
      }
      
      renderEffects() {
        const ctx = this.layers.effects.ctx
        ctx.clearRect(0, 0, this.width, this.height)
        
        this.effects.forEach(effect => {
          effect.particles.forEach(p => {
            ctx.fillStyle = `rgba(${p.color}, ${p.alpha})`
            ctx.beginPath()
            ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
            ctx.fill()
          })
        })
      }
      
      renderUI() {
        const ctx = this.layers.ui.ctx
        ctx.clearRect(0, 0, this.width, this.height)
        
        if (!this.uiVisible) return
        
        ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
        ctx.fillRect(10, 10, 150, 70)
        
        ctx.fillStyle = '#fff'
        ctx.font = '16px Arial'
        ctx.fillText(`分数: ${this.score}`, 20, 35)
        ctx.fillText(`时间: ${this.time.toFixed(1)}s`, 20, 60)
        
        ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
        ctx.fillRect(this.width - 110, 10, 100, 40)
        
        ctx.fillStyle = '#4ecdc4'
        ctx.font = '14px Arial'
        ctx.fillText('按空格暂停', this.width - 100, 35)
      }
      
      render() {
        this.renderGame()
        this.renderEffects()
        this.renderUI()
      }
      
      addEffect() {
        const effect = {
          life: 1,
          particles: []
        }
        
        for (let i = 0; i < 20; i++) {
          effect.particles.push({
            x: this.player.x + this.player.width / 2,
            y: this.player.y + this.player.height / 2,
            vx: (Math.random() - 0.5) * 8,
            vy: (Math.random() - 0.5) * 8,
            size: Math.random() * 4 + 2,
            color: Math.random() > 0.5 ? '233, 69, 96' : '78, 205, 196',
            alpha: 1
          })
        }
        
        this.effects.push(effect)
        this.score += 10
      }
      
      toggleUI() {
        this.uiVisible = !this.uiVisible
      }
      
      toggle() {
        this.isRunning = !this.isRunning
      }
      
      loop() {
        if (this.isRunning) {
          this.update()
          this.render()
        }
        requestAnimationFrame(() => this.loop())
      }
      
      start() {
        this.loop()
      }
    }
    
    const game = new GameScene(document.getElementById('gameContainer'))
    game.start()
    
    function toggleAnimation() {
      game.toggle()
    }
    
    function addEffect() {
      game.addEffect()
    }
    
    function toggleUI() {
      game.toggleUI()
    }
  </script>
</body>
</html>

最佳实践

分层原则

const layeringPrinciples = {
  byUpdateFrequency: {
    description: '按更新频率分层',
    layers: [
      { name: 'static', update: 'never', example: '背景、地图' },
      { name: 'rare', update: '偶尔', example: '地形变化' },
      { name: 'frequent', update: '每帧', example: '角色、粒子' },
      { name: 'overlay', update: '按需', example: 'UI、提示' }
    ]
  },
  
  byFunction: {
    description: '按功能分层',
    layers: [
      { name: 'background', purpose: '静态背景' },
      { name: 'terrain', purpose: '地形/地图' },
      { name: 'entities', purpose: '游戏对象' },
      { name: 'effects', purpose: '特效' },
      { name: 'ui', purpose: '用户界面' }
    ]
  },
  
  byInteraction: {
    description: '按交互性分层',
    layers: [
      { name: 'non-interactive', pointerEvents: 'none' },
      { name: 'interactive', pointerEvents: 'auto' }
    ]
  }
}

性能监控

class LayerPerformanceMonitor {
  constructor(layeredCanvas) {
    this.layeredCanvas = layeredCanvas
    this.metrics = new Map()
  }
  
  startMeasure(layerName) {
    if (!this.metrics.has(layerName)) {
      this.metrics.set(layerName, {
        renderTime: 0,
        renderCount: 0,
        avgTime: 0
      })
    }
    
    this.metrics.get(layerName).startTime = performance.now()
  }
  
  endMeasure(layerName) {
    const metric = this.metrics.get(layerName)
    if (metric && metric.startTime) {
      const elapsed = performance.now() - metric.startTime
      metric.renderTime += elapsed
      metric.renderCount++
      metric.avgTime = metric.renderTime / metric.renderCount
    }
  }
  
  getReport() {
    const report = []
    this.metrics.forEach((metric, name) => {
      report.push({
        layer: name,
        avgRenderTime: `${metric.avgTime.toFixed(2)}ms`,
        totalRenders: metric.renderCount
      })
    })
    return report
  }
}