composites, suppression, atomic swap

2026-05-07

series
cytoscape renderer
part 4 of 4

part 3 left every node and edge pre-rendered into offscreen sprites at eight zoom levels. drawLayeredElements blits from those sprites — no re-rasterising from scratch. but it still calls drawImage once per element. on a 10,000-node graph that's 10,000 draw calls per frame. the layered texture cache takes this further: it composites groups of elements onto a single large offscreen canvas, so pan and zoom reduce to a handful of drawImage calls.

composites

LayeredTextureCache (in layered-texture-cache.mjs) sits above the per-element atlas. instead of one sprite per element per zoom level, it groups elements into layers — each layer is a canvas that covers the bounding box of its elements, composited once. makeLayer creates it:

LTCp.makeLayer = function( bb, lvl ){
  var scale = Math.pow( 2, lvl );

  var w = Math.ceil( bb.w * scale );
  var h = Math.ceil( bb.h * scale );

  var canvas = this.renderer.makeOffscreenCanvas(w, h);

  var layer = {
    id: (layerIdPool = ++layerIdPool % MAX_INT ),
    bb: bb,
    level: lvl,
    width: w,
    height: h,
    canvas: canvas,
    context: canvas.getContext('2d'),
    eles: [],
    elesQueue: [],
    reqs: 0
  };

  // log('make layer %s with w %s and h %s and lvl %s', layer.id, layer.width, layer.height, layer.level);

  var cxt = layer.context;
  var dx = -layer.bb.x1;
  var dy = -layer.bb.y1;

  // do the transform on creation to save cycles (it's the same for all eles)
  cxt.scale( scale, scale );
  cxt.translate( dx, dy );

  return layer;
};

the canvas is sized to cover the bounding box, scaled by 2^lvl. then the transform is pre-applied once to the context: scale(scale, scale) maps graph coordinates to canvas pixels at the right zoom level; translate(-bb.x1, -bb.y1) shifts the origin so absolute graph positions land correctly. every element drawn into the layer later uses this pre-set transform — no per-element matrix math. the layer area is capped at 4,000 × 4,000 pixels (maxLayerArea = 4000 * 4000); if the group's bounding box would exceed that, a new layer is started.

with layers built, drawLayeredElements in drawing-elements.mjs calls the cache:

CRp.drawLayeredElements = function( context, eles, pxRatio, extent ){
  let r = this;

  let layers = r.data.lyrTxrCache.getLayers( eles, pxRatio );

  if( layers ){
    for( let i = 0; i < layers.length; i++ ){
      let layer = layers[i];
      let bb = layer.bb;

      if( bb.w === 0 || bb.h === 0 ){ continue; }

      context.drawImage( layer.canvas, bb.x1, bb.y1, bb.w, bb.h );
    }
  } else { // fall back on plain caching if no layers
    r.drawCachedElements( context, eles, pxRatio, extent );
  }
};

when getLayers returns layers, the draw is one drawImage per layer — the four arguments after layer.canvas are destination x, y, width, and height in graph coordinates. when getLayers returns null, drawLayeredElements falls through to drawCachedElements, which blits element-by-element from the sprite atlas. null happens more often than you'd expect; the fallback being fast matters.

the suppression window

the layered cache is expensive to rebuild. during a layout run, thousands of nodes change position in rapid succession and each move fires an invalidation. rebuilding the composite layer after every change would be pure waste — by the time the build finishes, the positions have moved again.

the cache handles this with a suppression window. any call to invalidateElements records the current time:

LTCp.invalidateElements = function( eles ){
  var self = this;

  if( eles.length === 0 ){ return; }

  self.lastInvalidationTime = util.performanceNow();

  // log('update invalidate layer time from eles');

  if( eles.length === 0 || !self.haveLayers() ){ return; }

  self.updateElementsInLayers( eles, function invalAssocLayers( layer, ele, req ){
    self.invalidateLayer( layer );
  } );
};

a priority-150 hook runs before every frame and compares that timestamp to now:

r.beforeRender(function( willDraw, now ){
  if( now - self.lastInvalidationTime <= invalidThreshold ){
    self.skipping = true;
  } else {
    self.skipping = false;
  }
}, r.beforeRenderPriorities.lyrTxrSkip);

invalidThreshold = 250. if any invalidation happened within the last 250ms, self.skipping = true. getLayers checks the flag early:

if( self.skipping && !firstGet ){
  return null;
}

(lines 230–233.) when skipping, getLayers returns null and drawLayeredElements falls back to per-element blitting. the per-element atlas handles individual invalidations cheaply; the composite layer waits. after 250ms of silence — no new invalidations — skipping goes false and the next getLayers call rebuilds the composite.

atomic swap

once a composite layer exists, element texture refinement (part 3's dequeuer) creates a secondary problem. as the dequeuer upgrades per-element sprites to higher-quality levels, the composite layer becomes stale — it was built from the lower-quality sprites. the obvious fix is to mark the layer invalid and rebuild, but rebuilding takes multiple frames and during those frames there'd be no composite to serve.

the cache avoids this by building a replacement layer in the background. refineElementTextures creates it:

LTCp.refineElementTextures = function( eles ){
  var self = this;

  // log('refine', eles.length);

  self.updateElementsInLayers( eles, function refineEachEle( layer, ele, req ){
    var rLyr = layer.replacement;

    if( !rLyr ){
      rLyr = layer.replacement = self.makeLayer( layer.bb, layer.level );
      rLyr.replaces = layer;
      rLyr.eles = layer.eles;

       // log('make replacement layer %s for %s with level %s', rLyr.id, layer.id, rLyr.level);
    }

    if( !rLyr.reqs ){
      for( var i = 0; i < rLyr.eles.length; i++ ){
        self.queueLayer( rLyr, rLyr.eles[i] );
      }

       // log('queue replacement layer refinement', rLyr.id);
    }
  } );
};

the replacement gets the same bounding box and level as the original. all the same elements are queued into it, and the dequeuer draws each one using the current upgraded per-element sprite. the original layer keeps serving frames the entire time.

when the dequeuer finishes the last element, it calls applyLayerReplacement:

LTCp.applyLayerReplacement = function( layer ){
  var self = this;
  var layersInLevel = self.layersByLevel[ layer.level ];
  var replaced = layer.replaces;
  var index = layersInLevel.indexOf( replaced );

  // if the replaced layer is not in the active list for the level, then replacing
  // refs would be a mistake (i.e. overwriting the true active layer)
  if( index < 0 || replaced.invalid ){
     // log('replacement layer would have no effect', layer.id);
    return;
  }

  layersInLevel[ index ] = layer; // replace level ref

  // replace refs in eles
  for( var i = 0; i < layer.eles.length; i++ ){
    var _p = layer.eles[i]._private;
    var cache = _p.imgLayerCaches = _p.imgLayerCaches || {};

    if( cache ){
      cache[ layer.level ] = layer;
    }
  }

   // log('apply replacement layer %s over %s', layer.id, replaced.id);

  self.requestRedraw();
};

the swap is two array writes. the level's layer list gets the new canvas at the old canvas's index; each element's cache reference is updated to point at the new layer. the next frame after applyLayerReplacement runs draws from the new canvas — the old one is never explicitly freed, just abandoned.

the guard at lines 622–624 handles the race: if the layer being replaced got invalidated again while the replacement was still building, replaced.invalid is true and the swap is abandoned. a fresh build starts from scratch instead.


that's the two-level cache stack. per-element sprites (part 3) feed into composite layers (this post). pan and zoom hit the composite; the composite gets the per-element atlas; the per-element atlas gets the raw draw calls. the suppression window prevents wasted builds during layout bursts; the atomic swap prevents blank frames during texture upgrades.

next up — part 5: style keys, viewport culling, Path2D caching, motion blur, and the other tricks that keep the canvas path graceful under pressure.