three canvas layers — only what's dirty repaints

2026-05-05

part 1 ended with a single boolean — requestedFrame — deciding whether the whole renderer paints this frame. that's not the whole story. the renderer doesn't actually paint into a single canvas. it stacks three on top of each other, and each one has its own dirty bit. when you drag a node, only one of the three repaints. that's why a 10,000-element graph stays smooth under your cursor.

three visible layers

the canvases are declared as numeric constants in canvas/index.mjs:

CRp.SELECT_BOX = 0;
CRp.DRAG       = 1;
CRp.NODE       = 2;
CRp.WEBGL      = 3;

CRp.CANVAS_TYPES = [ '2d', '2d', '2d', 'webgl2' ];

four constants are defined, but only three canvases are actually created in the default configuration. the WEBGL index is reserved for an opt-in fourth layer, controlled by CRp.CANVAS_LAYERS — which starts at 3 and is bumped to 4 inside the constructor when you pass webgl: true:

CRp.CANVAS_LAYERS = 3;          // line 30 — default

// …inside the constructor:
if( options.webgl ){
  CRp.CANVAS_LAYERS = r.CANVAS_LAYERS = 4;
  console.log('webgl rendering enabled');
}

so a default cytoscape({ ... }) graph has three canvases. a cytoscape({ webgl: true, ... }) graph has four. they're created in a loop and stacked at the same dom position, with z-indices chosen so that lower constants sit on top:

for( var i = 0; i < CRp.CANVAS_LAYERS; i++ ){
  var canvas = r.data.canvases[ i ] = document.createElement( 'canvas' );
  var type = CRp.CANVAS_TYPES[ i ];
  r.data.contexts[ i ] = canvas.getContext( type );

  canvas.style.position = 'absolute';
  canvas.setAttribute( 'data-id', 'layer' + i );
  canvas.style.zIndex = String( CRp.CANVAS_LAYERS - i );
  r.data.canvasContainer.appendChild( canvas );
}

zIndex = CANVAS_LAYERS - i is the trick. by default, with three layers, the stack is:

  default config (CANVAS_LAYERS = 3):
    on top  ┌─ SELECT_BOX  (i=0, z=3) ──┐  selection rect, fps counter
            ├─ DRAG        (i=1, z=2) ──┤  elements being dragged
    bottom  └─ NODE        (i=2, z=1) ──┘  everything else (static)

  with webgl: true (CANVAS_LAYERS = 4):
    on top  ┌─ SELECT_BOX  (i=0, z=4) ──┐
            ├─ DRAG        (i=1, z=3) ──┤
            ├─ NODE        (i=2, z=2) ──┤
    bottom  └─ WEBGL       (i=3, z=1) ──┘  webgl2 instanced path

the SELECT_BOX layer always sits on top because it's the most ephemeral — a selection rectangle drawn while you're dragging out a marquee, plus the fps counter when you've enabled it. the optional WEBGL layer sits underneath everything because, when it's active, it's drawing the same elements the canvas NODE layer would have drawn, just in a parallel instanced-batched way (we'll get there in part 6).

what's interesting isn't the z-stacking. it's that each layer is invalidated and repainted independently.

per-layer dirty flags

instead of one requestedFrame, the renderer keeps an array of dirty bits — one per layer. it's initialised at startup:

r.data.canvasNeedsRedraw[ i ] = false;

inside CRp.render() (over in drawing-redraw.mjs), the array gets a short alias needDraw (line 287), and each layer is its own conditional block. here's the NODE one:

if( needDraw[ r.NODE ] || drawAllLayers || drawOnlyNodeLayer || needMbClear[ r.NODE ] ){
  // pick the right context (motion-blur buffer or live canvas)…
  setContextTransform( context, clear );
  // walk z-sorted elements via the layered cache:
  r.drawLayeredElements( context, eles.nondrag, pixelRatio, extent );
  // …debug points…
  if( !drawAllLayers && !motionBlur ){
    needDraw[ r.NODE ] = false;
  }
}

three of these blocks exist, one per layer. the pattern is identical:

  1. check the dirty bit (with a few escape hatches: full-render request, motion blur clearing).
  2. if dirty: clear that one canvas, paint into it, clear only that bit.
  3. if not dirty: do nothing. the previous frame's pixels stay on screen.

so when you grab a node and start dragging, what happens? the drag code mutates position on the dragged elements, then sets needDraw[ r.DRAG ] = true. on the next frame:

  • needDraw[ r.NODE ] is still false — the static layer doesn't repaint. tens of thousands of nodes stay frozen on screen, costing zero render time.
  • needDraw[ r.DRAG ] is true — a tiny number of dragged nodes get cleared and redrawn on the DRAG layer.
  • needDraw[ r.SELECT_BOX ] flips when the marquee box updates, otherwise stays clean.

the cost of dragging scales with what you're dragging, not what's on screen. that's the entire reason cytoscape can keep dragging smooth on graphs that would otherwise melt under per-frame full-canvas redraws.

the same payoff applies to the selection box: when you marquee-select, only the SELECT_BOX layer flickers — the underlying graph stays stationary because its layer has no reason to invalidate.

the hidden buffers

three more canvases exist, but they aren't appended to the dom. they're offscreen scratch space:

CRp.TEXTURE_BUFFER         = 0;
CRp.MOTIONBLUR_BUFFER_NODE = 1;
CRp.MOTIONBLUR_BUFFER_DRAG = 2;

each one has a specific job:

bufferused bypurpose
TEXTURE_BUFFERtextureOnViewport modesnapshot of the graph used while you're pinching/panning. blurry, but instant.
MOTIONBLUR_BUFFER_NODEmotion blur on NODElow-res offscreen draw target for the static layer during gestures.
MOTIONBLUR_BUFFER_DRAGmotion blur on DRAGlow-res offscreen draw target for the drag layer during gestures.

these aren't visible. they exist so that during fast gestures the renderer can draw to a smaller, cheaper canvas first, then composite the result into the visible layers. the motion-blur buffers in particular run at motionBlurPxRatio = 0.8 — a fifth fewer pixels per axis — which the eye doesn't notice when something's moving. we'll come back to this in part 5.

why three, not one

the alternative — one canvas, repainted every frame — is the obvious thing to do, and it's what most charting libraries do. it works fine until you have a few thousand elements. then redrawing them all just because the user nudged one node becomes the bottleneck.

three canvases costs a little more memory (three rasters instead of one) and a little more setup code. in exchange you get independent invalidation domains. the cost of any single user action is bounded by the cost of repainting whatever layer that action invalidated, not by the cost of repainting the whole graph.

it's the same trick browsers play with their own compositor — dom layers are independently rasterised, and a transform on one layer doesn't repaint its siblings. cytoscape just runs that pattern at a smaller scale, with three layers it controls explicitly.

next up — part 3: the per-element sprite atlas. once a layer is dirty and needs to repaint, where do the actual pixels come from? not from re-rasterising every node's shape and label. cytoscape pre-renders elements at multiple zoom levels into an offscreen sprite atlas and drawImages them in.