Render optimization (#1087)

* 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
This commit is contained in:
Bane Stojkovic
2026-10-14 21:15:40 +02:00
committed by GitHub
parent 251b945e6d
commit a8d8a9b409
41 changed files with 2210 additions and 379 deletions
@@ -0,0 +1,264 @@
# 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
1. `CanvasPanel.paint` takes `getVisibleRect()`, translates its back buffer to it and calls
`IPlugInPort.draw(..., visibleRect)`. That reaches `DrawingManager.drawProject`.
2. `drawProject` scales `visibleRect` by `1 / zoom`, so from here on it is in **project
coordinates**, the same space as component control points.
3. If `DrawOption.ENABLE_CACHING` is set, `DrawingCache.bulkPrepare` renders every stale cacheable
component that intersects the clip, **in parallel** on the common fork-join pool.
4. Components are then drawn one by one in z-order. For each one:
- `trackArea = lastDrawnStateMap.get(c) != state`. The selection and continuity areas in
`componentAreaMap` are 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 either `startRecordingDrawnBounds()` (when
tracking) or `stopTracking()`.
- Unless `offScreen`, call `drawComponent`. That goes through `DrawingCache.draw` when caching
is on, and straight to `component.draw` otherwise.
- `finishedDrawingComponent()` returns a `ComponentArea`. When tracking and the outline is not
empty, it is stored in `componentAreaMap` together with the state it was drawn in.
- Recording an area only keeps copies of the tracked shapes, transformed into project
coordinates. `ComponentArea` merges them into the outline and continuity areas the first time
one is asked for, pairwise (`AreaUtils.union`), and keeps the result. `hasOutline()` and
`getOutlineBounds()` 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 is `false`);
- `shouldCache` passes:
- **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, as
`drawProject(..., movingComponents, ...)`. Those keep their normal appearance but are left out
of `bulkPrepare` and drawn directly, for the same reason as dragged ones;
- **at most `MAX_CACHED_IMAGE_PIXELS` (8 Mi pixels, 32 MB)**, measured as
`getCachingBounds()` × `zoom` × `scaleFactor` on each axis. A big board at high zoom on a
HiDPI screen would otherwise take a large share of the budget on its own.
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_ARGB` `ScaledBufferedImage` of `getCachingBounds()` 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.
- **Validity.** On every lookup an entry is checked only against state, zoom, scale factor,
outline mode and `DrawOption`s, all cheap comparisons. The cache does **not** detect changes to
the component itself. An entry stays valid until someone calls `DrawingCache.invalidate`. This
replaced a reflective `equalsTo` against 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 from `notifyProjectModifiedIfNeeded` and at the
end of a drag. It matches components to the pre-edit snapshot by id and calls
`componentChanged` for each one that is new or no longer `equalsTo` its snapshot. Every edit
already takes that snapshot and runs `Project.equals`, so this adds one comparison per
component per edit, not per repaint.
- Drag steps do not go through `notifyProjectModifiedIfNeeded`, so they call `componentChanged`
for 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.** `renderAndCache` returns `null` when
the caching bounds miss the clip, and `DrawingCache.draw` then 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
from `componentAreaMap` (`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
- `imageCache` is an **access-ordered `LinkedHashMap`**. 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, `invalidate`d or `clear`ed)
has `BufferedImage.flush()` called on it. That drops Java2D's accelerated (VRAM or native) copy,
which would otherwise live until finalisation.
- **Why not `SoftReference`:** this replaced a `SoftReference` map. 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 on `Presenter.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 under `synchronized (imageCache)`.
- The lock covers only the lookup and the insert. Rendering happens outside it.
- `renderStatsByType` is a `ConcurrentHashMap`. `Counter.add` is `synchronized`.
- The hit/miss counters are `AtomicLong`s.
## 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()`** is `null` by 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 `DrawingManager` turns on for draws where
`trackArea` is true. Ordinary repaints pay nothing.
- **What it covers.** All drawing methods feed `appendDrawnBounds`, independent of
`trackingAllowed`. The result is transformed by `currentTx` into project coordinates, the same
space as the areas.
- **Bounds per drawing call:**
- Strokes: the shape bounds padded by `lineWidth / 2 × max(√2, miterLimit)` for `JOIN_MITER`, or
`× √2` otherwise. That covers square caps and miter spikes. A non-`BasicStroke` uses
`createStrokedShape(s).getBounds2D()`.
- Strings: `FontMetrics.getStringBounds`.
- Attributed strings: `TextLayout.getBounds`.
- Glyph vectors: `getVisualBounds`.
- Images: their destination rectangle, or the transformed image rectangle.
- **Cached components** never draw through the canvas wrapper. `DrawingCache.draw` reports the
entry's cache bounds through `mergeDrawnBounds` instead.
- **Storage.** The rectangle is stored as `ComponentArea.getDrawnBounds()`. It is `null` when
nothing was recorded.
### The rule
`DrawingManager` skips `drawComponent` when **all** of these hold:
1. the component's areas are already recorded for its current state (`!trackArea`);
2. a visible rect was passed in;
3. the component has measured drawn bounds;
4. 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 `trackArea` stays 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 `Graphics2D` passed to `draw()`.** Do not use `create()` 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 in
`notifyProjectModifiedIfNeeded` with a snapshot is detected automatically. Code that changes
components on any other path, as drag steps do, must call `Presenter.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 from `ObjectCache` and do not log. See `CLAUDE.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 `bulkPrepare` lookups. The second lookup in `draw()` 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 `bulkPrepare` and `draw` in 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.
- `TwistedWire` cost about 96 ms per render.
## 6. Known limitations and next steps
Ordered by expected payoff.
1. **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.
2. **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.
3. **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.
4. **`HookupWire` and `TwistedWire`.** If culling is not enough, either enable caching for
`HookupWire` (large, mostly empty bounding boxes, but plenty of budget) or profile it. Profile
`TwistedWire.draw` in any case.
5. **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.
6. **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.
7. **`DrawingCache.Instance` is a static singleton.** `ProjectPreview` calls `loadProject`, which
clears it. If the preview runs its own `Presenter`, browsing files wipes the main canvas
cache. Check, and give each `Presenter` its own cache if so.
8. **Shrink the budget under memory pressure** with a `MemoryPoolMXBean` usage threshold. This
is only worth doing if users run with a much smaller `-Xmx`.