Developer

Cartoon Scene Rendering with Canvas 2D: Outlines, Cel Shading, and 60fps Animation

The scenes in Camera Simulator are drawn entirely with Canvas 2D — no image assets, no SVG, no external libraries. Just ctx.fillStyle, ctx.beginPath(), and math. Here's the system that makes five animated scenes look like polished cartoon illustrations at 60fps.

The outline trick

Real cel-shading uses a separate render pass for outlines. In Canvas 2D, the trick is simpler: draw the filled shape, then stroke() on top. The key is that stroke() operates on whatever path is currently active — so you don't need to re-specify it.

typescript
function outline(
  ctx: CanvasRenderingContext2D,
  color = '#1a1a2e',
  width = 3,
) {
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.stroke(); // strokes the current active path
}

// Usage:
ctx.fillStyle = '#4caf50';
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
outline(ctx, '#2e7d32', 3); // outlines the same circle

Cel shading: the three-layer trick

Real lighting is expensive. Cel shading fakes it with three concentric layers: a dark shadow shape offset slightly down-right, the main flat-colored shape, and a bright highlight dot in the top-left. That's the entire technique.

typescript
// Shadow layer (offset, darker)
ctx.fillStyle = '#2d6e26';
ctx.beginPath();
ctx.arc(x + r * 0.1, y + r * 0.1, r * 0.88, 0, Math.PI * 2);
ctx.fill();

// Main lit layer
ctx.fillStyle = '#4caf50';
ctx.beginPath();
ctx.arc(x, y, r * 0.88, 0, Math.PI * 2);
ctx.fill();
outline(ctx, '#2e7d32', 3);

// Highlight dot (top-left)
ctx.fillStyle = '#81c784';
ctx.beginPath();
ctx.arc(x - r * 0.28, y - r * 0.28, r * 0.3, 0, Math.PI * 2);
ctx.fill();

Background caching for performance

Static elements (sky, hills, buildings, floor) are expensive to redraw every frame but never change. Cache them on a secondary offscreen canvas and stamp them with a single drawImage() call each frame. Only the animated elements (rotors, cars, water streams) are redrawn.

typescript
const _bgCache = new Map<string, HTMLCanvasElement>();

function ensureBg(
  id: string, W: number, H: number,
  paint: (ctx: CanvasRenderingContext2D, W: number, H: number) => void,
): HTMLCanvasElement {
  const key = `${id}:${W}x${H}`;
  if (_bgCache.has(key)) return _bgCache.get(key)!;

  const c = document.createElement('canvas');
  c.width = W; c.height = H;
  paint(c.getContext('2d')!, W, H);
  _bgCache.set(key, c);
  return c;
}

// Each frame — background is free:
ctx.drawImage(ensureBg('helicopter', W, H, paintBg), 0, 0);
// then only draw moving elements on top

This dropped the helicopter scene from ~4ms/frame to ~0.3ms/frame for the background portion — a 13× speedup that keeps the whole pipeline comfortably under the 16ms frame budget even on mid-range phones.

Fake perspective with a single scale

Racing cars in the background lane should appear smaller than cars in the foreground. Setting up a full perspective matrix is overkill for a 2D canvas. One multiply is enough:

typescript
for (const car of RACE_CARS) {
  const x = ((car.phase * W + t * car.speed) % (W + 220)) - 220;
  const y = H * car.lane;

  // cars in far lane (lower y) get scale ~0.85
  // cars in near lane (higher y) get scale ~1.0
  const scale = 0.85 + (car.lane - 0.44) * 0.28;

  ctx.save();
  ctx.translate(x, y);
  ctx.scale(scale, scale);
  drawCar(ctx, car);
  ctx.restore();
}

Animating water streams

The waterfall is the trickiest scene. Each stream is a column of rounded rectangles scrolling downward at slightly different speeds, with a bright highlight stripe on the leading edge. Offsetting each stream by its index creates the natural variation of a real waterfall.

typescript
for (let i = 0; i < numStreams; i++) {
  const speed = 380 + Math.sin(i * 1.8) * 100; // vary per stream
  const offset = (t * speed + (i / numStreams) * totalHeight) % totalHeight;

  ctx.fillStyle = `rgba(144,202,249,${0.55 + Math.sin(i * 1.3) * 0.2})`;

  for (let seg = 0; seg < 5; seg++) {
    const sy = (offset + seg * segHeight) % totalHeight;
    roundRect(ctx, sx, sy, streamWidth, segHeight, streamWidth * 0.3);
    ctx.fill();
  }
}
All five scenes are live at camerasimulator.online/emulator — open DevTools and watch the canvas render in real time while you adjust the shutter speed.
Try it yourself

Everything described in this article is visible in real time in the free Camera Simulator. No signup, no install — works in any browser.

Launch Simulator →

More articles