motion blur, culling, path caching: how the canvas path handles pressure
2026-05-16
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:
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:
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:
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:
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:
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:
_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:
the dequeue loop in texture-cache-defs.mjs checks elapsed time against the appropriate threshold each iteration:
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,
timeAvailableis 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.