Canvas level transform rewrite
The core idea is simple: the current painter is treating pan and zoom as if they were per-rectangle work, but they are really global canvas state. Rewriting this to use one canvas transform lets Flutter apply the same viewport mapping to every rect without redoing the math and allocation inside the loop.
Findings¶
Problem¶
Right now BenchmarkPainter does this on every frame for every object in renderObjects:
final rect = Rect.fromLTWH(
object.rect.left * zoom + pan.dx,
object.rect.top * zoom + pan.dy,
object.rect.width * zoom,
object.rect.height * zoom,
);
canvas.drawRect(rect, paint);
That means the paint loop is still paying for:
- per-object transform math,
- per-object
Rectallocation, - repeated work even though
panandzoomare identical for the whole frame.
That partly defeats the value of BenchRenderData, because bench_render_data.dart already precomputes world-space Rects at load time.
What needs to be done¶
The painter should stop constructing screen-space rects in the loop. Instead, it should set the viewport transform once on the canvas, then draw the cached world-space rects directly.
A target-shaped version looks like this:
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.red
..style = PaintingStyle.fill;
canvas.save();
canvas.translate(pan.dx, pan.dy);
canvas.scale(zoom);
for (final object in renderObjects) {
canvas.drawRect(object.rect, paint);
}
canvas.restore();
}
The important point is that object.rect stays in world coordinates. The canvas transform makes those coordinates appear in screen space automatically.
Logic and mechanics behind it¶
The current mapping is:
That is a single viewport transform, not object-specific geometry. In Flutter, Canvas.translate(...) shifts the coordinate space, and Canvas.scale(...) scales the coordinate space. So if the painter does:
then every subsequent drawRect(object.rect, ...) is interpreted as:
which matches the current behavior.
Two details matter here:
canvas.save()/canvas.restore()are required so the transform does not leak beyond this painter. That matters because Flutter may composite multiple things onto shared canvases.- The order matters. For the current viewport math, this should be “translate then scale” so the result matches
world * zoom + pan. Using the wrong order would also scale the pan and break the current zoom behavior.
Why this fits the current architecture¶
This is a painter-level change, not a controller or widget-tree rewrite.
ViewportControllercan keep owningpanandzoom.InteractiveCanvascan keep translating input into viewport updates.BenchRenderDatabecomes more useful, because its cached world-space rects are now used directly during paint.
So this optimization is small in surface area and aligned with the repo’s current boundaries.
Expected benefit¶
This should mainly improve UI-thread cost, because it removes repeated per-frame CPU work in the paint loop.
Expected gains:
- Fewer allocations, since there is no new
Rectper object per frame. - Less per-object math, since zoom/pan are applied once at the canvas level.
- Better use of the existing load-time rect cache.
- Cleaner setup for viewport culling, because culling can operate in world space while drawing also stays in world space.
What it will not fix by itself:
- It does not reduce draw count.
- If 100k rects are all visible, Flutter still has to issue 100k
drawRectcalls.
So this is a strong first optimization, but not a complete answer for the fully zoomed-out worst case.
Summary¶
The transform rewrite is about moving pan/zoom from the inner loop to the canvas itself. That removes avoidable per-frame math and Rect allocation, keeps the existing architecture intact, and makes the current world-space rect cache actually worthwhile. It is a high-confidence first step, but it complements culling rather than replacing it.
If you want, I can next do the same kind of deep dive for viewport culling, including the world-space viewport math it would need.