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:
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:
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:
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:
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:
three of these blocks exist, one per layer. the pattern is identical:
- check the dirty bit (with a few escape hatches: full-render request, motion blur clearing).
- if dirty: clear that one canvas, paint into it, clear only that bit.
- 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 stillfalse— the static layer doesn't repaint. tens of thousands of nodes stay frozen on screen, costing zero render time.needDraw[ r.DRAG ]istrue— 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:
each one has a specific job:
| buffer | used by | purpose |
|---|---|---|
TEXTURE_BUFFER | textureOnViewport mode | snapshot of the graph used while you're pinching/panning. blurry, but instant. |
MOTIONBLUR_BUFFER_NODE | motion blur on NODE | low-res offscreen draw target for the static layer during gestures. |
MOTIONBLUR_BUFFER_DRAG | motion blur on DRAG | low-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.