* 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
14 KiB
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,
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.jfrto the run configuration, then drag, scroll, zoom and edit a complex layout. - Reading it.
jfr view hot-methods drag.jfr,jfr view gc drag.jfrandjfr view allocation-by-site drag.jfrgive the overview. For DIYLC call sites, export the samples withjfr print --json --events jdk.ExecutionSample --stack-depth 96(orjdk.ObjectAllocationSample) and group them by the innermostorg.diylcframe. - Before and after a change, compare the
DrawingManager"Page stats" average at the same zoom and viewport, plus theDrawingCachelines (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 ComponentAreas.
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.
Presenterknows 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.drawandDrawingManager.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
zoompx 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) isTYPE_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 asScaledBufferedImagedoes now, so that copying it to the screen takes the fast path. - Cached component images (
DrawingCache.renderAndCache) areTYPE_INT_ARGB, so every paste blends each pixel without premultiplied alpha.TYPE_INT_ARGB_PREblends faster. Confirm thatScaledBufferedImageand 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;
Presentercallsinvalidate()fromnotifyProjectModifiedIfNeeded,loadProject, paste (which copies the clipboard's groups straight in),ungroupComponents,setLayerLocked(which updates the selection before notifying) andlockComponent;- 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.
recalculateused 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.- 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
Areaoperations: 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.buildLeadGeometryproduces the lead paths and squares once per shape change and is cached inleadGeometry. 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(...), theG2DWrapperallocations on draws that record nothing, and theLOG.warn("Empty outline area detected ...")inDrawingCache.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.mouseMovedwalks 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.extractComponentTypeFromlookups (three per component per repaint) and the uncontendedsynchronized (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.