Skip to content

Web board viewer: pan and zoom from a bitmap of the layers - #62

Open
shanedertrain wants to merge 2 commits into
BackplaneWorks:mainfrom
shanedertrain:perf/plot2d-bitmap-cache
Open

shanedertrain wants to merge 2 commits into
BackplaneWorks:mainfrom
shanedertrain:perf/plot2d-bitmap-cache

Conversation

@shanedertrain

@shanedertrain shanedertrain commented Oct 12, 2026 •

Copy link
Copy Markdown

On a dense board, the web viewer pans at about 9 fps on a desktop GPU. Each pan or zoom frame refills and restrokes every chunk's Path2D, including all the zone polygons. Profiling showed the cost was not in our JavaScript (about 7 ms of main-thread time per frame). It is the browser rasterizing those paths again, every frame. The phones draw on the GPU and the window keeps a base frame, so they don't have this problem.

plot2d.js now does what the window does:

  • While the view moves, the layers come from an offscreen bitmap with a 256 px margin.
    • A pan that stays inside the margin only places the bitmap.
    • A zoom, or a pan past the margin, stretches the last bitmap until the view has rested for 120 ms; then the layers are drawn again.
  • When the view jumps or doesn't move, the layers are drawn for that view in the next frame, straight onto the page as before. That covers a new plot, a fade, a layer toggle, the All/Dim/Only focus, and a double-click or resize fit. The bitmap is built when the page is idle, so the first pan finds it ready. First paint, fades and toggles cost about what they did before.
  • The corner stats add drawn in N ms, how long the layers took to draw.

Only src/web/plot2d.js changes. Nothing in Bend or on the hub is touched.

Metrics

The board is a real one: our bench-rig layout, 4.4 MB .kicad_pcb, 30 filled zones. The hub's own --plot output for it is 530 chunks and about 87k coordinates; it is fed to plot2d.js exactly as the page receives it. The viewport is 1600×900 at DPR 1. The input is a scripted pan of 120 pointer moves and a zoom of 60 wheel steps, one input per frame. Each number is the median of 3 runs. Frame is the interval between requestAnimationFrame callbacks; main thread is the script time inside the frame.

Chrome 154 on Windows, GPU: ANGLE D3D11, RTX 4070 Ti SUPER

before after
pan, fps 8.6 60
pan, median / p95 frame 116.7 / 166.7 ms 16.7 / 16.8 ms
zoom, fps 15.2 60
zoom, median / p95 frame 66.7 / 100 ms 16.7 / 16.8 ms
pan / zoom main thread per frame 7.3 / 8.0 ms 0.2 / 0.1 ms
first paint 22.7 ms 26.6 ms
layer toggle, to the frame showing it 33.4 ms 33.3 ms
focus Dim 20.0 ms 30.4 ms
fit (double-click) 23.6 ms 26.8 ms

Chrome 154 on Linux, software raster: SwiftShader

before after
pan, fps 45.2 60
pan, p95 frame 33.4 ms 16.7 ms
pan, main thread per frame 9.0 ms 0.4 ms
zoom, fps 59.8 60
first paint / toggle / Dim / fit 55 / 36 / 37 / 41 ms 56 / 35 / 37 / 41 ms

The one-off actions are frame-quantized (16.7 ms steps). After the change they stay within one frame of before. No run had a long task (over 50 ms) during pan or zoom, before or after.

On a synthetic denser plot (software raster; 120 zone polygons of 2000 points, 20k tracks, 3k vias), pan goes from 590 to 16.7 ms per frame and zoom from 270 to 15.7 ms.

Same picture

These comparisons use the real board on the Linux software-raster renderer, and compare old and new frames at the same view.

  • After the view rests:
    • after a pan, 0.25 % of pixels differ (largest difference 12/255);
    • after a zoom, 0.40 % differ (largest 31/255);
    • every difference over 8 is on a shape's edge, so they are anti-aliasing, not geometry;
    • the corner stats text, which now says more, is left out of the comparison.
  • Layer toggle, Dim (layer 2 on top), Only, back to All: the frame right after each change already shows it, and matches the old drawing to within 0.02 % of pixels. Going back to All restores the original frame.
  • Double-click fit: the next frame is the fitted view, not a stretched bitmap. The second commit fixes that case.

Tests. The web bundle builds (bend src/web/index.html). I ran the change on a second hub and then on our own hub with the board above. scripts/check.sh isn't affected, since no Bend file changes. The benchmark harness is a standalone page around plot2d.js; I can add it under test/tools/ if you'd like it in the tree.

plot2d.js filled and stroked every layer's Path2D on each pan or zoom
frame. While the view moves, the layers now come from an offscreen bitmap
with a 256 px margin, as the native window keeps a base frame: a pan inside
the margin only places it, and a zoom or a longer pan stretches it until the
view rests for 120 ms, when the layers are drawn again. A view that has not
moved (a new plot, a fade, a layer toggled) still draws straight onto the
page, and the bitmap is made when the page is idle. The corner stats show
how long the layers took to draw.

Headless Chrome (software raster), synthetic plot at 1600x900 (4 layers,
120 zone polygons x 2000 points, 20k tracks, 3k vias), old -> new:
pan 590 -> 16.7 ms/frame, zoom 270 -> 15.7 ms/frame, first paint 65 -> 65
ms, fade 200 -> 200 ms/frame. Rested frames match apart from anti-aliasing
at the viewport edge (0.6% of pixels).
A double-click fit (or a resize refit) jumps the view rather than moving it,
so stretching the last bitmap until the view rests showed the zoomed-in
bitmap scaled out for 120 ms. A fit now draws the layers for its view in the
next frame; the frame after a double-click matches the old drawing.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant