motion blur, culling, path caching: how the canvas path handles pressure

2026-05-16

series
cytoscape renderer
part 5 of 5

the two-level cache stack from parts 3 and 4 handles the steady state: sprites and composite layers make pan and zoom fast when the graph isn't changing. but the renderer still needs answers for pressure: the user pinching the viewport, most nodes sitting off-screen, a thousand nodes sharing identical style, and a machine with time to spare. five targeted tricks cover those cases. they don't interact; each activates at a distinct moment in the renderer's session.

while you're gesturing

textureOnViewport

at the top of every render() call, one boolean decides whether drawing happens at all:

var textureDraw = r.textureOnViewport && !forcedContext && (r.pinching || r.hoverData.dragging || r.swipePanning || r.data.wheelZooming);

when any of those gesture flags is set, textureDraw is true. the renderer skips all element draws, cache lookups, and layer blitting. instead it renders once into a hidden TEXTURE_BUFFER canvas on the first gesture frame, then blits that snapshot each subsequent frame at the current transform:

if( textureDraw ){
  r.textureDrawLastFrame = true;

  if( !r.textureCache ){
    r.textureCache = {};
    r.textureCache.texture = r.data.bufferCanvases[ r.TEXTURE_BUFFER ];

    var cxt = r.data.bufferContexts[ r.TEXTURE_BUFFER ];
    cxt.setTransform( 1, 0, 0, 1, 0, 0 );
    cxt.clearRect( 0, 0, r.canvasWidth * r.textureMult, r.canvasHeight * r.textureMult );

    r.render( {
      forcedContext: cxt,
      drawOnlyNodeLayer: true,
      forcedPxRatio: pixelRatio * r.textureMult
    } );

    var vp = r.textureCache.viewport = { zoom: cy.zoom(), pan: cy.pan(), /* ... */ };
  }

  // ...

  context.drawImage( texture, vp.mpan.x, vp.mpan.y, vp.width / vp.zoom / pixelRatio, vp.height / vp.zoom / pixelRatio );
}

r.textureCache is null until the first gesture frame: that's when the snapshot is captured via a recursive r.render() call into the buffer. every subsequent gesture frame skips the inner block and goes straight to drawImage. when the gesture ends, r.textureCache = null resets it. the result is slightly blurry at the extremes of a pinch, but the user isn't scrutinising pixels while their hand is moving.

motion blur

during element drag, the renderer doesn't stop drawing. it blurs. instead of fully clearing the canvas before each frame, it partially fades the previous frame using destination-out:

function mbclear( context, x, y, w, h ){
  var gco = context.globalCompositeOperation;

  context.globalCompositeOperation = 'destination-out';
  r.colorFillStyle( context, 255, 255, 255, r.motionBlurTransparency );
  context.fillRect( x, y, w, h );

  context.globalCompositeOperation = gco;
}

destination-out erases existing canvas content proportionally to the fill's alpha. motionBlurTransparency = 1 - motionBlurOpacity. with the default opacity of 0.2, each frame fades the canvas by 0.8, preserving 20% of the previous frame. the new frame draws on top of that residue. nodes leave a short trail as they drag. the ghost hides the fact that the moved elements are being re-rendered every frame at normal quality. the blur sells the illusion that motion is fast.

  pinching / swipePanning / wheelZooming
  -> textureDraw = true
  -> frame 1: render to TEXTURE_BUFFER once
  -> frame 2+: drawImage(snapshot) at current transform
  -> gesture ends: textureCache = null, full draw resumes

  dragging elements
  -> motionBlur = true
  -> each frame: mbclear() fades canvas at motionBlurTransparency
  -> new frame draws on top of faded residue
  -> 100ms after drag ends: motionBlur = false, full clear resumes

while a frame draws

viewport culling

drawCachedElement, the function that blits one element's sprite, has a guard before any draw work:

let bb = ele.boundingBox();
let reason = requestHighQuality === true ? eleTxrCache.reasons.highQuality : null;

if( bb.w === 0 || bb.h === 0 || !ele.visible() ){ return; }

if( !extent || math.boundingBoxesIntersect( bb, extent ) ){
  let isEdge = ele.isEdge();
  // ...draw...
}

extent is the viewport bounding box, computed once per frame in render(). elements whose bounding box doesn't intersect the viewport skip all draw work: no sprite lookup, no drawImage, no path submission. on a graph where most content is panned off-screen, the vast majority of elements exit here at zero cost.

Path2D caching

node shape paths (arc segments, corner radii, polygon points) are expensive to construct. drawNodePath caches the result as a Path2D object keyed by shape parameters:

let getPath = (width, height, shape, points) => {
  let pathCache = r.nodePathCache = r.nodePathCache || [];

  let key = util.hashStrings(
    shape === 'polygon' ? shape + ',' + points.join(',') : shape,
    '' + height,
    '' + width,
    '' + cornerRadius
  );

  let cachedPath = pathCache[ key ];
  let path;
  let cacheHit = false;

  if( cachedPath != null ){
    path = cachedPath;
    cacheHit = true;
    rs.pathCache = path;
  } else {
    path = new Path2D();
    pathCache[ key ] = rs.pathCache = path;
  }

  return { path, cacheHit };
};

the key is (shape, height, width, cornerRadius). a graph where every node is a 60×40 rounded rectangle has exactly one Path2D in nodePathCache. every node reuses it: ctx.fill(path) submits pre-built GPU path data rather than re-issuing arc and line commands each frame.

style keys

the element texture cache shares atlas slots across nodes with identical appearance. each element carries several hash values in _private, computed after every style application:

if( isNode ){
  let { nodeBody, nodeBorder, nodeOutline, backgroundImage, compound, pie, stripe } = _p.styleKeys;

  let nodeKeys = [ nodeBody, nodeBorder, nodeOutline, backgroundImage, compound, pie, stripe ]
    .filter(k => k != null)
    .reduce(util.hashArrays, [ util.DEFAULT_HASH_SEED, util.DEFAULT_HASH_SEED_ALT ]);

  _p.nodeKey = util.combineHashesArray(nodeKeys);

_p.nodeKey hashes all visual node properties: body shape, border, outline, images, compound layout, pie chart segments, stripes. the atlas cache uses this as its lookup key (getStyleKey = ele => ele[0]._private.nodeKey). two nodes with the same key share one texture slot. change one node's fill color and only that key's slot invalidates.

there are separate keys for labels and edge endpoint labels (labelStyleKey, sourceLabelStyleKey, targetLabelStyleKey), so a node's body and its label can invalidate independently.

while the frame finishes early

texture refinement (dequeuing higher-quality sprites into the atlas) is time-gated. the budget constants live in ele-texture-cache.mjs:

const deqCost = 0.15;      // % of last render time usable per drawing frame
const deqAvgCost = 0.1;    // % of average render time usable per drawing frame
const deqNoDrawCost = 0.9; // % of a full-fps frame usable when not drawing
const deqFastCost = 0.9;   // % of remaining frame time usable when ahead of 60fps

the dequeue loop in texture-cache-defs.mjs checks elapsed time against the appropriate threshold each iteration:

while( true ){
  var now = util.performanceNow();
  var duration = now - startTime;
  var frameDuration = now - frameStartTime;

  if( renderTime < fullFpsTime ){
    // rendering faster than 60fps: use all remaining frame slack
    var timeAvailable = fullFpsTime - ( willDraw ? avgRenderTime : 0 );

    if( frameDuration >= deqFastCost * timeAvailable ){ break; }
  } else {
    if( willDraw ){
      if(
           duration >= deqCost * renderTime
        || duration >= deqAvgCost * avgRenderTime
      ){ break; }
    } else if( frameDuration >= deqNoDrawCost * fullFpsTime ){ break; }
  }

  var thisDeqd = opts.deq( self, pixelRatio, extent );
  if( thisDeqd.length === 0 ){ break; }
}

three cases:

  • drawing frame, slow machine: dequeue until 15% of last render time or 10% of average is consumed. minimal impact on frame rate.
  • non-drawing frame: 90% of a full-fps frame is available. the rAF loop still fires; the frame just doesn't call r.render(). nearly all that time goes to refinement.
  • ahead of 60fps: if the last render finished under 16.7ms, timeAvailable is the slack left in the frame. 90% of that goes to refinement.

faster hardware gets better texture quality automatically. the renderer never sets a quality level explicitly, it just runs the dequeue loop until time runs out, and time scales with how fast the machine is.


the five tricks are orthogonal. none interact and each activates at a distinct moment. during a gesture the renderer blurs or freezes. during each frame it culls, reuses paths, and shares atlas slots. between frames it quietly upgrades. every kind of pressure has a calibrated response.

next, part 6: the webgl path, instanced batching, sdf shapes, and the picking framebuffer that makes hover detection O(1) regardless of graph size.