* Caching and culling improvements * doc * Update canvas-rendering-cache-and-culling.md * caching for wires * cache invalidation improvements * caching improvements * twisted wire performance update * Improved grid rendering on screen and when exporting; PDF export of the grid is now vector-based and does not get blurry on zooming in * area append improvement * bug fix for subtracting trace cuts * Twisted wire stroke render improvement * various performance improvements * lazy area merging * regression update * Update update.xml * code cleanup * Twisted wire border thickness fix
15 KiB
Canvas Rendering: Caching and Off-Screen Culling
How the main canvas avoids redrawing work on every repaint. There are two mechanisms, both in
diylc-core/.../presenter:
- Image caching (
DrawingCache) turns an expensive component into a bitmap copy while it is unchanged. - Off-screen culling (
DrawingManager+G2DWrapper) skips components that cannot be seen.
Both are optimisations only. Exports, printing and the regression suite pass no visible rect, so culling never applies to them, and with caching off the output is the same.
1. The repaint pipeline
CanvasPanel.painttakesgetVisibleRect(), translates its back buffer to it and callsIPlugInPort.draw(..., visibleRect). That reachesDrawingManager.drawProject.drawProjectscalesvisibleRectby1 / zoom, so from here on it is in project coordinates, the same space as component control points.- If
DrawOption.ENABLE_CACHINGis set,DrawingCache.bulkPreparerenders every stale cacheable component that intersects the clip, in parallel on the common fork-join pool. - Components are then drawn one by one in z-order. For each one:
trackArea = lastDrawnStateMap.get(c) != state. The selection and continuity areas incomponentAreaMapare recorded only on a draw where this is true: the first draw after load, an invalidation or a state change.offScreen = !trackArea && isOffScreen(c, visibleRect)(see §3).g2dWrapper.startedDrawingComponent(i). Then eitherstartRecordingDrawnBounds()(when tracking) orstopTracking().- Unless
offScreen, calldrawComponent. That goes throughDrawingCache.drawwhen caching is on, and straight tocomponent.drawotherwise. finishedDrawingComponent()returns aComponentArea. When tracking and the outline is not empty, it is stored incomponentAreaMaptogether with the state it was drawn in.- Recording an area only keeps copies of the tracked shapes, transformed into project
coordinates.
ComponentAreamerges them into the outline and continuity areas the first time one is asked for, pairwise (AreaUtils.union), and keeps the result.hasOutline()andgetOutlineBounds()work from the shapes without merging, and hit tests check the bounds before the area, so storing areas, selection bounds and the rulers never merge anything. The moved components of a drag are recorded on every frame; their areas are merged only if something asks. - Mirrored copies for board undersides are drawn after that, outside both mechanisms.
Areas are invalidated by DrawingManager.invalidateComponent, which Presenter calls for
components moved by a drag step and for edited components. They are cleared wholesale by
clearComponentAreaMap: project load, undo/redo (which goes through loadProject), delete,
rotate, mirror and operation-mode changes. Zoom does not invalidate areas. They live in project coordinates.
2. Image caching (DrawingCache)
What gets cached
A component is cached when both of these hold:
- its type has
@ComponentDescriptor(enableCache = true)(the default isfalse); shouldCachepasses:- not in
ComponentState.DRAGGING. A dragged component changes every frame, so its image would be thrown away immediately while still pushing useful entries out; - not moved along by a drag.
Presenter.getMovingComponents()passes the keys of the drag's control-point map, which include components stuck to the dragged ones, asdrawProject(..., movingComponents, ...). Those keep their normal appearance but are left out ofbulkPrepareand drawn directly, for the same reason as dragged ones; - at most
MAX_CACHED_IMAGE_PIXELS(8 Mi pixels, 32 MB), measured asgetCachingBounds()×zoom×scaleFactoron each axis. A big board at high zoom on a HiDPI screen would otherwise take a large share of the budget on its own.
- not in
Anything else is drawn directly. The same component can switch between the two paths as zoom changes.
Entries
- Key: the component instance. Components do not override
equals/hashCode, so this is identity. - Value (
CacheValue):- a
TYPE_INT_ARGBScaledBufferedImageofgetCachingBounds()at the current zoom and HiDPI scale; - the outline and continuity areas captured while rendering it;
- the cache bounds;
- the parameters the entry is valid for.
- a
- Validity. On every lookup an entry is checked only against state, zoom, scale factor,
outline mode and
DrawOptions, all cheap comparisons. The cache does not detect changes to the component itself. An entry stays valid until someone callsDrawingCache.invalidate. This replaced a reflectiveequalsToagainst a clone kept in every entry, which ran twice per cacheable component on every repaint. - Invalidation lives in
Presenter:componentChanged(c)invalidates both the areas and the cached image of one component.invalidateChangedComponents(oldProject)runs fromnotifyProjectModifiedIfNeededand at the end of a drag. It matches components to the pre-edit snapshot by id and callscomponentChangedfor each one that is new or no longerequalsToits snapshot. Every edit already takes that snapshot and runsProject.equals, so this adds one comparison per component per edit, not per repaint.- Drag steps do not go through
notifyProjectModifiedIfNeeded, so they callcomponentChangedfor the moved components explicitly. loadProject(including undo/redo) clears the whole cache, and a null snapshot does too.
- Placement: the image is drawn at the first control point minus an integer offset, so it can sit up to one unit away from the cache bounds.
- Off-screen components are not rendered into the cache.
renderAndCachereturnsnullwhen the caching bounds miss the clip, andDrawingCache.drawthen draws the component directly. Java2D discards the pixels, but the wrapper still records the component's areas. This matters because the netlist, "highlight connected areas", compare and the chatbot all read continuity fromcomponentAreaMap(DrawingManager.getContinuityAreas). A cached conductor such as an eyelet, solder pad, trace cut, jack, pot or stripboard must not drop out of it just because it is off-screen. Culling (§3) means this direct draw happens once per invalidation, not on every repaint.
Memory: a byte budget with LRU eviction
imageCacheis an access-orderedLinkedHashMap. The cache keeps a running total of image bytes (width × height × 4).- Budget:
MAX_CACHE_BYTES = min(maxMemory / 4, 768 MB), which is 768 MB with the shipped-Xmx4096m.- A quarter of the heap leaves room for the project model, undo clones, area maps and exports.
- The 768 MB cap is a few full Retina canvases' worth of overlapping images.
- Eviction: after each insert, least recently used entries are evicted until the total is back under budget. The entry just inserted is never evicted, even if it alone exceeds the budget.
- Release: every entry that leaves the cache (evicted, replaced,
invalidated orcleared) hasBufferedImage.flush()called on it. That drops Java2D's accelerated (VRAM or native) copy, which would otherwise live until finalisation. - Why not
SoftReference: this replaced aSoftReferencemap. HotSpot keeps soft referents until the heap is nearly full. That kept the large pixel arrays alive, which G1 allocates as humongous objects, so they could only be reclaimed by a full GC. That was the reason the status-bar GC button helped. Strong references with explicit eviction let G1 reclaim the arrays in ordinary young collections. - Clearing:
clear()runs onPresenter.loadProject(including undo/redo).invalidate(components)runs on delete.
Threading
bulkPrepare calls renderAndCache from parallel-stream threads.
- Every read and write of
imageCache, and of the byte total, is undersynchronized (imageCache). - The lock covers only the lookup and the insert. Rendering happens outside it.
renderStatsByTypeis aConcurrentHashMap.Counter.addissynchronized.- The hit/miss counters are
AtomicLongs.
3. Off-screen culling
Measured drawn bounds
Culling needs a rectangle that contains everything a component draws. Neither existing candidate can serve:
- the tracked outline area leaves out whatever is drawn after
stopTracking(), typically labels and decoration; getCachingBounds()isnullby default and is a hand-tuned guess where it is implemented.
So G2DWrapper measures what each component actually draws:
- When it measures. Only while recording, which
DrawingManagerturns on for draws wheretrackAreais true. Ordinary repaints pay nothing. - What it covers. All drawing methods feed
appendDrawnBounds, independent oftrackingAllowed. The result is transformed bycurrentTxinto project coordinates, the same space as the areas. - Bounds per drawing call:
- Strokes: the shape bounds padded by
lineWidth / 2 × max(√2, miterLimit)forJOIN_MITER, or× √2otherwise. That covers square caps and miter spikes. A non-BasicStrokeusescreateStrokedShape(s).getBounds2D(). - Strings:
FontMetrics.getStringBounds. - Attributed strings:
TextLayout.getBounds. - Glyph vectors:
getVisualBounds. - Images: their destination rectangle, or the transformed image rectangle.
- Strokes: the shape bounds padded by
- Cached components never draw through the canvas wrapper.
DrawingCache.drawreports the entry's cache bounds throughmergeDrawnBoundsinstead. - Storage. The rectangle is stored as
ComponentArea.getDrawnBounds(). It isnullwhen nothing was recorded.
The rule
DrawingManager skips drawComponent when all of these hold:
- the component's areas are already recorded for its current state (
!trackArea); - a visible rect was passed in;
- the component has measured drawn bounds;
- those bounds, grown by
OFF_SCREEN_MARGIN(4 units, for antialiasing and whole-pixel image placement), do not intersect the visible rect.
The first condition is what keeps culling safe. Areas are only ever produced by drawing, so a component is always drawn, visible or not, until its areas exist. Clicking to select, hover, continuity and the netlist therefore never lose a component because it was off-screen.
These are never culled:
- the first draw after load or invalidation;
- components whose outline area came out empty (they are never stored, so
trackAreastays true); - every draw when no visible rect is passed (exports, printing, regression);
- mirrored underside copies, control points and the component slot.
Culled components are not counted in the per-type render stats.
4. Rules for component code
Each rule exists so that one of the mechanisms above stays correct:
- Draw only through the
Graphics2Dpassed todraw(). Do not usecreate()or reach for an underlying graphics object. Anything drawn that way is missing from the measured bounds, and the component can be culled while part of it is visible. No component in core or library does this today. - Change components through
Presenter. An edit that ends innotifyProjectModifiedIfNeededwith a snapshot is detected automatically. Code that changes components on any other path, as drag steps do, must callPresenter.componentChanged. Otherwise the cached image, the stored areas and the drawn bounds all go stale. With culling, stale bounds show up as a component missing at the edge of the view. Run with-Dorg.diylc.verifyDrawingCache=true(DrawingCache.VERIFY_CACHE) to find a missed path: each entry then keeps a clone, and every cache hit is compared against it, logging a warning naming the component and re-rendering it. - For cached types,
getCachingBounds()must contain everything drawn. The cached image is clipped to it, and the culling bounds come from it. draw()is hot. Fetch strokes fromObjectCacheand do not log. SeeCLAUDE.md.
5. Diagnostics
Logging is at DEBUG (diylc-swing/src/main/resources/log4j.properties). Output goes to the
console and to a per-run file in ~/diylc/logs/. DrawingManager.logStats writes at most once
every statReportFrequencyMs (60 s), after a repaint:
DrawingManager - Render stats: {type=count time; ...} drawn components only
DrawingManager - Page stats: <repaints> <avg ms>
DrawingCache - Render cache contains N elements, approx size is X MB of <budget> MB
DrawingCache - Render cache hits: H, misses: A not cached + B outdated, hit rate: R%,
re-rendered while painting: T
DrawingCache - Cache render distribution stats: {type: render: ..., paste: ...}
All counters accumulate over the session.
- hits / misses count only the
bulkPreparelookups. The second lookup indraw()would otherwise hold the hit rate at 50% or more. - not cached means the entry was missing: a first render or an eviction. It falls as the budget grows. If it stays high while the size sits at the budget, the budget is too small.
- outdated means the component changed. The budget cannot reduce it.
- re-rendered while painting counts entries evicted between
bulkPrepareanddrawin the same repaint. Anything above 0 means the visible set alone exceeds the budget.
Baseline from a complex layout before culling: 327 repaints averaging 26 ms.
- The cache sat at 61 MB of 768 MB with a 95.5% hit rate, nothing evicted, and nothing re-rendered while painting.
- The largest single cost was the uncached
HookupWire: about 224 drawn per repaint, roughly 9 ms of the 26 ms. TwistedWirecost about 96 ms per render.
6. Known limitations and next steps
Ordered by expected payoff.
- Measure culling. Repeat the baseline above on the same layout, zoomed in so that part of it is off-screen. Compare the "Page stats" average, not per-type counts, since culled draws are no longer counted.
- Run the regression suite. It has not been run on these changes. Exports draw everything, so the run guards against exceptions in the new bounds code rather than against culling.
- Zoom does not invalidate areas or drawn bounds. Content that is larger at another zoom
could be culled at the edge of the view. Raise
OFF_SCREEN_MARGIN, or invalidate on zoom, if that shows up. HookupWireandTwistedWire. If culling is not enough, either enable caching forHookupWire(large, mostly empty bounding boxes, but plenty of budget) or profile it. ProfileTwistedWire.drawin any case.- Components over the pixel limit are drawn directly. That may make large perfboards and stripboards slow at high zoom. Caching only the visible tile of such components would fix it.
- Reuse the image buffer when a stale entry has the same pixel size: clear it and redraw. That removes allocation churn for property edits. It must be safe against an export rendering concurrently.
DrawingCache.Instanceis a static singleton.ProjectPreviewcallsloadProject, which clears it. If the preview runs its ownPresenter, browsing files wipes the main canvas cache. Check, and give eachPresenterits own cache if so.- Shrink the budget under memory pressure with a
MemoryPoolMXBeanusage threshold. This is only worth doing if users run with a much smaller-Xmx.