Files
diy-layout-creator/diylc/docs/plans/drawing-engine-performance.md
T
Bane Stojkovic a8d8a9b409 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
2026-10-14 21:15:40 +02:00

237 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Drawing Engine Performance
The remaining repaint costs in the canvas drawing pipeline, ranked by expected payoff, and what
each fix needs. The image cache and off-screen culling that already exist are described in
[`../adr/canvas-rendering-cache-and-culling.md`](../adr/canvas-rendering-cache-and-culling.md),
which also lists their own open issues. This plan covers everything else.
How a repaint flows: `CanvasPanel.paint` → `Presenter.draw` → `DrawingManager.drawProject`. That
calls `DrawingCache.bulkPrepare` (parallel), then runs the per-component loop through
`DrawingCache.draw` / `G2DWrapper` → `component.draw()`.
## 0. Measurements
**How to measure.**
- **Recording.** Add `-XX:StartFlightRecording=duration=60s,filename=drag.jfr` to the run
configuration, then drag, scroll, zoom and edit a complex layout.
- **Reading it.** `jfr view hot-methods drag.jfr`, `jfr view gc drag.jfr` and
`jfr view allocation-by-site drag.jfr` give the overview. For DIYLC call sites, export the samples
with `jfr print --json --events jdk.ExecutionSample --stack-depth 96` (or
`jdk.ObjectAllocationSample`) and group them by the innermost `org.diylc` frame.
- **Before and after a change,** compare the `DrawingManager` "Page stats" average at the same zoom
and viewport, plus the `DrawingCache` lines (see the ADR, §5). Compare total time, not per-type
counts, because culled components are not counted.
**Profile of 2026-10-14.** Complex layout, wires cached, culling and invalidation-based caching in
place. 750 CPU samples over 60 s, so treat the shares as approximate.
| Share | Where | Item |
| --- | --- | --- |
| ~52% of CPU samples | `TwistedWire.recalculate` and the `Area` operations under it, mostly on the `bulkPrepare` workers (fixed, see §7) | §7 |
| ~7% of CPU samples | `G2DWrapper.appendShape` recording `TwistedWire`'s outline (`currentArea.add`) | §2a |
| 16% of CPU samples | Marlin rasterisation (`Renderer._endRendering`): the drawing itself | — |
| ~19% of EDT samples | `Presenter.findAllGroupedComponents`, from `Presenter.draw` and `mouseMoved` | §6 |
| 958 MB / 60 s | pixel buffers for cache renders | — |
| 426 MB / 60 s | grid image rebuilt on zoom changes (fixed, see §3) | §3 |
| 386 MB / 60 s | canvas back buffer re-created on every scroll step (fixed, see §5) | §5 |
GC pauses totalled 348 ms. Most were under 40 ms, with one young-generation pause of 115 ms during
heavy allocation.
**Profile of 2026-10-14, later (P4),** mostly dragging parts with twisted wires attached: 707 CPU
samples, 383 of them `TwistedWire` geometry on the `bulkPrepare` workers, and 4.75 GB of cached
images allocated in 60 s. Wires stuck to a dragged part kept the `NORMAL` state, so every frame
re-rendered them into a new cache image. Moved-along components now bypass the cache (ADR §2).
The geometry itself still runs on every frame, because the wire's shape really changes; see §7.
Grouping fell to 12 samples after §6, and the per-component overhead (now under "Not worth
optimizing now") showed 1 sample.
**P5 (stroke-drawn twisted wires, moved components bypass the cache):** 248 CPU samples against
707 in P4 for a similar drag session; `TwistedWire` geometry 35 samples (was 383), workers 70
(was 537), total allocation 2.3 GB (was 7.4 GB), cached images 0.98 GB (was 4.75 GB), longest GC
pause 42 ms (was 71 ms). Recording areas was then the largest cost, about 37% of samples, mostly
for the moved wires on every frame; §2a addresses it.
**P6 (lazy area merging):** 174 CPU samples for a similar drag session; area recording 0 (was
92), on-demand merges from clicks and hovers 6, total allocation 1.45 GB, longest GC pause 13 ms.
The cursor check briefly cost 17 samples recomputing outline bounds on every mouse move, fixed by
caching them in `ComponentArea`. Rasterising (Marlin) is now the largest cost at about a third of
the samples, so the remaining gains come from drawing less: §2b and §5. Image copies measured 3
samples, which puts §4 low.
**Baseline before culling,** from the same layout: 327 repaints averaging 26 ms; `HookupWire`
(then uncached) about 9 ms per repaint; `TwistedWire` about 96 ms per render; `TubeSocket` 572 ms
in `DrawingManager` against 25 ms of image pasting.
## 1. Reflection on every edit
**Done:** the per-repaint reflective comparison is gone. `DrawingCache` now relies on `Presenter`
invalidating changed components; see the ADR, §2.
**What is left.** Reflection now runs once per edit instead of on every repaint. Each edit:
- clones the whole project (`AbstractComponent.clone()` walks the fields with reflection);
- runs `Project.equals`;
- runs `Presenter.invalidateChangedComponents`, which compares every component with its snapshot.
All three use the same `getDeclaredFields()` / `setAccessible` / `Field.get` walk. It is felt
mostly on repeated edits, such as holding an arrow key to nudge a large project.
**Fix.** Cache each class's comparable fields once, with `setAccessible` already applied, in a
`ClassValue`, and use it in both `equalsTo` and `clone`. Behaviour stays identical. Unit-test the
new `equalsTo` against the current one on a few real components from `diylc-library`.
**Watch.** Missed invalidation paths are now a correctness risk. Run with
`-Dorg.diylc.verifyDrawingCache=true` while testing new edit operations or plugins that change
components.
## 2. Dragging redraws everything
Each drag step invalidates the moved components, and the next frame redraws the whole visible
project.
**2a. Area recording during drags: done.** Recording areas used to merge every tracked shape into
an `Area` as it was drawn, which cost the most for the components moved on every frame. Recording
now keeps copies of the shapes, and `ComponentArea` merges them pairwise the first time an area is
asked for (ADR §1). `debugAreaPerformance` keeps an eagerly merged outline of its own for its check,
and `Snapshot`, the test recorder, now stores shapes rather than areas in its `ComponentArea`s.
**2b. Draw the drag over a frozen background.**
- At drag start, render everything that is not moving into a background image once. Each frame
then pastes that image and draws only the moving components, the control points and the
selection.
- Re-render the background when the viewport scrolls or the drag ends.
- **Stacking order:** a moving part that sits under other parts would draw on top of them while
dragging. Either accept that for the length of the drag, or split the background into a layer
below the lowest moving component and a layer above it, and draw the moving parts between them.
- Components that move along with the dragged ones without being selected (stuck components) must
count as moving. `Presenter` knows which these are from the control-point map.
- This is the biggest structural win for dragging, and the most invasive change in this plan. It
touches `CanvasPanel`, `Presenter.draw` and `DrawingManager.drawProject`.
## 3. Grid: done
`DrawingManager.drawGrid` used to cache the grid as one image the size of the whole canvas at the
current zoom. That was up to ~190 MB at high zoom, outside the cache budget, and 426–1,041 MB of
grid images were allocated per profiled minute of zooming. It was rendered at 1× and stretched on
HiDPI, and its rebuild check ignored grid spacing, dot spacing and theme.
It now lives in `GridRenderer` (tested by `GridRendererTest`), which draws the lines and dots
directly, and only those inside the visible rect:
- **Antialiasing off for the grid.** The lines are axis-aligned, so each one snaps to crisp whole
pixels instead of being smeared across two. The thickness formula is unchanged: 1 px at zoom
≥ 1, and `zoom` px below that.
- **Dense grids are thinned.** When lines would be closer than `GridRenderer.MIN_GRID_LINE_SPACING` (3 canvas
pixels at the current zoom), only every 5th line is drawn, then every 25th, and so on. Snapping
still uses the real spacing. A 0.5 mm grid shows every line at 100% and every 5th line from
75% down.
- **Exports and printing** pass no visible rect and get the whole grid as vector lines, instead of
a raster image embedded in the PDF or SVG.
One viewport of a 0.5 mm grid draws in under a millisecond. There is no cached state any more, so
the grid cannot go stale.
## 4. Image formats force slow compositing
- **The canvas back buffer** (`CanvasPanel.createBufferImage`) is `TYPE_INT_ARGB`, though the
canvas is always filled with an opaque background. Use an opaque compatible image,
`getGraphicsConfiguration().createCompatibleImage(w, h, Transparency.OPAQUE)`, kept HiDPI-scaled
as `ScaledBufferedImage` does now, so that copying it to the screen takes the fast path.
- **Cached component images** (`DrawingCache.renderAndCache`) are `TYPE_INT_ARGB`, so every paste
blends each pixel without premultiplied alpha. `TYPE_INT_ARGB_PRE` blends faster. Confirm that
`ScaledBufferedImage` and anything else reading the pixels is fine with the format.
Each is a one-line change, but Java2D's speed varies by pipeline (Metal on macOS), so keep each
change only if the page-time comparison shows a gain.
## 5. Every paint redraws the whole visible area
**Done:** the back buffer used to be thrown away on every scroll-bar event. `CanvasPlugin` called
`invalidateCache()` from both scroll listeners, which allocated 386 MB in the profiled minute.
`CanvasPanel.paint` now re-creates the buffer only when its pixel size changes, which includes a
change of HiDPI scale.
**Problem.** `CanvasPanel.paint` ignores the clip that Swing passes in. It always redraws the full
visible rect into a back buffer sized and positioned to the viewport. Scrolling, which only
exposes a thin strip, and partial repaints after a tooltip or balloon closes, both pay for a full
redraw.
**Fix.** Redraw only `g.getClipBounds()` (intersected with
the visible rect), passing that as the visible rect so that culling and the cache limit the work.
The buffer has to survive scrolling: either shift its contents and redraw the exposed strip, or
make it larger than the viewport. Moderate work. Do it after item 3, since the grid currently
assumes it draws the whole canvas.
## 6. Grouping and locking bookkeeping: done
`Presenter.draw` used to rebuild group membership and the locked-component set on every repaint,
and `mouseMoved` looked groups up on every move: about a fifth of the EDT's samples in all three
profiles. Both now come from `GroupAndLockIndex` (tested by `GroupAndLockIndexTest`), which works
them out once per project state:
- group membership is indexed by component id, so a component outside the project, such as one
being placed, resolves the same way as before; the sets it hands out are read-only;
- `Presenter` calls `invalidate()` from `notifyProjectModifiedIfNeeded`, `loadProject`, paste
(which copies the clipboard's groups straight in), `ungroupComponents`, `setLayerLocked` (which
updates the selection before notifying) and `lockComponent`;
- asking about a different project object rebuilds the index without an explicit invalidation.
**Watch.** Code that changes the current project's groups, locks or component list without going
through `Presenter` would leave the index stale until the next edit. Nothing does today; plugins
change the project through `IPlugInPort`, and `LoadlineEditorFrame` builds its own project.
## 7. Individual components
**`TwistedWire`: done in two steps.**
1. `recalculate` used to build and cut each lead by growing one area segment by segment and
cutting every crossing out of the whole lead, so its cost grew with the square of the wire's
length. The segment areas are now merged pairwise and each kind of crossing is cut in one
operation. That also removed a data race (the parallel crossing loop read one lead while
another thread subtracted from it) and a bug that left the last crossings near the end of many
wires uncut.
2. P4 still showed the geometry on every frame for wires whose ends move with a drag. Opaque
wires are now drawn with strokes and no `Area` operations: per lead, a dark stroke one pixel
wider than the lead and a coloured one a pixel narrower give the outline, the stripe is a
dashed stroke on top, and redrawing the first lead clipped to its own crossing squares puts it
on top there. `buildLeadGeometry` produces the lead paths and squares once per shape change and
is cached in `leadGeometry`. The first stroke of each lead is the one recorded as the
component's area.
Translucent wires (alpha below 100%) keep the area path, because strokes paint some pixels twice
and that would show through; its output is pixel-identical to before. On eight test renders the
stroke path is about 8x faster (97 ms against 772 ms). Opaque output matches apart from
antialiasing along the edges and a slightly finer outline, and selected wires lose faint seams the
area cuts used to leave. Stroking a lead as one path whose segments are separate sub-paths is fine:
stroke outlines are filled with the non-zero rule, so overlapping segments merge.
**`HookupWire`** is now cached and no longer shows up in the profile.
## Not worth optimizing now
- **Per-component overhead in the drawing loop** (formerly §7): the stats key built with
`getCanonicalName().replace(...)`, the `G2DWrapper` allocations on draws that record nothing, and
the `LOG.warn("Empty outline area detected ...")` in `DrawingCache.renderAndCache`. P3 and P4
show it at 1 sample each. The warning still breaks the no-logging-on-hot-paths rule and can go
whenever that file is next touched.
- **Mouse-move handling.** `Presenter.mouseMoved` walks every control point twice per move, but it
does not repaint. It only becomes worth indexing at much larger project sizes.
- **Type lookups and locking.** The `ComponentProcessor.extractComponentTypeFrom` lookups (three
per component per repaint) and the uncontended `synchronized (this)` around each component in
the drawing loop are cheap.
## Order of work
| Step | Item | Size | Risk | Needs |
| --- | --- | --- | --- | --- |
| 1 | §4 image formats | small | low | page-time comparison on macOS |
| 2 | §2b frozen drag background | large | medium | manual drag testing incl. stacking order |
| 3 | §5 redraw only the damaged area | medium | medium | scroll and tooltip testing |
| 4 | §1 cached reflection metadata for `equalsTo` / `clone` | small | low | unit test for `equalsTo` |
Re-profile next: the ranking of the remaining steps still comes from reading the code.