mirror of
https://github.com/bancika/diy-layout-creator
synced 2026-11-13 02:32:09 +10:00
a8d8a9b409
* 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
265 lines
15 KiB
Markdown
265 lines
15 KiB
Markdown
# 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`.
|