From 92731923db2cb03a3ea7e730ea81c6d653ad2a78 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 1 Nov 2026 16:51:37 -0300 Subject: [PATCH] perf(native): record icon and arc paths once and keep them on the GPU Gio keys its path cache on where a path was recorded, and a path written into the frame's op.Ops is new every frame: every Lucide icon, ring and arc was re-stroked on the CPU, re-triangulated and re-uploaded 30 times a second while a session was busy. Icons now record their paths once per scale in an op.Ops of their own, and StrokeArc keeps a bounded record of the arcs it drew, so the renderer keeps their vertices between frames. Rig, dense window, one busy session, 30fps, 15s CPU profile: 1.61s to 0.79s, stroke.StrokePathCommands gone from the profile. GPU time per frame is unchanged: it is the painting itself, not the geometry. --- CHANGELOG.md | 1 + native/ui/icons/icons.go | 39 ++++++++++++++++++++++++++------ native/ui/icons/icons_test.go | 20 +++++++++++++++++ native/ui/spinner.go | 42 +++++++++++++++++++++++++++++++---- native/ui/spinner_test.go | 20 +++++++++++++++++ 5 files changed, 111 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae457a851..379e1563f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Leave on a page in the session browser that asks before you go no longer reloads the page instead on a busy machine; the browser goes where it was going. - An agent clicking a point in the session browser no longer misses it after you move lich to another monitor, resize the browser pane or change its zoom. Its screenshots are now in the same pixels as its clicks, which a HiDPI monitor or a page zoom had made up to 1.5 times larger, and a click at a point read off a screenshot taken before the pane changed size is refused, telling the agent to take a new one. - lich no longer keeps the GPU busy while a session works. A project tab's spinner redrew the whole window at the monitor's refresh rate, and with sessions busy in more than one project lich held over half of a laptop GPU, taking it from games and everything else. Every spinner, the sidebar's busy ring and a running service's beat now redraw at most 30 times a second, and hold still while the lich window is not focused, moving again when you come back to it. +- The lich window uses half the processor it did while a session works. Every icon, ring and arc on screen was rebuilt from its outline on every frame; each is now built once and kept on the graphics card. ## [0.83.0] - 2026-10-29 diff --git a/native/ui/icons/icons.go b/native/ui/icons/icons.go index e2390ba03..f919b6cab 100644 --- a/native/ui/icons/icons.go +++ b/native/ui/icons/icons.go @@ -30,6 +30,7 @@ import ( "gioui.org/f32" "gioui.org/layout" + "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/unit" @@ -45,6 +46,13 @@ const lucideStroke = 2 // Icon is a parsed icon, drawn at any size in any color. type Icon struct { shapes []shape + // paths holds the shapes' paths at each scale drawn so far, recorded + // once in ops, which is never reset: the renderer keys its vertex cache + // on where a path was recorded, so a path rebuilt every frame is + // re-stroked and re-uploaded every frame, and one recorded once is kept + // on the GPU. Icons are drawn from the window's goroutine alone. + ops op.Ops + paths map[float32][]clip.PathSpec } //go:embed lucide/icon-nodes.json @@ -152,12 +160,13 @@ func fillOnly(d string) (*Icon, error) { func (ic *Icon) Layout(gtx layout.Context, size unit.Dp, c color.NRGBA) layout.Dimensions { px := gtx.Dp(size) scale := float32(px) / viewBox - for _, sh := range ic.shapes { + paths := ic.at(scale) + for i, sh := range ic.shapes { if sh.fill { - paint.FillShape(gtx.Ops, c, clip.Outline{Path: sh.spec(gtx, scale)}.Op()) + paint.FillShape(gtx.Ops, c, clip.Outline{Path: paths[i]}.Op()) } if sh.stroke { - paint.FillShape(gtx.Ops, c, clip.Stroke{Path: sh.spec(gtx, scale), Width: lucideStroke * scale}.Op()) + paint.FillShape(gtx.Ops, c, clip.Stroke{Path: paths[i], Width: lucideStroke * scale}.Op()) } } return layout.Dimensions{Size: image.Pt(px, px)} @@ -167,15 +176,31 @@ func (ic *Icon) Layout(gtx layout.Context, size unit.Dp, c color.NRGBA) layout.D // the fill-current that makes a Lucide glyph read as on, like a set pin. func (ic *Icon) LayoutFilled(gtx layout.Context, size unit.Dp, c color.NRGBA) layout.Dimensions { scale := float32(gtx.Dp(size)) / viewBox - for _, sh := range ic.shapes { - paint.FillShape(gtx.Ops, c, clip.Outline{Path: sh.spec(gtx, scale)}.Op()) + for _, path := range ic.at(scale) { + paint.FillShape(gtx.Ops, c, clip.Outline{Path: path}.Op()) } return ic.Layout(gtx, size, c) } -func (sh shape) spec(gtx layout.Context, scale float32) clip.PathSpec { +// at is the shapes' paths at scale, recorded on first use. +func (ic *Icon) at(scale float32) []clip.PathSpec { + if paths, ok := ic.paths[scale]; ok { + return paths + } + if ic.paths == nil { + ic.paths = map[float32][]clip.PathSpec{} + } + paths := make([]clip.PathSpec, len(ic.shapes)) + for i, sh := range ic.shapes { + paths[i] = sh.spec(&ic.ops, scale) + } + ic.paths[scale] = paths + return paths +} + +func (sh shape) spec(ops *op.Ops, scale float32) clip.PathSpec { var p clip.Path - p.Begin(gtx.Ops) + p.Begin(ops) for _, s := range sh.segs { switch s.op { case 'M': diff --git a/native/ui/icons/icons_test.go b/native/ui/icons/icons_test.go index 2264410e1..9caa4a068 100644 --- a/native/ui/icons/icons_test.go +++ b/native/ui/icons/icons_test.go @@ -192,3 +192,23 @@ func TestSonnerIconsKeepTheirEvenOddHoles(t *testing.T) { } } } + +// An icon's paths are recorded once per scale and handed out again: the +// renderer keeps a path's vertices only while it is the same recording. +func TestIconPathsAreRecordedOncePerScale(t *testing.T) { + ic := Lucide("terminal") + first := ic.at(1) + if len(first) != len(ic.shapes) { + t.Fatalf("got %d paths for %d shapes", len(first), len(ic.shapes)) + } + for i, again := range ic.at(1) { + if again != first[i] { + t.Errorf("shape %d at the same scale came back as a new recording", i) + } + } + for i, other := range ic.at(2) { + if other == first[i] { + t.Errorf("shape %d at another scale reused the first scale's path", i) + } + } +} diff --git a/native/ui/spinner.go b/native/ui/spinner.go index 1ac4cb963..fdf94b670 100644 --- a/native/ui/spinner.go +++ b/native/ui/spinner.go @@ -77,10 +77,44 @@ func (th *Theme) BusyRing(gtx layout.Context, size unit.Dp, fg, track color.NRGB // StrokeArc strokes width wide the arc of radius around center that starts // at the angle start, in radians clockwise from 3 o'clock, and runs sweep. +// The path is recorded once per distinct arc and kept, so a ring drawn the +// same every frame keeps its vertices on the GPU; see Icon. func StrokeArc(gtx layout.Context, center f32.Point, radius, width, start, sweep float32, c color.NRGBA) { + paint.FillShape(gtx.Ops, c, clip.Stroke{Path: arcs.get(arcKey{center, radius, start, sweep}), Width: width}.Op()) +} + +type arcKey struct { + center f32.Point + radius, start float32 + sweep float32 +} + +// arcPaths is StrokeArc's record of paths. A turning arc is a new path every +// frame, so the record is bounded: past arcPathsMax paths it starts over in a +// fresh op.Ops, and the old one lives on for as long as a frame still calls +// into it. +var arcs = arcPaths{paths: map[arcKey]clip.PathSpec{}} + +const arcPathsMax = 1024 + +type arcPaths struct { + ops *op.Ops + paths map[arcKey]clip.PathSpec +} + +func (a *arcPaths) get(k arcKey) clip.PathSpec { + if spec, ok := a.paths[k]; ok { + return spec + } + if a.ops == nil || len(a.paths) >= arcPathsMax { + a.ops = new(op.Ops) + clear(a.paths) + } var p clip.Path - p.Begin(gtx.Ops) - p.MoveTo(f32.Pt(center.X+radius*float32(math.Cos(float64(start))), center.Y+radius*float32(math.Sin(float64(start))))) - p.ArcTo(center, center, sweep) - paint.FillShape(gtx.Ops, c, clip.Stroke{Path: p.End(), Width: width}.Op()) + p.Begin(a.ops) + p.MoveTo(f32.Pt(k.center.X+k.radius*float32(math.Cos(float64(k.start))), k.center.Y+k.radius*float32(math.Sin(float64(k.start))))) + p.ArcTo(k.center, k.center, k.sweep) + spec := p.End() + a.paths[k] = spec + return spec } diff --git a/native/ui/spinner_test.go b/native/ui/spinner_test.go index 773cce171..859e9ac0a 100644 --- a/native/ui/spinner_test.go +++ b/native/ui/spinner_test.go @@ -4,7 +4,9 @@ import ( "testing" "time" + "gioui.org/f32" "gioui.org/io/input" + "gioui.org/op/clip" ) // loopWake draws w once at now and reads the frame it asks for next. @@ -42,3 +44,21 @@ func TestStillThemeHoldsTheFrame(t *testing.T) { } } } + +func TestStrokeArcPathsAreRecordedOnce(t *testing.T) { + a := arcPaths{paths: map[arcKey]clip.PathSpec{}} + k := arcKey{center: f32.Pt(5, 5), radius: 4, sweep: 1} + if a.get(k) != a.get(k) { + t.Error("the same arc came back as a new recording") + } + if a.get(k) == a.get(arcKey{center: f32.Pt(5, 5), radius: 4, sweep: 2}) { + t.Error("another arc reused the first's path") + } + first := a.get(k) + for i := range arcPathsMax { + a.get(arcKey{radius: float32(i), sweep: 1}) + } + if len(a.paths) > arcPathsMax || a.get(k) == first { + t.Errorf("after %d arcs the record holds %d and still hands out the old recording", arcPathsMax, len(a.paths)) + } +}