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..4a9b902e --- /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. TestPartialPathFramesMatchWhole 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..6ee793eb 100644 --- a/gpu/internal/d3d11/d3d11_windows.go +++ b/gpu/internal/d3d11/d3d11_windows.go @@ -870,6 +870,10 @@ 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, +// 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..cccbcdb6 100644 --- a/gpu/internal/metal/metal_darwin.go +++ b/gpu/internal/metal/metal_darwin.go @@ -1182,6 +1182,10 @@ 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, +// 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..d0c2e06d 100644 --- a/gpu/internal/opengl/opengl.go +++ b/gpu/internal/opengl/opengl.go @@ -212,6 +212,10 @@ 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 + } + reportSoftwareRenderer(f.GetString(gl.RENDERER)) if ffboErr == nil { b.feats.Features |= driver.FeatureFloatRenderTargets } @@ -1242,6 +1246,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/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) + } + } +} diff --git a/gpu/internal/vulkan/vulkan.go b/gpu/internal/vulkan/vulkan.go index e045a48a..a170e0b5 100644 --- a/gpu/internal/vulkan/vulkan.go +++ b/gpu/internal/vulkan/vulkan.go @@ -1160,6 +1160,10 @@ 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, +// 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) }