From 2329c785ea815506158679225df7b84d7cefbd90 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 8 Nov 2026 18:04:23 -0300 Subject: [PATCH 1/3] gpu: redraw only what changed into a framebuffer the renderer keeps Partial redraw relied on EGL_BUFFER_AGE, which NVIDIA 595 on COSMIC reports for a buffer that does not hold that frame, so a whole window showed stale pixels. Keep the frame in an sRGB texture the renderer owns, redraw only the damaged rectangles there and blit it whole to the output; the swap stays a plain full swap. The buffer age and swap-with-damage paths go away. It stays behind GIO_DAMAGE=on. A frame goes straight to the output and whole on backends without FeatureBlitFramebuffer, on a frame that did not clear, and on a translucent clear, whose ground would blend over the kept frame. Paths the rectangles do not reach keep their place in the atlas and only lose their stencil, as the place changes a path's coverage by a rounding. Measured in lich on a real NVIDIA GPU on a private headless compositor, a terminal line updating at 19 fps: 1.2-2.0 ms of GL work per frame, from 2.0-3.6 ms drawn whole. A scrolling log, two thirds of the window dirty, costs 0.1-0.4 ms more than drawn whole. The texture is width x height x 4 bytes. --- app/window.go | 20 +- gpu/damage.go | 103 ++++------ gpu/damage_test.go | 97 ++++----- gpu/gpu.go | 104 +++++++--- gpu/headless/retained_test.go | 299 ++++++++++++++++++++++++++++ gpu/internal/d3d11/d3d11_windows.go | 4 + gpu/internal/driver/driver.go | 5 + gpu/internal/metal/metal_darwin.go | 4 + gpu/internal/opengl/opengl.go | 9 + gpu/internal/vulkan/vulkan.go | 4 + internal/egl/egl.go | 46 ----- internal/egl/egl_unix.go | 25 --- internal/egl/egl_windows.go | 8 - internal/gl/gl_js.go | 4 + internal/gl/gl_windows.go | 6 + 15 files changed, 486 insertions(+), 252 deletions(-) create mode 100644 gpu/headless/retained_test.go diff --git a/app/window.go b/app/window.go index 1366dddb..5ef7bbd8 100644 --- a/app/window.go +++ b/app/window.go @@ -184,11 +184,6 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops, w.gpu = gpu } if w.gpu != nil { - if ager, ok := w.ctx.(interface{ BufferAge() int }); ok { - if g, ok := w.gpu.(interface{ SetBufferAge(int) }); ok { - g.SetBufferAge(ager.BufferAge()) - } - } if err := w.frame(frame, size); err != nil { w.ctx.Unlock() if errors.Is(err, errOutOfDate) { @@ -209,7 +204,7 @@ func (w *Window) validateAndProcess(size image.Point, sync bool, frame *op.Ops, signal() var err error if w.gpu != nil { - err = w.present(size) + err = w.ctx.Present() w.ctx.Unlock() if errors.Is(err, gpu.ErrDeviceLost) { // The frame is already released to the client and can't be @@ -238,19 +233,6 @@ func (w *Window) frame(frame *op.Ops, viewport image.Point) error { return w.gpu.Frame(frame, target, viewport) } -// present shows the frame, as damage to the part the GPU drew when the -// context takes a damage rectangle. -func (w *Window) present(size image.Point) error { - p, ok := w.ctx.(interface { - PresentDamage([]image.Rectangle, image.Point) error - }) - g, okg := w.gpu.(interface{ Damage() []image.Rectangle }) - if !ok || !okg { - return w.ctx.Present() - } - return p.PresentDamage(g.Damage(), size) -} - func (w *Window) processFrame(frame *op.Ops, ack chan<- struct{}) { w.coalesced.framePending = false wrapper := &w.decorations.Ops diff --git a/gpu/damage.go b/gpu/damage.go index ed4ee378..88acb4fb 100644 --- a/gpu/damage.go +++ b/gpu/damage.go @@ -4,7 +4,6 @@ package gpu import ( "image" - "os" "gioui.org/gpu/internal/driver" "gioui.org/internal/f32" @@ -12,27 +11,20 @@ import ( ) // damageTracker finds what a frame changed against the one before it, so -// that only that part of the window is drawn again. It sees a frame as a -// multiset of draw operations: an operation that was not in the last frame, -// or one of the last frame that is gone, dirties its rectangle. The region -// drawn is that, plus what the frames the back buffer is older than -// changed, which the window learns from EGL_EXT_buffer_age. +// that only that part of the retained framebuffer is drawn again. It sees a +// frame as a multiset of draw operations: an operation that was not in the +// last frame, or one of the last frame that is gone, dirties its rectangle. type damageTracker struct { prev, cur map[opSig]int - // history holds the dirty rectangles of the latest frames, newest first. - history [8][]image.Rectangle - age int - viewport image.Point + viewport image.Point // region is what the frame being drawn covers; full says it is the // whole viewport, and no culling applies. region []image.Rectangle full bool - frames int - // clearColor is the last frame's, when its first op was a fill of the - // whole viewport that the renderer turned into the clear: a frame drawn in - // part paints it under its rectangles, and one of another color is whole. + // held says the frame just tracked was drawn into the retained + // framebuffer, which then holds it for the next one. + held bool clearColor f32color.RGBA - cleared bool } // opSig is what identifies a draw operation across frames. @@ -82,77 +74,54 @@ func sigOf(img *imageOp) opSig { return s } -// setAge tells the tracker how many frames old the back buffer the next -// frame draws into is: 1 is the frame just shown, 0 is unknown, and -// unknown means the whole viewport is drawn. -func (t *damageTracker) setAge(age int) { t.age = age } - -// frame reads the frame's operations and settles the region to draw. -func (t *damageTracker) frame(ops []imageOp, layers []opacityLayer, viewport image.Point, cleared bool, clearColor f32color.RGBA) { - if !damageOn { - // Every frame is whole, so hashing its operations would find - // what nothing reads. - t.full = true - t.region = append(t.region[:0], image.Rectangle{Max: viewport}) +// frame reads the frame's operations and settles the region to draw. A +// frame that does not go into the retained framebuffer is whole, and hashing +// its operations would find what nothing reads. +func (t *damageTracker) frame(ops []imageOp, layers []opacityLayer, viewport image.Point, retained bool, clearColor f32color.RGBA) { + whole := image.Rectangle{Max: viewport} + if !retained { + t.held, t.full = false, true + t.region = append(t.region[:0], whole) return } if t.cur == nil { t.cur = map[opSig]int{} } clear(t.cur) - // A layer's opacity lives on the layer, not on the operations it - // composites, so each layer counts as an operation of its own. - all := append(ops[:len(ops):len(ops)], make([]imageOp, 0, len(layers))...) - for _, l := range layers { - all = append(all, imageOp{clip: l.clip, layerOps: -1 - l.depth, material: material{opacity: l.opacity}}) - } - for i := range all { - t.cur[sigOf(&all[i])]++ - } - var dirty []image.Rectangle - for i := range all { - s := sigOf(&all[i]) + dirty := t.region[:0] + mark := func(img *imageOp) { + s := sigOf(img) + t.cur[s]++ if n := t.prev[s]; n > 0 { t.prev[s] = n - 1 - continue + return } - dirty = addRect(dirty, all[i].clip) + dirty = addRect(dirty, img.clip.Intersect(whole)) + } + for i := range ops { + mark(&ops[i]) + } + // A layer's opacity lives on the layer, not on the operations it + // composites, so each layer counts as an operation of its own. + for _, l := range layers { + mark(&imageOp{clip: l.clip, layerOps: -1 - l.depth, material: material{opacity: l.opacity}}) } for s, n := range t.prev { if n > 0 { - dirty = addRect(dirty, s.clip) + dirty = addRect(dirty, s.clip.Intersect(whole)) } } t.prev, t.cur = t.cur, t.prev - whole := image.Rectangle{Max: viewport} - for i := range dirty { - dirty[i] = dirty[i].Intersect(whole) - } - first := t.prev == nil || len(t.prev) == 0 && t.frames == 0 - t.full = !damageOn || first || t.age <= 0 || t.age > len(t.history)+1 || viewport != t.viewport || cleared != t.cleared || clearColor != t.clearColor - t.cleared, t.clearColor = cleared, clearColor - t.viewport = viewport - t.region = append(t.region[:0], dirty...) - for i := 0; !t.full && i < t.age-1; i++ { - for _, r := range t.history[i] { - t.region = addRect(t.region, r) - } - } - copy(t.history[1:], t.history[:]) - t.history[0] = dirty + // A translucent ground blends over what the framebuffer kept instead + // of replacing it, so only a whole frame gets a translucent clear right. + t.full = !t.held || viewport != t.viewport || clearColor != t.clearColor || clearColor.A != 1 + t.held, t.viewport, t.clearColor = true, viewport, clearColor if t.full { - t.region = append(t.region[:0], whole) + dirty = append(dirty[:0], whole) } - t.frames++ - t.age = 0 + t.region = dirty } -// damageOn draws a frame in part where the buffer age allows: GIO_DAMAGE=on -// in the environment. Off, every frame is drawn whole, the way Gio does -// upstream. It is opt-in because a driver can report an age its buffer does -// not hold, and the frame then shows stale pixels: NVIDIA 595 on COSMIC does. -var damageOn = os.Getenv("GIO_DAMAGE") == "on" - // damageRects bounds how many rectangles a frame is drawn in: past it, // the rectangles fold into their bounding box. const damageRects = 16 diff --git a/gpu/damage_test.go b/gpu/damage_test.go index 8b703d1c..78085bfb 100644 --- a/gpu/damage_test.go +++ b/gpu/damage_test.go @@ -4,19 +4,11 @@ package gpu import ( "image" - "os" "testing" "gioui.org/internal/f32color" ) -// TestMain draws in part, as GIO_DAMAGE=on does: the tracker's diff is what -// these tests are about. -func TestMain(m *testing.M) { - damageOn = true - os.Exit(m.Run()) -} - func colorOp(r image.Rectangle, c f32color.RGBA) imageOp { return imageOp{clip: r, material: material{material: materialColor, color: c, opaque: true, opacity: 1}} } @@ -27,68 +19,48 @@ var ( view = image.Pt(100, 100) ) -// freshTracker draws into a buffer one frame old, as a double-buffered -// surface reports it. -func freshTracker() *damageTracker { return &damageTracker{} } - -func frameAt(t *damageTracker, age int, ops []imageOp, view image.Point, clear f32color.RGBA) { - t.setAge(age) +func frameAt(t *damageTracker, ops []imageOp, view image.Point, clear f32color.RGBA) { t.frame(ops, nil, view, true, clear) } func TestFirstFrameIsWhole(t *testing.T) { - tr := freshTracker() - frameAt(tr, 1, []imageOp{colorOp(image.Rect(0, 0, 10, 10), red)}, view, blue) + tr := &damageTracker{} + frameAt(tr, []imageOp{colorOp(image.Rect(0, 0, 10, 10), red)}, view, blue) if !tr.full { t.Fatal("a first frame, with nothing before it, must be drawn whole") } } func TestUnchangedOpsDirtyNothing(t *testing.T) { - tr := freshTracker() + tr := &damageTracker{} ops := []imageOp{colorOp(image.Rect(0, 0, 10, 10), red), colorOp(image.Rect(50, 50, 60, 60), blue)} - frameAt(tr, 1, ops, view, blue) - frameAt(tr, 1, ops, view, blue) + frameAt(tr, ops, view, blue) + frameAt(tr, ops, view, blue) if tr.full || len(tr.region) != 0 { t.Fatalf("the same ops again dirtied %v (full %v), want nothing", tr.region, tr.full) } } func TestNewAndGoneOpsDirtyTheirRectangles(t *testing.T) { - tr := freshTracker() - frameAt(tr, 1, []imageOp{colorOp(image.Rect(0, 0, 10, 10), red), colorOp(image.Rect(50, 50, 60, 60), blue)}, view, blue) - frameAt(tr, 1, []imageOp{colorOp(image.Rect(0, 0, 10, 10), red), colorOp(image.Rect(80, 80, 90, 90), blue)}, view, blue) + tr := &damageTracker{} + frameAt(tr, []imageOp{colorOp(image.Rect(0, 0, 10, 10), red), colorOp(image.Rect(50, 50, 60, 60), blue)}, view, blue) + frameAt(tr, []imageOp{colorOp(image.Rect(0, 0, 10, 10), red), colorOp(image.Rect(80, 80, 90, 90), blue)}, view, blue) want := []image.Rectangle{image.Rect(80, 80, 90, 90), image.Rect(50, 50, 60, 60)} if tr.full || len(tr.region) != 2 || tr.region[0] != want[0] || tr.region[1] != want[1] { t.Fatalf("region %v (full %v), want the op that came and the op that went: %v", tr.region, tr.full, want) } } -func TestOlderBuffersGatherTheFramesBetween(t *testing.T) { - tr := &damageTracker{} - a, b, c := image.Rect(0, 0, 10, 10), image.Rect(20, 20, 30, 30), image.Rect(40, 40, 50, 50) - frameAt(tr, 1, []imageOp{colorOp(a, red)}, view, blue) - frameAt(tr, 1, []imageOp{colorOp(b, red)}, view, blue) - frameAt(tr, 3, []imageOp{colorOp(c, red)}, view, blue) - if tr.full || len(tr.region) != 3 { - t.Fatalf("drawing into a buffer three frames old covered %v (full %v), want this frame's change and the two before it", tr.region, tr.full) - } - frameAt(tr, 0, []imageOp{colorOp(c, red)}, view, blue) - if !tr.full { - t.Fatal("a buffer of unknown age must be drawn whole") - } -} - func TestAChangedViewportOrClearIsWhole(t *testing.T) { - tr := freshTracker() + tr := &damageTracker{} ops := []imageOp{colorOp(image.Rect(0, 0, 10, 10), red)} - frameAt(tr, 1, ops, view, blue) - frameAt(tr, 1, ops, image.Pt(200, 100), blue) + frameAt(tr, ops, view, blue) + frameAt(tr, ops, image.Pt(200, 100), blue) if !tr.full { t.Fatal("a resized viewport must be drawn whole") } - frameAt(tr, 1, ops, image.Pt(200, 100), blue) - frameAt(tr, 1, ops, image.Pt(200, 100), red) + frameAt(tr, ops, image.Pt(200, 100), blue) + frameAt(tr, ops, image.Pt(200, 100), red) if !tr.full { t.Fatal("a frame whose clear color changed must be drawn whole") } @@ -108,36 +80,43 @@ func TestManyRectanglesFoldIntoTheirBox(t *testing.T) { } func TestAFadingLayerDirtiesItsRectangle(t *testing.T) { - tr := freshTracker() + tr := &damageTracker{} ops := []imageOp{colorOp(image.Rect(0, 0, 10, 10), red)} layer := image.Rect(20, 20, 60, 60) - tr.setAge(1) tr.frame(ops, []opacityLayer{{opacity: 1, clip: layer}}, view, true, blue) - tr.setAge(1) tr.frame(ops, []opacityLayer{{opacity: 0.5, clip: layer}}, view, true, blue) if tr.full || len(tr.region) != 1 || tr.region[0] != layer { t.Fatalf("a layer fading dirtied %v (full %v), want its rectangle %v", tr.region, tr.full, layer) } } -func TestWithoutGIODamageEveryFrameIsWhole(t *testing.T) { - damageOn = false - t.Cleanup(func() { damageOn = true }) - tr := freshTracker() +func TestAFrameOutsideTheRetainedFramebufferIsWholeAndUntracked(t *testing.T) { + tr := &damageTracker{} ops := []imageOp{colorOp(image.Rect(0, 0, 10, 10), red)} - frameAt(tr, 1, ops, view, blue) - frameAt(tr, 1, ops, view, blue) + tr.frame(ops, nil, view, false, blue) + if !tr.full || tr.held || tr.cur != nil || len(tr.region) != 1 || tr.region[0] != (image.Rectangle{Max: view}) { + t.Fatalf("full %v, held %v, cur %v, region %v: want a whole, untracked frame", tr.full, tr.held, tr.cur, tr.region) + } +} + +func TestReturningToTheRetainedFramebufferIsWhole(t *testing.T) { + tr := &damageTracker{} + ops := []imageOp{colorOp(image.Rect(0, 0, 10, 10), red)} + frameAt(tr, ops, view, blue) + tr.frame(ops, nil, view, false, blue) + frameAt(tr, ops, view, blue) if !tr.full { - t.Fatal("without GIO_DAMAGE=on a frame must be drawn whole, whatever the buffer age") + t.Fatal("the retained framebuffer missed a frame, so the next one must be whole") } } -func TestWithoutGIODamageTheRegionIsTheViewport(t *testing.T) { - damageOn = false - t.Cleanup(func() { damageOn = true }) - tr := freshTracker() - frameAt(tr, 1, []imageOp{colorOp(image.Rect(0, 0, 10, 10), red)}, view, blue) - if len(tr.region) != 1 || tr.region[0] != (image.Rectangle{Max: view}) { - t.Fatalf("region %v, want the whole viewport %v", tr.region, view) +func TestATranslucentClearIsWhole(t *testing.T) { + tr := &damageTracker{} + clearColor := f32color.RGBA{R: 0.1, A: 0.3} + ops := []imageOp{colorOp(image.Rect(0, 0, 10, 10), red)} + frameAt(tr, ops, view, clearColor) + frameAt(tr, ops, view, clearColor) + if !tr.full { + t.Fatal("a translucent ground would blend over the kept frame, so its frame must be whole") } } diff --git a/gpu/gpu.go b/gpu/gpu.go index adbe7dff..61a27088 100644 --- a/gpu/gpu.go +++ b/gpu/gpu.go @@ -14,6 +14,7 @@ import ( "image" "image/color" "math" + "os" "slices" "time" "unsafe" @@ -49,6 +50,11 @@ type GPU interface { type gpu struct { cache *textureCache damage damageTracker + // retained holds the last frame drawn, redrawn only where it changed + // and blitted whole to the output; nil is a frame drawn straight there. + retained driver.Texture + retainedSize image.Point + retainOn bool profile string timers *timers @@ -138,6 +144,9 @@ type pathOp struct { pathVerts []byte parent *pathOp place placement + // culled keeps the path's place in the atlas but skips its stencil, + // because the frame's rectangles do not reach it. + culled bool } type imageOp struct { @@ -363,6 +372,9 @@ func NewWithDevice(d driver.Device) (GPU, error) { func newGPU(ctx driver.Device) *gpu { g := &gpu{ cache: newTextureCache(), + // Opt-in until the retained framebuffer has proved itself across + // the stacks the fork reaches. + retainOn: os.Getenv("GIO_DAMAGE") == "on", } g.ctx = ctx g.drawOps.pathCache = newOpCache() @@ -376,6 +388,7 @@ func (g *gpu) Clear(col color.NRGBA) { } func (g *gpu) Release() { + g.dropRetained() g.renderer.release() g.drawOps.pathCache.release() g.cache.release() @@ -387,17 +400,37 @@ func (g *gpu) Release() { func (g *gpu) Frame(frameOps *op.Ops, target RenderTarget, viewport image.Point) error { g.collect(viewport, frameOps) - g.damage.frame(g.drawOps.imageOps, g.drawOps.layers, viewport, g.drawOps.clear, g.drawOps.clearColor) + g.damage.frame(g.drawOps.imageOps, g.drawOps.layers, viewport, g.drawOps.clear && g.prepareRetained(viewport), g.drawOps.clearColor) return g.frame(target) } -// SetBufferAge is how many frames old the buffer the next Frame draws into -// is, from EGL_EXT_buffer_age: 1 is the frame just shown. With it, Frame -// draws only what changed since that buffer was shown; 0 draws everything. -func (g *gpu) SetBufferAge(age int) { g.damage.setAge(age) } +// prepareRetained has the retained framebuffer at the viewport's size and +// reports whether there is one. It is sRGB, as the output is or is made to +// look, so a blit moves the frame's bytes unchanged. +func (g *gpu) prepareRetained(viewport image.Point) bool { + if !g.retainOn || !g.ctx.Caps().Features.Has(driver.FeatureBlitFramebuffer) || viewport.X == 0 || viewport.Y == 0 { + return false + } + if g.retained != nil && g.retainedSize == viewport { + return true + } + g.dropRetained() + tex, err := g.ctx.NewTexture(driver.TextureFormatSRGBA, viewport.X, viewport.Y, driver.FilterNearest, driver.FilterNearest, driver.BufferBindingFramebuffer) + if err != nil { + return false + } + g.retained, g.retainedSize = tex, viewport + return true +} + +func (g *gpu) dropRetained() { + if g.retained != nil { + g.retained.Release() + g.retained = nil + } +} -// Damage is the part of the viewport the last Frame drew, for the window -// to pass on as the surface's damage. +// Damage is the part of the viewport the last Frame drew. func (g *gpu) Damage() []image.Rectangle { return g.damage.region } func (g *gpu) collect(viewport image.Point, frameOps *op.Ops) { @@ -422,23 +455,7 @@ func (g *gpu) frame(target RenderTarget) error { for _, img := range g.drawOps.imageOps { expandPathOp(img.path, img.clip) } - if !g.damage.full { - // A layer is drawn whole into its own texture, so its paths stay - // whatever the frame's rectangles. - inLayer := map[*pathOp]bool{} - for _, l := range g.drawOps.layers { - for _, img := range g.drawOps.imageOps[l.opStart:l.opEnd] { - for p := img.path; p != nil; p = p.parent { - inLayer[p] = true - } - } - } - for _, p := range g.drawOps.pathOps { - if !inLayer[p] && !overlapsAny(g.damage.region, p.clip) { - p.clip = image.Rectangle{} - } - } - } + g.cullStencils() g.stencilTimer.begin() g.renderer.packStencils(&g.drawOps.pathOps) g.renderer.stencilClips(g.drawOps.pathCache, g.drawOps.pathOps) @@ -460,7 +477,11 @@ func (g *gpu) frame(target RenderTarget) error { d.Action = driver.LoadActionClear } } - g.ctx.BeginRenderPass(defFBO, d) + out := defFBO + if g.damage.held { + out = g.retained + } + g.ctx.BeginRenderPass(out, d) g.ctx.Viewport(0, 0, viewport.X, viewport.Y) if g.damage.full { g.renderer.damage = image.Rectangle{} @@ -473,15 +494,16 @@ func (g *gpu) frame(target RenderTarget) error { for _, rc := range g.damage.region { g.ctx.Scissor(rc) g.renderer.damage = rc - if g.damage.cleared { - g.renderer.drawOps(false, image.Point{}, g.renderer.blitter.viewport, ground) - } + g.renderer.drawOps(false, image.Point{}, g.renderer.blitter.viewport, ground) g.renderer.drawOps(false, image.Point{}, g.renderer.blitter.viewport, g.drawOps.imageOps) } g.ctx.Scissor(image.Rectangle{}) } g.coverTimer.end() g.ctx.EndRenderPass() + if g.damage.held { + g.ctx.BlitFramebuffer(defFBO, g.retained, viewport) + } g.cleanupTimer.begin() g.cache.frame() g.drawOps.pathCache.frame() @@ -498,6 +520,29 @@ func (g *gpu) frame(target RenderTarget) error { return nil } +// cullStencils leaves unstencilled the paths the frame's rectangles do not +// reach. They stay in the atlas: a path's place changes its rasterized +// coverage by a rounding, and a frame drawn in part would differ from the +// same frame drawn whole by that. +func (g *gpu) cullStencils() { + if g.damage.full { + return + } + // A layer is drawn whole into its own texture, so its paths stay + // whatever the frame's rectangles. + inLayer := map[*pathOp]bool{} + for _, l := range g.drawOps.layers { + for _, img := range g.drawOps.imageOps[l.opStart:l.opEnd] { + for p := img.path; p != nil; p = p.parent { + inLayer[p] = true + } + } + } + for _, p := range g.drawOps.pathOps { + p.culled = !inLayer[p] && !overlapsAny(g.damage.region, p.clip) + } +} + func (g *gpu) Profile() string { return g.profile } @@ -726,6 +771,9 @@ func (r *renderer) stencilClips(pathCache *opCache, ops []*pathOp) { fbo := -1 r.pather.begin(r.packer.sizes) for _, p := range ops { + if p.culled { + continue + } if fbo != p.place.Idx { if fbo != -1 { r.ctx.EndRenderPass() diff --git a/gpu/headless/retained_test.go b/gpu/headless/retained_test.go new file mode 100644 index 00000000..2689f8b6 --- /dev/null +++ b/gpu/headless/retained_test.go @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package headless + +import ( + "bytes" + "fmt" + "image" + "image/color" + "math/rand/v2" + "testing" + + "gioui.org/f32" + "gioui.org/gpu" + "gioui.org/gpu/internal/driver" + "gioui.org/op" + "gioui.org/op/clip" + "gioui.org/op/paint" +) + +const ( + testW, testH = 320, 240 + testSeeds = 12 + testSteps = 30 + dropEvery = 11 +) + +var testClears = []color.NRGBA{ + {R: 30, G: 80, B: 200, A: 255}, + {}, + {R: 20, G: 40, B: 200, A: 77}, +} + +// item is one thing a test scene draws; change perturbs it a little. +type item struct { + kind int + r image.Rectangle + c1, c2 color.NRGBA + opacity float32 + img paint.ImageOp +} + +const ( + itemRect = iota + itemGradient + itemImage + itemLayerRect + pathlessKinds + + itemRRect = iota - 1 + itemStroke + itemLayerPath + allKinds +) + +// pathJitter is how far one channel of a pixel covered by a stencilled path +// can move by where the frame packs that path in the atlas, measured by +// drawing the same scene whole with its paths at other atlas positions: +// anti-aliased coverage rounds differently, by at most one level. +const pathJitter = 1 + +type scene struct { + rnd *rand.Rand + canvas *color.NRGBA + items []item +} + +func newScene(seed uint64, kinds int) *scene { + s := &scene{rnd: rand.New(rand.NewPCG(seed, 2))} + if s.rnd.IntN(2) == 0 { + s.canvas = &color.NRGBA{R: 240, G: 230, B: 210, A: 255} + } + im := image.NewNRGBA(image.Rect(0, 0, 16, 16)) + for i := range im.Pix { + im.Pix[i] = byte(s.rnd.IntN(256)) + } + img := paint.NewImageOp(im) + for range 40 { + s.items = append(s.items, item{kind: s.rnd.IntN(kinds), r: s.rect(), c1: s.color(), c2: s.color(), opacity: s.rnd.Float32(), img: img}) + } + return s +} + +func (s *scene) rect() image.Rectangle { + x, y := s.rnd.IntN(testW-20), s.rnd.IntN(testH-20) + return image.Rect(x, y, x+10+s.rnd.IntN(60), y+10+s.rnd.IntN(60)) +} + +func (s *scene) color() color.NRGBA { + return color.NRGBA{R: byte(s.rnd.IntN(256)), G: byte(s.rnd.IntN(256)), B: byte(s.rnd.IntN(256)), A: byte(s.rnd.IntN(256))} +} + +// change moves or recolors a few items. +func (s *scene) change() { + for range 1 + s.rnd.IntN(3) { + it := &s.items[s.rnd.IntN(len(s.items))] + switch s.rnd.IntN(4) { + case 0: + it.r = s.rect() + case 1: + it.c1 = s.color() + case 2: + it.opacity = s.rnd.Float32() + default: + it.r = it.r.Add(image.Pt(s.rnd.IntN(5)-2, s.rnd.IntN(5)-2)) + } + } +} + +func (s *scene) build(o *op.Ops) { + o.Reset() + if s.canvas != nil { + paint.Fill(o, *s.canvas) + } + for i := range s.items { + s.items[i].draw(o) + } +} + +func (it *item) draw(o *op.Ops) { + switch it.kind { + case itemRect: + paint.FillShape(o, it.c1, clip.Rect(it.r).Op()) + case itemRRect: + paint.FillShape(o, it.c1, clip.RRect{Rect: it.r, SE: 6, SW: 6, NE: 6, NW: 6}.Op(o)) + case itemStroke: + paint.FillShape(o, it.c1, clip.Stroke{Path: clip.Ellipse(it.r).Path(o), Width: 2}.Op()) + case itemGradient: + defer clip.Rect(it.r).Push(o).Pop() + paint.LinearGradientOp{Stop1: f32.Pt(float32(it.r.Min.X), 0), Color1: it.c1, Stop2: f32.Pt(float32(it.r.Max.X), 0), Color2: it.c2}.Add(o) + paint.PaintOp{}.Add(o) + case itemImage: + defer op.Offset(it.r.Min).Push(o).Pop() + it.img.Add(o) + paint.PaintOp{}.Add(o) + case itemLayerRect: + defer paint.PushOpacity(o, it.opacity).Pop() + paint.FillShape(o, it.c1, clip.Rect(it.r).Op()) + paint.FillShape(o, it.c2, clip.Rect(it.r.Inset(4)).Op()) + case itemLayerPath: + defer paint.PushOpacity(o, it.opacity).Pop() + paint.FillShape(o, it.c1, clip.Rect(it.r).Op()) + paint.FillShape(o, it.c2, clip.RRect{Rect: it.r.Inset(4), SE: 3, SW: 3, NE: 3, NW: 3}.Op(o)) + } +} + +func frame(w *Window, clearColor color.NRGBA, o *op.Ops, size image.Point) error { + return contextDo(w.ctx, func() error { + w.gpu.Clear(clearColor) + return w.gpu.Frame(o, w.fboTex, size) + }) +} + +func shot(t *testing.T, w *Window) []byte { + t.Helper() + img := image.NewRGBA(image.Rectangle{Max: w.Size()}) + if err := w.Screenshot(img); err != nil { + t.Fatal(err) + } + return img.Pix +} + +func damageOf(w *Window) []image.Rectangle { + return w.gpu.(interface{ Damage() []image.Rectangle }).Damage() +} + +// checkPartial draws random scenes changing a little at a time into the +// retained framebuffer, each frame only where it changed, and has compare +// judge each one against the same frame drawn whole, over an opaque clear +// and a translucent one. Now and then the retained contents are dropped by +// a resize, which must redraw whole. +func checkPartial(t *testing.T, kinds int, compare func(got, want []byte) string) { + t.Setenv("GIO_DAMAGE", "on") + partial, total := 0, 0 + for seed := range uint64(testSeeds) { + w, whole := openWindow(t), openDirectWindow(t) + sc := newScene(seed, kinds) + clearColor := testClears[seed%uint64(len(testClears))] + ops := new(op.Ops) + for step := range testSteps { + if step > 0 { + sc.change() + } + sc.build(ops) + dropped := step%dropEvery == dropEvery-1 + if dropped { + if err := frame(w, clearColor, ops, w.Size().Sub(image.Pt(5, 5))); err != nil { + t.Fatal(err) + } + } + if err := frame(w, clearColor, ops, w.Size()); err != nil { + t.Fatal(err) + } + if err := frame(whole, clearColor, ops, whole.Size()); err != nil { + t.Fatal(err) + } + got, want := shot(t, w), shot(t, whole) + region := damageOf(w) + isWhole := len(region) == 1 && region[0] == (image.Rectangle{Max: w.Size()}) + if dropped && !isWhole { + t.Fatalf("seed %d step %d: dropped contents drew only %v", seed, step, region) + } + if msg := compare(got, want); msg != "" { + t.Fatalf("seed %d step %d (whole %v, dropped %v, region %v): %s", seed, step, isWhole, dropped, region, msg) + } + total++ + if !isWhole { + partial++ + } + } + w.Release() + whole.Release() + } + if partial < total/3 { + t.Errorf("only %d of %d frames were drawn in part", partial, total) + } +} + +// TestPartialFramesMatchWhole holds a frame drawn in part to the bytes of +// the frame drawn whole. Its scenes have no stencilled paths: a path's +// coverage rounds by its atlas position, which differs between any two +// frames, so whole frames do not match each other byte for byte there +// either. TestPartialPathFrames covers those. +func TestPartialFramesMatchWhole(t *testing.T) { + checkPartial(t, pathlessKinds, func(got, want []byte) string { + if bytes.Equal(got, want) { + return "" + } + return fmt.Sprintf("%d bytes differ from the frame drawn whole", diffCount(got, want)) + }) +} + +func TestPartialPathFramesMatchWhole(t *testing.T) { + checkPartial(t, allKinds, func(got, want []byte) string { + for i := range got { + if d := int(got[i]) - int(want[i]); d > pathJitter || d < -pathJitter { + return fmt.Sprintf("byte %d is %d, the frame drawn whole %d", i, got[i], want[i]) + } + } + return "" + }) +} + +func diffCount(a, b []byte) int { + n := 0 + for i := range a { + if a[i] != b[i] { + n++ + } + } + return n +} + +func openWindow(t *testing.T) *Window { + t.Helper() + w, err := NewWindow(testW, testH) + if err != nil { + t.Skipf("headless windows not supported: %v", err) + } + return w +} + +// directDevice hides the blit from gpu, which then draws every frame whole +// and straight to the output: what the retained framebuffer has to match. +type directDevice struct{ driver.Device } + +func (d directDevice) Caps() driver.Caps { + c := d.Device.Caps() + c.Features &^= driver.FeatureBlitFramebuffer + return c +} + +func openDirectWindow(t *testing.T) *Window { + t.Helper() + ctx, err := newContext() + if err != nil { + t.Skipf("headless windows not supported: %v", err) + } + w := &Window{size: image.Pt(testW, testH), ctx: ctx} + err = contextDo(ctx, func() error { + dev, err := driver.NewDevice(ctx.API()) + if err != nil { + return err + } + w.fboTex, err = dev.NewTexture(driver.TextureFormatSRGBA, testW, testH, driver.FilterNearest, driver.FilterNearest, driver.BufferBindingFramebuffer) + if err != nil { + dev.Release() + return err + } + w.dev = dev + w.gpu, err = gpu.NewWithDevice(directDevice{dev}) + return err + }) + if err != nil { + ctx.Release() + t.Fatal(err) + } + return w +} diff --git a/gpu/internal/d3d11/d3d11_windows.go b/gpu/internal/d3d11/d3d11_windows.go index 016d2034..2890312a 100644 --- a/gpu/internal/d3d11/d3d11_windows.go +++ b/gpu/internal/d3d11/d3d11_windows.go @@ -873,3 +873,7 @@ func sliceOf(ptr uintptr, cap int) []byte { // Scissor is not implemented: a frame is drawn in part only where the // window reports its buffer age, which only the EGL context does. func (b *Backend) Scissor(image.Rectangle) {} + +// BlitFramebuffer is not implemented: these backends draw every frame whole, +// straight to the output. +func (b *Backend) BlitFramebuffer(dst, src driver.Texture, size image.Point) {} diff --git a/gpu/internal/driver/driver.go b/gpu/internal/driver/driver.go index 84f6e69f..dc3db40a 100644 --- a/gpu/internal/driver/driver.go +++ b/gpu/internal/driver/driver.go @@ -40,6 +40,10 @@ type Device interface { // r lifts the limit. Scissor(r image.Rectangle) EndRenderPass() + // BlitFramebuffer copies size pixels from src to dst, pixel for pixel. It + // needs FeatureBlitFramebuffer; a frame is otherwise drawn straight to + // the output. + BlitFramebuffer(dst, src Texture, size image.Point) PrepareTexture(t Texture) BindProgram(p Program) BindPipeline(p Pipeline) @@ -179,6 +183,7 @@ const ( FeatureFloatRenderTargets FeatureCompute FeatureSRGB + FeatureBlitFramebuffer ) const ( diff --git a/gpu/internal/metal/metal_darwin.go b/gpu/internal/metal/metal_darwin.go index a5f18404..eb11844e 100644 --- a/gpu/internal/metal/metal_darwin.go +++ b/gpu/internal/metal/metal_darwin.go @@ -1185,3 +1185,7 @@ func (f *Texture) ImplementsRenderTarget() {} // Scissor is not implemented: a frame is drawn in part only where the // window reports its buffer age, which only the EGL context does. func (b *Backend) Scissor(image.Rectangle) {} + +// BlitFramebuffer is not implemented: these backends draw every frame whole, +// straight to the output. +func (b *Backend) BlitFramebuffer(dst, src driver.Texture, size image.Point) {} diff --git a/gpu/internal/opengl/opengl.go b/gpu/internal/opengl/opengl.go index 875cf022..ed17626c 100644 --- a/gpu/internal/opengl/opengl.go +++ b/gpu/internal/opengl/opengl.go @@ -212,6 +212,9 @@ func newOpenGLDevice(api driver.OpenGL) (driver.Device, error) { if srgbErr == nil { b.feats.Features |= driver.FeatureSRGB } + if runtime.GOOS != "js" && ver[0] >= 3 { + b.feats.Features |= driver.FeatureBlitFramebuffer + } if ffboErr == nil { b.feats.Features |= driver.FeatureFloatRenderTargets } @@ -1242,6 +1245,12 @@ func (b *Backend) BeginRenderPass(tex driver.Texture, desc driver.LoadDesc) { func (b *Backend) EndRenderPass() { } +func (b *Backend) BlitFramebuffer(dst, src driver.Texture, size image.Point) { + b.glstate.bindFramebuffer(b.funcs, gl.READ_FRAMEBUFFER, src.(*texture).ensureFBO()) + b.glstate.bindFramebuffer(b.funcs, gl.DRAW_FRAMEBUFFER, dst.(*texture).ensureFBO()) + b.funcs.BlitFramebuffer(0, 0, size.X, size.Y, 0, 0, size.X, size.Y, gl.COLOR_BUFFER_BIT, gl.NEAREST) +} + func (f *texture) ImplementsRenderTarget() {} func (p *pipeline) Release() { diff --git a/gpu/internal/vulkan/vulkan.go b/gpu/internal/vulkan/vulkan.go index e045a48a..dcaa9465 100644 --- a/gpu/internal/vulkan/vulkan.go +++ b/gpu/internal/vulkan/vulkan.go @@ -1163,3 +1163,7 @@ func (f *Texture) ImplementsRenderTarget() {} // Scissor is not implemented: a frame is drawn in part only where the // window reports its buffer age, which only the EGL context does. func (b *Backend) Scissor(image.Rectangle) {} + +// BlitFramebuffer is not implemented: these backends draw every frame whole, +// straight to the output. +func (b *Backend) BlitFramebuffer(dst, src driver.Texture, size image.Point) {} diff --git a/internal/egl/egl.go b/internal/egl/egl.go index 3b3c6b67..d6ad7b6d 100644 --- a/internal/egl/egl.go +++ b/internal/egl/egl.go @@ -7,7 +7,6 @@ package egl import ( "errors" "fmt" - "image" "runtime" "slices" "strings" @@ -27,11 +26,6 @@ type eglContext struct { visualID int srgb bool surfaceless bool - // bufferAge says EGL_EXT_buffer_age is there, and swapDamage - // EGL_KHR_swap_buffers_with_damage: together they let a frame draw and - // present only what changed. - bufferAge bool - swapDamage bool } var ( @@ -49,7 +43,6 @@ const ( _EGL_CONTEXT_CLIENT_VERSION = 0x3098 _EGL_DEPTH_SIZE = 0x3025 _EGL_GL_COLORSPACE_KHR = 0x309d - _EGL_BUFFER_AGE_EXT = 0x313d _EGL_GL_COLORSPACE_SRGB_KHR = 0x3089 _EGL_GREEN_SIZE = 0x3023 _EGL_EXTENSIONS = 0x3055 @@ -79,43 +72,6 @@ func (c *Context) Present() error { return nil } -// BufferAge is how many frames old the back buffer's content is: 1 is the -// frame just presented. 0 says the age is unknown and the whole surface -// must be drawn. It is read once per frame, after Lock. -func (c *Context) BufferAge() int { - if !c.eglCtx.bufferAge { - return 0 - } - age, ok := eglQuerySurface(c.disp, c.eglSurf, _EGL_BUFFER_AGE_EXT) - if !ok { - return 0 - } - return int(age) -} - -// PresentDamage presents the frame telling the compositor that only rects, -// in pixels from the top left of a surface size high, changed; no rect -// still presents, as a frame callback hangs on the commit. -func (c *Context) PresentDamage(rects []image.Rectangle, size image.Point) error { - if !c.eglCtx.swapDamage { - return c.Present() - } - var flat []_EGLint - for _, r := range rects { - if r.Empty() { - continue - } - flat = append(flat, _EGLint(r.Min.X), _EGLint(size.Y-r.Max.Y), _EGLint(r.Dx()), _EGLint(r.Dy())) - } - if len(flat) == 0 { - flat = []_EGLint{0, 0, 1, 1} - } - if !eglSwapBuffersWithDamage(c.disp, c.eglSurf, flat) { - return fmt.Errorf("eglSwapBuffersWithDamageKHR failed (%x)", eglGetError()) - } - return nil -} - func NewContext(disp NativeDisplayType) (*Context, error) { if err := loadEGL(); err != nil { return nil, err @@ -267,8 +223,6 @@ func createContext(disp _EGLDisplay) (*eglContext, error) { visualID: int(visID), srgb: srgb, surfaceless: hasExtension(exts, "EGL_KHR_surfaceless_context"), - bufferAge: hasExtension(exts, "EGL_EXT_buffer_age"), - swapDamage: hasExtension(exts, "EGL_KHR_swap_buffers_with_damage") && eglLoadSwapBuffersWithDamage(), }, nil } diff --git a/internal/egl/egl_unix.go b/internal/egl/egl_unix.go index bbd66c51..cbd3feb3 100644 --- a/internal/egl/egl_unix.go +++ b/internal/egl/egl_unix.go @@ -15,17 +15,6 @@ package egl #include #include - -static PFNEGLSWAPBUFFERSWITHDAMAGEKHRPROC gio_swapWithDamage; - -static int gio_loadSwapBuffersWithDamage(void) { - gio_swapWithDamage = (PFNEGLSWAPBUFFERSWITHDAMAGEKHRPROC)eglGetProcAddress("eglSwapBuffersWithDamageKHR"); - return gio_swapWithDamage != NULL; -} - -static EGLBoolean gio_eglSwapBuffersWithDamage(EGLDisplay disp, EGLSurface surf, EGLint *rects, EGLint n) { - return gio_swapWithDamage(disp, surf, rects, n); -} */ import "C" @@ -117,17 +106,3 @@ func eglCreateWindowSurface(disp _EGLDisplay, conf _EGLConfig, win NativeWindowT func eglWaitClient() bool { return C.eglWaitClient() == C.EGL_TRUE } - -func eglLoadSwapBuffersWithDamage() bool { - return C.gio_loadSwapBuffersWithDamage() != 0 -} - -func eglSwapBuffersWithDamage(disp _EGLDisplay, surf _EGLSurface, rects []_EGLint) bool { - return C.gio_eglSwapBuffersWithDamage(disp, surf, &rects[0], _EGLint(len(rects)/4)) == C.EGL_TRUE -} - -func eglQuerySurface(disp _EGLDisplay, surf _EGLSurface, attr _EGLint) (_EGLint, bool) { - var val _EGLint - ret := C.eglQuerySurface(disp, surf, attr, &val) - return val, ret == C.EGL_TRUE -} diff --git a/internal/egl/egl_windows.go b/internal/egl/egl_windows.go index 0b2ab530..b6d97eb6 100644 --- a/internal/egl/egl_windows.go +++ b/internal/egl/egl_windows.go @@ -185,11 +185,3 @@ func eglWaitClient() bool { func issue34474KeepAlive(v any) { runtime.KeepAlive(v) } - -func eglLoadSwapBuffersWithDamage() bool { return false } - -func eglSwapBuffersWithDamage(disp _EGLDisplay, surf _EGLSurface, rects []_EGLint) bool { return false } - -func eglQuerySurface(disp _EGLDisplay, surf _EGLSurface, attr _EGLint) (_EGLint, bool) { - return 0, false -} diff --git a/internal/gl/gl_js.go b/internal/gl/gl_js.go index f2d794aa..c10877a1 100644 --- a/internal/gl/gl_js.go +++ b/internal/gl/gl_js.go @@ -272,6 +272,10 @@ func (f *Functions) BindBufferBase(target Enum, index int, b Buffer) { f._bindBufferBase.Invoke(int(target), index, js.Value(b)) } +func (f *Functions) BlitFramebuffer(sx0, sy0, sx1, sy1, dx0, dy0, dx1, dy1 int, mask Enum, filter Enum) { + f.Ctx.Call("blitFramebuffer", sx0, sy0, sx1, sy1, dx0, dy0, dx1, dy1, int(mask), int(filter)) +} + func (f *Functions) BindFramebuffer(target Enum, fb Framebuffer) { f._bindFramebuffer.Invoke(int(target), js.Value(fb)) } diff --git a/internal/gl/gl_windows.go b/internal/gl/gl_windows.go index 9624ed47..e6c12b6e 100644 --- a/internal/gl/gl_windows.go +++ b/internal/gl/gl_windows.go @@ -48,6 +48,7 @@ func loadGLESv2Procs() error { "glDeleteQueries": &_glDeleteQueries, "glDeleteVertexArrays": &_glDeleteVertexArrays, "glCompileShader": &_glCompileShader, + "glBlitFramebuffer": &_glBlitFramebuffer, "glCopyTexSubImage2D": &_glCopyTexSubImage2D, "glGenerateMipmap": &_glGenerateMipmap, "glGenBuffers": &_glGenBuffers, @@ -148,6 +149,7 @@ var ( _glDeleteQueries *windows.Proc _glDeleteVertexArrays *windows.Proc _glCompileShader *windows.Proc + _glBlitFramebuffer *windows.Proc _glCopyTexSubImage2D *windows.Proc _glGenerateMipmap *windows.Proc _glGenBuffers *windows.Proc @@ -324,6 +326,10 @@ func (c *Functions) CompileShader(s Shader) { syscall.Syscall(_glCompileShader.Addr(), 1, uintptr(s.V), 0, 0) } +func (f *Functions) BlitFramebuffer(sx0, sy0, sx1, sy1, dx0, dy0, dx1, dy1 int, mask Enum, filter Enum) { + syscall.Syscall12(_glBlitFramebuffer.Addr(), 10, uintptr(sx0), uintptr(sy0), uintptr(sx1), uintptr(sy1), uintptr(dx0), uintptr(dy0), uintptr(dx1), uintptr(dy1), uintptr(mask), uintptr(filter), 0, 0) +} + func (f *Functions) CopyTexSubImage2D(target Enum, level, xoffset, yoffset, x, y, width, height int) { syscall.Syscall9(_glCopyTexSubImage2D.Addr(), 8, uintptr(target), uintptr(level), uintptr(xoffset), uintptr(yoffset), uintptr(x), uintptr(y), uintptr(width), uintptr(height), 0) } From 1c1140d6421b751611fe959c8527109f5ece6897 Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 8 Nov 2026 18:04:24 -0300 Subject: [PATCH 2/3] gpu: log when the window draws on the CPU --- gpu/internal/opengl/opengl.go | 1 + gpu/internal/opengl/software.go | 31 ++++++++++++++++++++++++++++ gpu/internal/opengl/software_test.go | 23 +++++++++++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 gpu/internal/opengl/software.go create mode 100644 gpu/internal/opengl/software_test.go diff --git a/gpu/internal/opengl/opengl.go b/gpu/internal/opengl/opengl.go index ed17626c..d0c2e06d 100644 --- a/gpu/internal/opengl/opengl.go +++ b/gpu/internal/opengl/opengl.go @@ -215,6 +215,7 @@ func newOpenGLDevice(api driver.OpenGL) (driver.Device, error) { if runtime.GOOS != "js" && ver[0] >= 3 { b.feats.Features |= driver.FeatureBlitFramebuffer } + reportSoftwareRenderer(f.GetString(gl.RENDERER)) if ffboErr == nil { b.feats.Features |= driver.FeatureFloatRenderTargets } diff --git a/gpu/internal/opengl/software.go b/gpu/internal/opengl/software.go new file mode 100644 index 00000000..eab4e613 --- /dev/null +++ b/gpu/internal/opengl/software.go @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package opengl + +import ( + "log" + "strings" + "sync" +) + +var softwareRendererOnce sync.Once + +// reportSoftwareRenderer logs, once per process, that the window draws on +// the CPU: a slow window then has an explanation in its log. +func reportSoftwareRenderer(name string) { + if softwareRenderer(name) { + softwareRendererOnce.Do(func() { + log.Printf("gio: no GPU acceleration, the window draws on the CPU (GL_RENDERER %q)", name) + }) + } +} + +func softwareRenderer(name string) bool { + n := strings.ToLower(name) + for _, s := range []string{"llvmpipe", "softpipe", "swrast", "software rasterizer", "swiftshader"} { + if strings.Contains(n, s) { + return true + } + } + return false +} diff --git a/gpu/internal/opengl/software_test.go b/gpu/internal/opengl/software_test.go new file mode 100644 index 00000000..2f2e4e25 --- /dev/null +++ b/gpu/internal/opengl/software_test.go @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Unlicense OR MIT + +package opengl + +import "testing" + +func TestSoftwareRenderer(t *testing.T) { + cases := map[string]bool{ + "llvmpipe (LLVM 19.1.7, 256 bits)": true, + "softpipe": true, + "Software Rasterizer": true, + "swrast": true, + "Google SwiftShader": true, + "NVIDIA GeForce RTX 3050 6GB Laptop GPU": false, + "Mesa Intel(R) Graphics (ADL GT2)": false, + "AMD Radeon RX 7800 XT (radeonsi, navi32, LLVM 19)": false, + } + for name, want := range cases { + if got := softwareRenderer(name); got != want { + t.Errorf("softwareRenderer(%q) = %v, want %v", name, got, want) + } + } +} From da2b56775624fd0782778e77ef4a297be8318f7e Mon Sep 17 00:00:00 2001 From: omartelo Date: Sun, 8 Nov 2026 18:23:40 -0300 Subject: [PATCH 3/3] gpu: stop naming the buffer age in the Scissor comments The retained framebuffer no longer reads it. --- gpu/headless/retained_test.go | 2 +- gpu/internal/d3d11/d3d11_windows.go | 4 ++-- gpu/internal/metal/metal_darwin.go | 4 ++-- gpu/internal/vulkan/vulkan.go | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/gpu/headless/retained_test.go b/gpu/headless/retained_test.go index 2689f8b6..4a9b902e 100644 --- a/gpu/headless/retained_test.go +++ b/gpu/headless/retained_test.go @@ -220,7 +220,7 @@ func checkPartial(t *testing.T, kinds int, compare func(got, want []byte) string // the frame drawn whole. Its scenes have no stencilled paths: a path's // coverage rounds by its atlas position, which differs between any two // frames, so whole frames do not match each other byte for byte there -// either. TestPartialPathFrames covers those. +// either. TestPartialPathFramesMatchWhole covers those. func TestPartialFramesMatchWhole(t *testing.T) { checkPartial(t, pathlessKinds, func(got, want []byte) string { if bytes.Equal(got, want) { diff --git a/gpu/internal/d3d11/d3d11_windows.go b/gpu/internal/d3d11/d3d11_windows.go index 2890312a..6ee793eb 100644 --- a/gpu/internal/d3d11/d3d11_windows.go +++ b/gpu/internal/d3d11/d3d11_windows.go @@ -870,8 +870,8 @@ func sliceOf(ptr uintptr, cap int) []byte { return unsafe.Slice((*byte)(unsafe.Pointer(ptr)), cap) } -// Scissor is not implemented: a frame is drawn in part only where the -// window reports its buffer age, which only the EGL context does. +// Scissor is not implemented: these backends draw every frame whole, +// straight to the output. func (b *Backend) Scissor(image.Rectangle) {} // BlitFramebuffer is not implemented: these backends draw every frame whole, diff --git a/gpu/internal/metal/metal_darwin.go b/gpu/internal/metal/metal_darwin.go index eb11844e..cccbcdb6 100644 --- a/gpu/internal/metal/metal_darwin.go +++ b/gpu/internal/metal/metal_darwin.go @@ -1182,8 +1182,8 @@ func (b *Backend) endEncoder() { func (f *Texture) ImplementsRenderTarget() {} -// Scissor is not implemented: a frame is drawn in part only where the -// window reports its buffer age, which only the EGL context does. +// Scissor is not implemented: these backends draw every frame whole, +// straight to the output. func (b *Backend) Scissor(image.Rectangle) {} // BlitFramebuffer is not implemented: these backends draw every frame whole, diff --git a/gpu/internal/vulkan/vulkan.go b/gpu/internal/vulkan/vulkan.go index dcaa9465..a170e0b5 100644 --- a/gpu/internal/vulkan/vulkan.go +++ b/gpu/internal/vulkan/vulkan.go @@ -1160,8 +1160,8 @@ func mapErr(err error) error { func (f *Texture) ImplementsRenderTarget() {} -// Scissor is not implemented: a frame is drawn in part only where the -// window reports its buffer age, which only the EGL context does. +// Scissor is not implemented: these backends draw every frame whole, +// straight to the output. func (b *Backend) Scissor(image.Rectangle) {} // BlitFramebuffer is not implemented: these backends draw every frame whole,