javascripthtmlcsshtml5-canvasrequestanimationframe

How can I optimize my full-screen rain animation to balance GPU/CPU load and maintain control?


I'm developing a full-screen rain animation using HTML, CSS, and JavaScript. I use a single <canvas> element with a requestAnimationFrame loop to animate 500 raindrops. Despite this, my GPU memory usage remains high and load times increase at high FPS. Below is a minimal code snippet that reproduces the issue:

const canvas = document.getElementById('rainCanvas');
const ctx = canvas.getContext('2d');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;

const numDrops = 500;
const drops = [];
for (let i = 0; i < numDrops; i++) {
  drops.push({
    x: Math.random() * canvas.width,
    y: Math.random() * canvas.height,
    speed: 2 + Math.random() * 4,
    length: 10 + Math.random() * 10
  });
}

function drawRain() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  ctx.strokeStyle = "rgba(255, 255, 255, 0.5)";
  ctx.lineWidth = 2;
  
  drops.forEach(drop => {
    ctx.beginPath();
    ctx.moveTo(drop.x, drop.y);
    ctx.lineTo(drop.x, drop.y + drop.length);
    ctx.stroke();
    drop.y += drop.speed;
    if (drop.y > canvas.height) {
      drop.y = -drop.length;
    }
  });
  
  requestAnimationFrame(drawRain);
}

drawRain();
body, 
html { 
  margin: 0; 
  padding: 0; 
  overflow: hidden; 
  background-color: black;
}

canvas { 
  display: block; 
}
<canvas id="rainCanvas"></canvas>

How can I optimize this canvas-based rain animation to reduce GPU load while maintaining smooth performance?


Solution

  • Avoid GPU state changes

    The only space for improvement is in the render loop.

    The general rule of thumb is to avoid GPU state changes. Calls to ctx.stroke and ctx.fill force GPU state changes.

    This means that data (style and path to stroke) is moved from the CPU to the GPU.

    As all the strokes in your code are the same style you can render the whole scene in one call to ctx.stroke.

    Code Changes

    Thus the change around the inner loop would be as follows

      ctx.beginPath();  // ADDED. Do only once per loop
      drops.forEach(drop => {
    
        // REMOVED ctx.beginPath();
        ctx.moveTo(drop.x, drop.y);
        ctx.lineTo(drop.x, drop.y + drop.length);
    
        // REMOVED ctx.stroke();
        drop.y += drop.speed;
        if (drop.y > canvas.height) { drop.y = -drop.length }
      });
      ctx.stroke(); // ADDED. Do once for all rain drops
    

    Depending on the device, setup, GPU and number of drops this can provide a significant performance boost.

    Note that if each stroke needed a different color/style you can group strokes with the same style and render each group with only one draw call (stroke and or fill)