detached 657f658

perf: cut steady-frame allocations from 241 to 48 (#8)

* perf(text): hash glyph variations without allocating

* perf(bidi): resolve left-to-right-only text without running UAX#9

* perf(vkdevice): reuse per-frame Vulkan scratch structs and signal semaphore

Keep submit/record info structs, out-parameters and the readback copy
region in per-Frame/per-BinaryFence storage, since the purego bindings
heap-allocate every pointer argument. Reuse the acquire semaphore after
its SYNC_FD export (copy transference resets it) instead of destroying
and recreating it each frame. Reuse the image-cache batch slice.

* perf(session): reuse release-wait semaphores and export via Frame

Keep a consumed, unsignaled release-wait semaphore per slot instead of
creating and destroying one per release, and export the acquire SYNC_FD
through Frame.ExportSignal so the frame knows its semaphore is reusable.

* perf(text): reuse measure scratch buffers and memoize ranked faces

* fix(vkdevice): clear the instance mapping pointer after unmapping

* refactor(text): harden scratch iterator reuse and cap memoized face lists

* perf(ui): reuse elements, identities and layout nodes across frames

* perf(layout): size result children once per node

* perf(ui): reuse the frame struct across builds

* perf(ui): lower the steady-frame allocation guard to 48 and document it
commit
657f6586a4bcf3d1815c319bee92e08019a155ae
author
bnema · · 2026-10-01 20:20 UTC
committer
GitHub · · 2026-10-01 20:20 UTC
parent
5fd5066
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.5.1
25 files · +646 -150
M docs/performance.md +1 -1
@@ -29,7 +29,7 @@ The demo-frame benchmark builds the light, comfortable document workspace with a
2929
3030 Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. Layout1000 measures about 36% slower than the earlier 656 µs run; allocation count stays at 1,027. A per-layout node/width cache increased time and retained allocation in a trial and is not used. LayoutPaintTree stays near its previous cost. These are correctness/performance tradeoffs, not a claim of universal speed improvement.
3131
32-`TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measured 244 allocations per steady Render+Released cycle on 2026-10-01 (the guard fails above that baseline), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions.
32+`TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measures 48 allocations per steady Render+Released cycle on radv (the guard fails above that baseline; the target is 0), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. `BenchmarkRendererSteadyFrame` profiles the same cycle. Most of the remainder is one uncached text measurement (go-text segmentation and wrapping) plus per-node layout results; `TestAllocMeasureMissShort` bounds the uncached measurement. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions.
3333
3434 Frame construction limits allocation churn through:
3535
@@ -29,7 +29,7 @@ The demo-frame benchmark builds the light, comfortable document workspace with a
29 29
30 Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. Layout1000 measures about 36% slower than the earlier 656 µs run; allocation count stays at 1,027. A per-layout node/width cache increased time and retained allocation in a trial and is not used. LayoutPaintTree stays near its previous cost. These are correctness/performance tradeoffs, not a claim of universal speed improvement.30 Natural container measurement prevents nested flex text from collapsing, at a CPU cost that grows with nesting depth. Layout1000 measures about 36% slower than the earlier 656 µs run; allocation count stays at 1,027. A per-layout node/width cache increased time and retained allocation in a trial and is not used. LayoutPaintTree stays near its previous cost. These are correctness/performance tradeoffs, not a claim of universal speed improvement.
31 31
32-`TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measured 244 allocations per steady Render+Released cycle on 2026-10-01 (the guard fails above that baseline), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions.32+`TestAllocDemoFrame` checks the 1,100 allocs/op limit with `testing.AllocsPerRun`. `TestAllocRendererSteadyFrame` measures `Renderer.Render` for a view whose text changes through `TextInt`; it measures 48 allocations per steady Render+Released cycle on radv (the guard fails above that baseline; the target is 0), needs a GPU, and is skipped unless `NEFERGUI_RENDER_NODE` is set. `BenchmarkRendererSteadyFrame` profiles the same cycle. Most of the remainder is one uncached text measurement (go-text segmentation and wrapping) plus per-node layout results; `TestAllocMeasureMissShort` bounds the uncached measurement. Focused tests also check constant quad allocation, bounded paint allocation, allocation-free warm list-buffer reuse and allocation-free idle wait results (the real-ioctl variant is opt-in via `NEFERGUI_RENDER_NODE`). The other table budgets are reference targets, not automated assertions.
33 33
34 Frame construction limits allocation churn through:34 Frame construction limits allocation churn through:
35 35
M internal/bidi/levels.go +26 -0
@@ -47,6 +47,14 @@ func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8,
4747 }
4848 prev = end
4949 }
50+ if base != RTL && leftToRightOnly(text) {
51+ return make([]uint8, len(text)), 0, nil
52+ }
53+ return resolveFull(text, base, lineBreaks)
54+}
55+
56+// resolveFull runs the complete UAX#9 algorithm on validated input.
57+func resolveFull(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8, error) {
5058 classes := make([]Class, len(text))
5159 pairTypes := make([]bracketType, len(text))
5260 pairValues := make([]rune, len(text))
@@ -92,6 +100,24 @@ func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8,
92100 return out, uint8(p.embeddingLevel), nil
93101 }
94102
103+// leftToRightOnly reports whether every rune resolves to level 0 in a level-0
104+// paragraph: with no R, AL, AN, explicit formatting or BN, P2/P3 pick level 0,
105+// W7 turns EN into L (sos is L), neutrals resolve to L and I1 raises nothing.
106+// B is left to the full algorithm, which validates its position.
107+func leftToRightOnly(text []rune) bool {
108+ for _, r := range text {
109+ if r < 0x80 && r >= 0x20 && r != 0x7f {
110+ continue // printable ASCII: L, EN, ES, ET, CS, WS or ON
111+ }
112+ prop, _ := LookupRune(r)
113+ switch prop.Class() {
114+ case R, AL, AN, B, BN, LRO, RLO, LRE, RLE, PDF, LRI, RLI, FSI, PDI:
115+ return false
116+ }
117+ }
118+ return true
119+}
120+
95121 // Reorder returns a visual-to-logical rune map per line using L2; X9-removed
96122 // formatting characters are excluded from the result. Levels must be the
97123 // per-line L1 output of Resolve. Consumers may group neighboring indices into
@@ -47,6 +47,14 @@ func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8,
47 }47 }
48 prev = end48 prev = end
49 }49 }
50+ if base != RTL && leftToRightOnly(text) {
51+ return make([]uint8, len(text)), 0, nil
52+ }
53+ return resolveFull(text, base, lineBreaks)
54+}
55+
56+// resolveFull runs the complete UAX#9 algorithm on validated input.
57+func resolveFull(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8, error) {
50 classes := make([]Class, len(text))58 classes := make([]Class, len(text))
51 pairTypes := make([]bracketType, len(text))59 pairTypes := make([]bracketType, len(text))
52 pairValues := make([]rune, len(text))60 pairValues := make([]rune, len(text))
@@ -92,6 +100,24 @@ func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8,
92 return out, uint8(p.embeddingLevel), nil100 return out, uint8(p.embeddingLevel), nil
93 }101 }
94 102
103+// leftToRightOnly reports whether every rune resolves to level 0 in a level-0
104+// paragraph: with no R, AL, AN, explicit formatting or BN, P2/P3 pick level 0,
105+// W7 turns EN into L (sos is L), neutrals resolve to L and I1 raises nothing.
106+// B is left to the full algorithm, which validates its position.
107+func leftToRightOnly(text []rune) bool {
108+ for _, r := range text {
109+ if r < 0x80 && r >= 0x20 && r != 0x7f {
110+ continue // printable ASCII: L, EN, ES, ET, CS, WS or ON
111+ }
112+ prop, _ := LookupRune(r)
113+ switch prop.Class() {
114+ case R, AL, AN, B, BN, LRO, RLO, LRE, RLE, PDF, LRI, RLI, FSI, PDI:
115+ return false
116+ }
117+ }
118+ return true
119+}
120+
95 // Reorder returns a visual-to-logical rune map per line using L2; X9-removed121 // Reorder returns a visual-to-logical rune map per line using L2; X9-removed
96 // formatting characters are excluded from the result. Levels must be the122 // formatting characters are excluded from the result. Levels must be the
97 // per-line L1 output of Resolve. Consumers may group neighboring indices into123 // per-line L1 output of Resolve. Consumers may group neighboring indices into
A internal/bidi/levels_test.go +56 -0
@@ -0,0 +1,56 @@
1+package bidi
2+
3+import (
4+ "slices"
5+ "testing"
6+ "unicode"
7+)
8+
9+// The left-to-right fast path must match the full algorithm on every rune
10+// it accepts, alone and next to other accepted runes.
11+func TestLeftToRightOnlyMatchesFullAlgorithm(t *testing.T) {
12+ var accepted []rune
13+ for r := rune(0); r <= unicode.MaxRune; r++ {
14+ if (r >= 0xd800 && r <= 0xdfff) || !leftToRightOnly([]rune{r}) {
15+ continue
16+ }
17+ accepted = append(accepted, r)
18+ }
19+ if len(accepted) < 1000 {
20+ t.Fatalf("only %d runes accepted", len(accepted))
21+ }
22+ check := func(text []rune) {
23+ t.Helper()
24+ breaks := []int{len(text)}
25+ if len(text) > 1 {
26+ breaks = []int{1, len(text)} // L1 on a line end inside the paragraph
27+ }
28+ for _, base := range []BaseDirection{Auto, LTR} {
29+ fast, fastBase, err := Resolve(text, base, breaks)
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ full, fullBase, err := resolveFull(text, base, breaks)
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if fastBase != fullBase || !slices.Equal(fast, full) {
38+ t.Fatalf("%U base %d: fast %v/%d, full %v/%d", text, base, fast, fastBase, full, fullBase)
39+ }
40+ }
41+ }
42+ for _, r := range accepted {
43+ check([]rune{r})
44+ check([]rune{'1', r, '2'})
45+ check([]rune{'(', r, ' ', '1', ')'})
46+ }
47+ check([]rune("Value: 42, (x+y) = -3.5% \t#1"))
48+}
49+
50+func TestAllocResolveLeftToRight(t *testing.T) {
51+ text := []rune("Value: 42")
52+ breaks := []int{len(text)}
53+ if got := testing.AllocsPerRun(100, func() { _, _, _ = Resolve(text, Auto, breaks) }); got > 1 {
54+ t.Fatalf("allocs = %v, want at most 1 (the result)", got)
55+ }
56+}
@@ -0,0 +1,56 @@
1+package bidi
2+
3+import (
4+ "slices"
5+ "testing"
6+ "unicode"
7+)
8+
9+// The left-to-right fast path must match the full algorithm on every rune
10+// it accepts, alone and next to other accepted runes.
11+func TestLeftToRightOnlyMatchesFullAlgorithm(t *testing.T) {
12+ var accepted []rune
13+ for r := rune(0); r <= unicode.MaxRune; r++ {
14+ if (r >= 0xd800 && r <= 0xdfff) || !leftToRightOnly([]rune{r}) {
15+ continue
16+ }
17+ accepted = append(accepted, r)
18+ }
19+ if len(accepted) < 1000 {
20+ t.Fatalf("only %d runes accepted", len(accepted))
21+ }
22+ check := func(text []rune) {
23+ t.Helper()
24+ breaks := []int{len(text)}
25+ if len(text) > 1 {
26+ breaks = []int{1, len(text)} // L1 on a line end inside the paragraph
27+ }
28+ for _, base := range []BaseDirection{Auto, LTR} {
29+ fast, fastBase, err := Resolve(text, base, breaks)
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ full, fullBase, err := resolveFull(text, base, breaks)
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if fastBase != fullBase || !slices.Equal(fast, full) {
38+ t.Fatalf("%U base %d: fast %v/%d, full %v/%d", text, base, fast, fastBase, full, fullBase)
39+ }
40+ }
41+ }
42+ for _, r := range accepted {
43+ check([]rune{r})
44+ check([]rune{'1', r, '2'})
45+ check([]rune{'(', r, ' ', '1', ')'})
46+ }
47+ check([]rune("Value: 42, (x+y) = -3.5% \t#1"))
48+}
49+
50+func TestAllocResolveLeftToRight(t *testing.T) {
51+ text := []rune("Value: 42")
52+ breaks := []int{len(text)}
53+ if got := testing.AllocsPerRun(100, func() { _, _, _ = Resolve(text, Auto, breaks) }); got > 1 {
54+ t.Fatalf("allocs = %v, want at most 1 (the result)", got)
55+ }
56+}
M internal/layout/layout.go +11 -2
@@ -580,7 +580,7 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
580580 if absolute {
581581 cw, chh = safe(ch.Rect.X+ch.Rect.W), safe(ch.Rect.Y+ch.Rect.H)
582582 }
583- r.Children = append(r.Children, child)
583+ r.Children = appendChild(r.Children, child, len(n.Children))
584584 r.ContentSize.W = math.Max(r.ContentSize.W, cw)
585585 if n.Kind == Stack {
586586 r.ContentSize.H = math.Max(r.ContentSize.H, chh)
@@ -592,6 +592,15 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
592592 return nil
593593 }
594594
595+// appendChild appends child, sizing the first allocation for the n input
596+// children so a node's result children cost one allocation, not a growth chain.
597+func appendChild(children []*Result, child *Result, n int) []*Result {
598+ if children == nil {
599+ children = make([]*Result, 0, n)
600+ }
601+ return append(children, child)
602+}
603+
595604 type flexItem struct {
596605 node *Node
597606 base, size, cross, grow, shrink, marginMain, marginCross float64 // sizes are border boxes
@@ -880,7 +889,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
880889 return err
881890 }
882891 if child != nil {
883- r.Children = append(r.Children, child)
892+ r.Children = appendChild(r.Children, child, len(n.Children))
884893 cw, ch := envelope(child)
885894 r.ContentSize.W = math.Max(r.ContentSize.W, child.Border.X-r.Content.X+cw)
886895 r.ContentSize.H = math.Max(r.ContentSize.H, child.Border.Y-r.Content.Y+ch)
@@ -580,7 +580,7 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
580 if absolute {580 if absolute {
581 cw, chh = safe(ch.Rect.X+ch.Rect.W), safe(ch.Rect.Y+ch.Rect.H)581 cw, chh = safe(ch.Rect.X+ch.Rect.W), safe(ch.Rect.Y+ch.Rect.H)
582 }582 }
583- r.Children = append(r.Children, child)583+ r.Children = appendChild(r.Children, child, len(n.Children))
584 r.ContentSize.W = math.Max(r.ContentSize.W, cw)584 r.ContentSize.W = math.Max(r.ContentSize.W, cw)
585 if n.Kind == Stack {585 if n.Kind == Stack {
586 r.ContentSize.H = math.Max(r.ContentSize.H, chh)586 r.ContentSize.H = math.Max(r.ContentSize.H, chh)
@@ -592,6 +592,15 @@ func (c context) children(n *Node, r *Result, s css.Style, depth int) error {
592 return nil592 return nil
593 }593 }
594 594
595+// appendChild appends child, sizing the first allocation for the n input
596+// children so a node's result children cost one allocation, not a growth chain.
597+func appendChild(children []*Result, child *Result, n int) []*Result {
598+ if children == nil {
599+ children = make([]*Result, 0, n)
600+ }
601+ return append(children, child)
602+}
603+
595 type flexItem struct {604 type flexItem struct {
596 node *Node605 node *Node
597 base, size, cross, grow, shrink, marginMain, marginCross float64 // sizes are border boxes606 base, size, cross, grow, shrink, marginMain, marginCross float64 // sizes are border boxes
@@ -880,7 +889,7 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error {
880 return err889 return err
881 }890 }
882 if child != nil {891 if child != nil {
883- r.Children = append(r.Children, child)892+ r.Children = appendChild(r.Children, child, len(n.Children))
884 cw, ch := envelope(child)893 cw, ch := envelope(child)
885 r.ContentSize.W = math.Max(r.ContentSize.W, child.Border.X-r.Content.X+cw)894 r.ContentSize.W = math.Max(r.ContentSize.W, child.Border.X-r.Content.X+cw)
886 r.ContentSize.H = math.Max(r.ContentSize.H, child.Border.Y-r.Content.Y+ch)895 r.ContentSize.H = math.Max(r.ContentSize.H, child.Border.Y-r.Content.Y+ch)
M internal/presentation/session/slot.go +17 -1
@@ -20,6 +20,7 @@ type Slot struct {
2020 Wait *vkdevice.BinaryFence
2121 PendingWait *vkdevice.BinaryFence
2222 WaitInFlight bool
23+ spare *vkdevice.BinaryFence // consumed, unsignaled wait semaphore kept for reuse
2324 ReleaseHandle uint32
2425 ReleaseFD int // eventfd armed on each commit's release point
2526 ReleaseExport int // exported syncobj fd of the release timeline
@@ -67,12 +68,27 @@ func installReleaseWait(slot *Slot, next *vkdevice.BinaryFence, ready func() (bo
6768 }
6869 }
6970 if slot.Wait != nil {
70- slot.Wait.Close()
71+ if slot.WaitInFlight {
72+ slot.recycle(slot.Wait) // submission done: the wait was consumed
73+ } else {
74+ slot.Wait.Close() // never waited on: its imported payload is still signaled
75+ }
7176 }
7277 slot.Wait, slot.PendingWait, slot.WaitInFlight = next, nil, false
7378 return true, nil
7479 }
7580
81+// recycle keeps a release-wait semaphore for reuse. The caller guarantees the
82+// submission that waited on it has completed: a wait operation consumes the
83+// temporarily imported payload and leaves the semaphore unsignaled and idle.
84+func (s *Slot) recycle(w *vkdevice.BinaryFence) {
85+ if s.spare != nil {
86+ w.Close()
87+ return
88+ }
89+ s.spare = w
90+}
91+
7692 type retiredPipelines struct {
7793 generation uint64
7894 list *vkdevice.Pipeline
@@ -20,6 +20,7 @@ type Slot struct {
20 Wait *vkdevice.BinaryFence20 Wait *vkdevice.BinaryFence
21 PendingWait *vkdevice.BinaryFence21 PendingWait *vkdevice.BinaryFence
22 WaitInFlight bool22 WaitInFlight bool
23+ spare *vkdevice.BinaryFence // consumed, unsignaled wait semaphore kept for reuse
23 ReleaseHandle uint3224 ReleaseHandle uint32
24 ReleaseFD int // eventfd armed on each commit's release point25 ReleaseFD int // eventfd armed on each commit's release point
25 ReleaseExport int // exported syncobj fd of the release timeline26 ReleaseExport int // exported syncobj fd of the release timeline
@@ -67,12 +68,27 @@ func installReleaseWait(slot *Slot, next *vkdevice.BinaryFence, ready func() (bo
67 }68 }
68 }69 }
69 if slot.Wait != nil {70 if slot.Wait != nil {
70- slot.Wait.Close()71+ if slot.WaitInFlight {
72+ slot.recycle(slot.Wait) // submission done: the wait was consumed
73+ } else {
74+ slot.Wait.Close() // never waited on: its imported payload is still signaled
75+ }
71 }76 }
72 slot.Wait, slot.PendingWait, slot.WaitInFlight = next, nil, false77 slot.Wait, slot.PendingWait, slot.WaitInFlight = next, nil, false
73 return true, nil78 return true, nil
74 }79 }
75 80
81+// recycle keeps a release-wait semaphore for reuse. The caller guarantees the
82+// submission that waited on it has completed: a wait operation consumes the
83+// temporarily imported payload and leaves the semaphore unsignaled and idle.
84+func (s *Slot) recycle(w *vkdevice.BinaryFence) {
85+ if s.spare != nil {
86+ w.Close()
87+ return
88+ }
89+ s.spare = w
90+}
91+
76 type retiredPipelines struct {92 type retiredPipelines struct {
77 generation uint6493 generation uint64
78 list *vkdevice.Pipeline94 list *vkdevice.Pipeline
M internal/presentation/session/target.go +21 -4
@@ -221,6 +221,7 @@ func (t *Target) closeSlot(slot *Slot) error {
221221 if slot.PendingWait != nil {
222222 slot.PendingWait.Close()
223223 }
224+ slot.spare.Close()
224225 if slot.Wait != nil {
225226 slot.Wait.Close()
226227 }
@@ -315,7 +316,9 @@ func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command,
315316 return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
316317 }
317318 if slot.Wait != nil && slot.WaitInFlight {
318- slot.Wait.Close()
319+ // Draw selected this slot only after its frame was Ready, so the
320+ // submission that waited on Wait has completed and consumed it.
321+ slot.recycle(slot.Wait)
319322 slot.Wait = nil
320323 slot.WaitInFlight = false
321324 }
@@ -331,7 +334,7 @@ func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command,
331334 }
332335 slot.First = false
333336 // From here the buffer is committed to the pipeline; failures are fatal.
334- fd, err := slot.Frame.Signal.Export()
337+ fd, err := slot.Frame.ExportSignal()
335338 if err != nil {
336339 return false, t.fail(err)
337340 }
@@ -419,6 +422,16 @@ func drainEventFD(fd int) error {
419422 return nil
420423 }
421424
425+// acquireWait returns an unsignaled binary semaphore for a release wait,
426+// reusing the slot's spare instead of creating one per frame.
427+func (t *Target) acquireWait(slot *Slot) (*vkdevice.BinaryFence, error) {
428+ if w := slot.spare; w != nil {
429+ slot.spare = nil
430+ return w, nil
431+ }
432+ return t.Device.NewBinaryFence(false)
433+}
434+
422435 func (t *Target) release(slot *Slot, point uint64) error {
423436 b := slot.Buffer
424437 // An older completion cannot authorize a newer commit of the same image.
@@ -433,7 +446,7 @@ func (t *Target) release(slot *Slot, point uint64) error {
433446 if err != nil {
434447 return err
435448 }
436- wait, err := t.Device.NewBinaryFence(false)
449+ wait, err := t.acquireWait(slot)
437450 if err != nil {
438451 _ = unix.Close(fd)
439452 return err
@@ -476,7 +489,11 @@ func (t *Target) finishPending() (bool, error) {
476489 continue
477490 }
478491 if slot.Wait != nil {
479- slot.Wait.Close()
492+ if slot.WaitInFlight {
493+ slot.recycle(slot.Wait) // submission is Ready: the wait was consumed
494+ } else {
495+ slot.Wait.Close()
496+ }
480497 }
481498 slot.Wait, slot.PendingWait, slot.WaitInFlight = slot.PendingWait, nil, false
482499 if err := t.Pool.Reuse(slot.Buffer, true); err != nil {
@@ -221,6 +221,7 @@ func (t *Target) closeSlot(slot *Slot) error {
221 if slot.PendingWait != nil {221 if slot.PendingWait != nil {
222 slot.PendingWait.Close()222 slot.PendingWait.Close()
223 }223 }
224+ slot.spare.Close()
224 if slot.Wait != nil {225 if slot.Wait != nil {
225 slot.Wait.Close()226 slot.Wait.Close()
226 }227 }
@@ -315,7 +316,9 @@ func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command,
315 return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)316 return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped)
316 }317 }
317 if slot.Wait != nil && slot.WaitInFlight {318 if slot.Wait != nil && slot.WaitInFlight {
318- slot.Wait.Close()319+ // Draw selected this slot only after its frame was Ready, so the
320+ // submission that waited on Wait has completed and consumed it.
321+ slot.recycle(slot.Wait)
319 slot.Wait = nil322 slot.Wait = nil
320 slot.WaitInFlight = false323 slot.WaitInFlight = false
321 }324 }
@@ -331,7 +334,7 @@ func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command,
331 }334 }
332 slot.First = false335 slot.First = false
333 // From here the buffer is committed to the pipeline; failures are fatal.336 // From here the buffer is committed to the pipeline; failures are fatal.
334- fd, err := slot.Frame.Signal.Export()337+ fd, err := slot.Frame.ExportSignal()
335 if err != nil {338 if err != nil {
336 return false, t.fail(err)339 return false, t.fail(err)
337 }340 }
@@ -419,6 +422,16 @@ func drainEventFD(fd int) error {
419 return nil422 return nil
420 }423 }
421 424
425+// acquireWait returns an unsignaled binary semaphore for a release wait,
426+// reusing the slot's spare instead of creating one per frame.
427+func (t *Target) acquireWait(slot *Slot) (*vkdevice.BinaryFence, error) {
428+ if w := slot.spare; w != nil {
429+ slot.spare = nil
430+ return w, nil
431+ }
432+ return t.Device.NewBinaryFence(false)
433+}
434+
422 func (t *Target) release(slot *Slot, point uint64) error {435 func (t *Target) release(slot *Slot, point uint64) error {
423 b := slot.Buffer436 b := slot.Buffer
424 // An older completion cannot authorize a newer commit of the same image.437 // An older completion cannot authorize a newer commit of the same image.
@@ -433,7 +446,7 @@ func (t *Target) release(slot *Slot, point uint64) error {
433 if err != nil {446 if err != nil {
434 return err447 return err
435 }448 }
436- wait, err := t.Device.NewBinaryFence(false)449+ wait, err := t.acquireWait(slot)
437 if err != nil {450 if err != nil {
438 _ = unix.Close(fd)451 _ = unix.Close(fd)
439 return err452 return err
@@ -476,7 +489,11 @@ func (t *Target) finishPending() (bool, error) {
476 continue489 continue
477 }490 }
478 if slot.Wait != nil {491 if slot.Wait != nil {
479- slot.Wait.Close()492+ if slot.WaitInFlight {
493+ slot.recycle(slot.Wait) // submission is Ready: the wait was consumed
494+ } else {
495+ slot.Wait.Close()
496+ }
480 }497 }
481 slot.Wait, slot.PendingWait, slot.WaitInFlight = slot.PendingWait, nil, false498 slot.Wait, slot.PendingWait, slot.WaitInFlight = slot.PendingWait, nil, false
482 if err := t.Pool.Reuse(slot.Buffer, true); err != nil {499 if err := t.Pool.Reuse(slot.Buffer, true); err != nil {
M internal/presentation/vkdevice/fence.go +13 -8
@@ -15,6 +15,11 @@ import (
1515 type BinaryFence struct {
1616 Device *Device
1717 Semaphore vulkan.Semaphore
18+ // Reused call arguments: the bindings heap-allocate pointer arguments, and
19+ // a fence has a single owner that calls these methods one at a time.
20+ getInfo vulkan.SemaphoreGetFdInfoKHR
21+ importInfo vulkan.ImportSemaphoreFdInfoKHR
22+ fd int32
1823 }
1924
2025 func (d *Device) NewBinaryFence(export bool) (*BinaryFence, error) {
@@ -26,27 +31,27 @@ func (d *Device) NewBinaryFence(export bool) (*BinaryFence, error) {
2631 if err := vulkan.Check(d.Dispatch.CreateSemaphore(d.Logical, &ci, nil, &sem)); err != nil {
2732 return nil, fmt.Errorf("create SYNC_FD semaphore: %w", err)
2833 }
29- return &BinaryFence{d, sem}, nil
34+ return &BinaryFence{Device: d, Semaphore: sem}, nil
3035 }
3136 var sem vulkan.Semaphore
3237 if err := vulkan.Check(d.Dispatch.CreateSemaphore(d.Logical, &ci, nil, &sem)); err != nil {
3338 return nil, err
3439 }
35- return &BinaryFence{d, sem}, nil
40+ return &BinaryFence{Device: d, Semaphore: sem}, nil
3641 }
3742
3843 func (s *BinaryFence) Export() (int, error) {
39- info := vulkan.SemaphoreGetFdInfoKHR{SType: vulkan.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s.Semaphore, HandleType: vulkan.ExternalSemaphoreHandleTypeSyncFDBit}
40- fd := int32(-1)
41- if err := vulkan.Check(s.Device.Dispatch.GetSemaphoreFdKHR(s.Device.Logical, &info, &fd)); err != nil {
44+ s.getInfo = vulkan.SemaphoreGetFdInfoKHR{SType: vulkan.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s.Semaphore, HandleType: vulkan.ExternalSemaphoreHandleTypeSyncFDBit}
45+ s.fd = -1
46+ if err := vulkan.Check(s.Device.Dispatch.GetSemaphoreFdKHR(s.Device.Logical, &s.getInfo, &s.fd)); err != nil {
4247 return -1, fmt.Errorf("export Vulkan SYNC_FD: %w", err)
4348 }
44- return int(fd), nil
49+ return int(s.fd), nil
4550 }
4651
4752 func (s *BinaryFence) ImportTemporary(fd int) error {
48- info := vulkan.ImportSemaphoreFdInfoKHR{SType: vulkan.StructureTypeImportSemaphoreFDInfoKHR, Semaphore: s.Semaphore, Flags: vulkan.SemaphoreImportTemporaryBit, HandleType: vulkan.ExternalSemaphoreHandleTypeSyncFDBit, Fd: int32(fd)}
49- return vulkan.Check(s.Device.Dispatch.ImportSemaphoreFdKHR(s.Device.Logical, &info))
53+ s.importInfo = vulkan.ImportSemaphoreFdInfoKHR{SType: vulkan.StructureTypeImportSemaphoreFDInfoKHR, Semaphore: s.Semaphore, Flags: vulkan.SemaphoreImportTemporaryBit, HandleType: vulkan.ExternalSemaphoreHandleTypeSyncFDBit, Fd: int32(fd)}
54+ return vulkan.Check(s.Device.Dispatch.ImportSemaphoreFdKHR(s.Device.Logical, &s.importInfo))
5055 }
5156
5257 func (s *BinaryFence) Close() {
@@ -15,6 +15,11 @@ import (
15 type BinaryFence struct {15 type BinaryFence struct {
16 Device *Device16 Device *Device
17 Semaphore vulkan.Semaphore17 Semaphore vulkan.Semaphore
18+ // Reused call arguments: the bindings heap-allocate pointer arguments, and
19+ // a fence has a single owner that calls these methods one at a time.
20+ getInfo vulkan.SemaphoreGetFdInfoKHR
21+ importInfo vulkan.ImportSemaphoreFdInfoKHR
22+ fd int32
18 }23 }
19 24
20 func (d *Device) NewBinaryFence(export bool) (*BinaryFence, error) {25 func (d *Device) NewBinaryFence(export bool) (*BinaryFence, error) {
@@ -26,27 +31,27 @@ func (d *Device) NewBinaryFence(export bool) (*BinaryFence, error) {
26 if err := vulkan.Check(d.Dispatch.CreateSemaphore(d.Logical, &ci, nil, &sem)); err != nil {31 if err := vulkan.Check(d.Dispatch.CreateSemaphore(d.Logical, &ci, nil, &sem)); err != nil {
27 return nil, fmt.Errorf("create SYNC_FD semaphore: %w", err)32 return nil, fmt.Errorf("create SYNC_FD semaphore: %w", err)
28 }33 }
29- return &BinaryFence{d, sem}, nil34+ return &BinaryFence{Device: d, Semaphore: sem}, nil
30 }35 }
31 var sem vulkan.Semaphore36 var sem vulkan.Semaphore
32 if err := vulkan.Check(d.Dispatch.CreateSemaphore(d.Logical, &ci, nil, &sem)); err != nil {37 if err := vulkan.Check(d.Dispatch.CreateSemaphore(d.Logical, &ci, nil, &sem)); err != nil {
33 return nil, err38 return nil, err
34 }39 }
35- return &BinaryFence{d, sem}, nil40+ return &BinaryFence{Device: d, Semaphore: sem}, nil
36 }41 }
37 42
38 func (s *BinaryFence) Export() (int, error) {43 func (s *BinaryFence) Export() (int, error) {
39- info := vulkan.SemaphoreGetFdInfoKHR{SType: vulkan.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s.Semaphore, HandleType: vulkan.ExternalSemaphoreHandleTypeSyncFDBit}44+ s.getInfo = vulkan.SemaphoreGetFdInfoKHR{SType: vulkan.StructureTypeSemaphoreGetFDInfoKHR, Semaphore: s.Semaphore, HandleType: vulkan.ExternalSemaphoreHandleTypeSyncFDBit}
40- fd := int32(-1)45+ s.fd = -1
41- if err := vulkan.Check(s.Device.Dispatch.GetSemaphoreFdKHR(s.Device.Logical, &info, &fd)); err != nil {46+ if err := vulkan.Check(s.Device.Dispatch.GetSemaphoreFdKHR(s.Device.Logical, &s.getInfo, &s.fd)); err != nil {
42 return -1, fmt.Errorf("export Vulkan SYNC_FD: %w", err)47 return -1, fmt.Errorf("export Vulkan SYNC_FD: %w", err)
43 }48 }
44- return int(fd), nil49+ return int(s.fd), nil
45 }50 }
46 51
47 func (s *BinaryFence) ImportTemporary(fd int) error {52 func (s *BinaryFence) ImportTemporary(fd int) error {
48- info := vulkan.ImportSemaphoreFdInfoKHR{SType: vulkan.StructureTypeImportSemaphoreFDInfoKHR, Semaphore: s.Semaphore, Flags: vulkan.SemaphoreImportTemporaryBit, HandleType: vulkan.ExternalSemaphoreHandleTypeSyncFDBit, Fd: int32(fd)}53+ s.importInfo = vulkan.ImportSemaphoreFdInfoKHR{SType: vulkan.StructureTypeImportSemaphoreFDInfoKHR, Semaphore: s.Semaphore, Flags: vulkan.SemaphoreImportTemporaryBit, HandleType: vulkan.ExternalSemaphoreHandleTypeSyncFDBit, Fd: int32(fd)}
49- return vulkan.Check(s.Device.Dispatch.ImportSemaphoreFdKHR(s.Device.Logical, &info))54+ return vulkan.Check(s.Device.Dispatch.ImportSemaphoreFdKHR(s.Device.Logical, &s.importInfo))
50 }55 }
51 56
52 func (s *BinaryFence) Close() {57 func (s *BinaryFence) Close() {
M internal/presentation/vkdevice/image_cache.go +5 -1
@@ -35,6 +35,9 @@ type imageCache struct {
3535 // inflight holds submitted frames whose image serial is not settled yet.
3636 // resolve polls them so an idle frame slot cannot stall completion.
3737 inflight map[*Frame]struct{}
38+ // batches is the backing store of resolve's result. The caller records it
39+ // before the next resolve and never retains it.
40+ batches []Batch
3841 }
3942
4043 func (c *imageCache) Close() {
@@ -145,7 +148,8 @@ func (c *imageCache) resolve(f *Frame, batches []Batch) ([]Batch, error) {
145148 f.imageSerial = c.serial
146149 f.imageCopies = f.imageCopies[:0]
147150 c.dispose(c.policy.begin(c.serial, c.policy.completed))
148- result := append([]Batch(nil), batches...)
151+ c.batches = append(c.batches[:0], batches...)
152+ result := c.batches
149153 var pixels []byte
150154 // Validate the entire frame before creating or changing any GPU resources.
151155 // In particular, do not partially record an over-budget frame.
@@ -35,6 +35,9 @@ type imageCache struct {
35 // inflight holds submitted frames whose image serial is not settled yet.35 // inflight holds submitted frames whose image serial is not settled yet.
36 // resolve polls them so an idle frame slot cannot stall completion.36 // resolve polls them so an idle frame slot cannot stall completion.
37 inflight map[*Frame]struct{}37 inflight map[*Frame]struct{}
38+ // batches is the backing store of resolve's result. The caller records it
39+ // before the next resolve and never retains it.
40+ batches []Batch
38 }41 }
39 42
40 func (c *imageCache) Close() {43 func (c *imageCache) Close() {
@@ -145,7 +148,8 @@ func (c *imageCache) resolve(f *Frame, batches []Batch) ([]Batch, error) {
145 f.imageSerial = c.serial148 f.imageSerial = c.serial
146 f.imageCopies = f.imageCopies[:0]149 f.imageCopies = f.imageCopies[:0]
147 c.dispose(c.policy.begin(c.serial, c.policy.completed))150 c.dispose(c.policy.begin(c.serial, c.policy.completed))
148- result := append([]Batch(nil), batches...)151+ c.batches = append(c.batches[:0], batches...)
152+ result := c.batches
149 var pixels []byte153 var pixels []byte
150 // Validate the entire frame before creating or changing any GPU resources.154 // Validate the entire frame before creating or changing any GPU resources.
151 // In particular, do not partially record an over-budget frame.155 // In particular, do not partially record an over-budget frame.
M internal/presentation/vkdevice/instances.go +4 -3
@@ -71,11 +71,12 @@ func (f *Frame) uploadInstances(instances []Instance) error {
7171 }
7272 b.capacity = len(instances)
7373 }
74- var ptr unsafe.Pointer
75- if err := vulkan.Check(f.device.Dispatch.MapMemory(f.device.Logical, f.instances.Memory, 0, size, 0, &ptr)); err != nil {
74+ ptr := &f.scratch.mapped
75+ if err := vulkan.Check(f.device.Dispatch.MapMemory(f.device.Logical, f.instances.Memory, 0, size, 0, ptr)); err != nil {
7676 return err
7777 }
78- copy(unsafe.Slice((*Instance)(ptr), len(instances)), instances)
78+ copy(unsafe.Slice((*Instance)(*ptr), len(instances)), instances)
7979 f.device.Dispatch.UnmapMemory(f.device.Logical, f.instances.Memory)
80+ *ptr = nil
8081 return nil
8182 }
@@ -71,11 +71,12 @@ func (f *Frame) uploadInstances(instances []Instance) error {
71 }71 }
72 b.capacity = len(instances)72 b.capacity = len(instances)
73 }73 }
74- var ptr unsafe.Pointer74+ ptr := &f.scratch.mapped
75- if err := vulkan.Check(f.device.Dispatch.MapMemory(f.device.Logical, f.instances.Memory, 0, size, 0, &ptr)); err != nil {75+ if err := vulkan.Check(f.device.Dispatch.MapMemory(f.device.Logical, f.instances.Memory, 0, size, 0, ptr)); err != nil {
76 return err76 return err
77 }77 }
78- copy(unsafe.Slice((*Instance)(ptr), len(instances)), instances)78+ copy(unsafe.Slice((*Instance)(*ptr), len(instances)), instances)
79 f.device.Dispatch.UnmapMemory(f.device.Logical, f.instances.Memory)79 f.device.Dispatch.UnmapMemory(f.device.Logical, f.instances.Memory)
80+ *ptr = nil
80 return nil81 return nil
81 }82 }
M internal/presentation/vkdevice/readback.go +3 -2
@@ -19,6 +19,7 @@ type Readback struct {
1919 Buffer vulkan.Buffer
2020 Memory vulkan.DeviceMemory
2121 width, height int32
22+ region vulkan.BufferImageCopy // reused: bindings heap-allocate pointer arguments
2223 }
2324
2425 func (d *Device) NewReadback(width, height int32) (r *Readback, err error) {
@@ -63,8 +64,8 @@ func (d *Device) NewReadback(width, height int32) (r *Readback, err error) {
6364 return r, nil
6465 }
6566 func (r *Readback) record(cmd vulkan.CommandBuffer, img vulkan.Image) {
66- copy := vulkan.BufferImageCopy{ImageSubresource: vulkan.ImageSubresourceLayers{AspectMask: vulkan.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vulkan.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
67- r.device.Dispatch.CmdCopyImageToBuffer(cmd, img, vulkan.ImageLayoutTransferSrcOptimal, r.Buffer, 1, &copy)
67+ r.region = vulkan.BufferImageCopy{ImageSubresource: vulkan.ImageSubresourceLayers{AspectMask: vulkan.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vulkan.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
68+ r.device.Dispatch.CmdCopyImageToBuffer(cmd, img, vulkan.ImageLayoutTransferSrcOptimal, r.Buffer, 1, &r.region)
6869 }
6970
7071 // Copy returns the completed readback as an owned image whose pixels are still
@@ -19,6 +19,7 @@ type Readback struct {
19 Buffer vulkan.Buffer19 Buffer vulkan.Buffer
20 Memory vulkan.DeviceMemory20 Memory vulkan.DeviceMemory
21 width, height int3221 width, height int32
22+ region vulkan.BufferImageCopy // reused: bindings heap-allocate pointer arguments
22 }23 }
23 24
24 func (d *Device) NewReadback(width, height int32) (r *Readback, err error) {25 func (d *Device) NewReadback(width, height int32) (r *Readback, err error) {
@@ -63,8 +64,8 @@ func (d *Device) NewReadback(width, height int32) (r *Readback, err error) {
63 return r, nil64 return r, nil
64 }65 }
65 func (r *Readback) record(cmd vulkan.CommandBuffer, img vulkan.Image) {66 func (r *Readback) record(cmd vulkan.CommandBuffer, img vulkan.Image) {
66- copy := vulkan.BufferImageCopy{ImageSubresource: vulkan.ImageSubresourceLayers{AspectMask: vulkan.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vulkan.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}67+ r.region = vulkan.BufferImageCopy{ImageSubresource: vulkan.ImageSubresourceLayers{AspectMask: vulkan.ImageAspectColorBit, LayerCount: 1}, ImageExtent: vulkan.Extent3D{Width: uint32(r.width), Height: uint32(r.height), Depth: 1}}
67- r.device.Dispatch.CmdCopyImageToBuffer(cmd, img, vulkan.ImageLayoutTransferSrcOptimal, r.Buffer, 1, &copy)68+ r.device.Dispatch.CmdCopyImageToBuffer(cmd, img, vulkan.ImageLayoutTransferSrcOptimal, r.Buffer, 1, &r.region)
68 }69 }
69 70
70 // Copy returns the completed readback as an owned image whose pixels are still71 // Copy returns the completed readback as an owned image whose pixels are still
M internal/presentation/vkdevice/render.go +73 -27
@@ -30,6 +30,33 @@ type Frame struct {
3030 imageSerial uint64
3131 atlasRecorded bool
3232 fallbackRecorded bool
33+ // signalExported is set once the acquire SYNC_FD was exported. Exporting
34+ // with copy transference resets the binary semaphore, so Signal may be
35+ // signaled again after the frame's fence completes.
36+ signalExported bool
37+ scratch frameScratch
38+}
39+
40+// frameScratch holds the Vulkan info structs and out-parameters of one
41+// recording and submission. The purego bindings force every pointer argument
42+// to the heap, so building these per call allocated each frame. A Frame is
43+// used by a single owner and its structs are only read by the driver during
44+// the call that receives them (the command buffer copies recorded state), so
45+// they are rebuilt and reused on every call.
46+type frameScratch struct {
47+ begin vulkan.CommandBufferBeginInfo
48+ barrier vulkan.ImageMemoryBarrier2
49+ dependency vulkan.DependencyInfo
50+ attachment vulkan.RenderingAttachmentInfo
51+ rendering vulkan.RenderingInfo
52+ sets [2]vulkan.DescriptorSet
53+ screen [2]float32
54+ offset vulkan.DeviceSize
55+ cmd vulkan.CommandBufferSubmitInfo
56+ signal vulkan.SemaphoreSubmitInfo
57+ waiting vulkan.SemaphoreSubmitInfo
58+ submit vulkan.SubmitInfo2
59+ mapped unsafe.Pointer
3360 }
3461
3562 func (d *Device) NewFrame(img *Image) (f *Frame, err error) {
@@ -98,10 +125,15 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
98125 if err = vulkan.Check(f.device.Dispatch.ResetCommandBuffer(f.Commands, 0)); err != nil {
99126 return err
100127 }
101- f.Signal.Close()
102- f.Signal, err = f.device.NewBinaryFence(true)
103- if err != nil {
104- return err
128+ // The fence is signaled, so the previous submission finished. Its
129+ // semaphore is reusable only if the SYNC_FD export consumed the signal;
130+ // otherwise it is still signaled and must be replaced.
131+ if !f.signalExported {
132+ f.Signal.Close()
133+ f.Signal, err = f.device.NewBinaryFence(true)
134+ if err != nil {
135+ return err
136+ }
105137 }
106138 f.submitted = false
107139 }
@@ -127,9 +159,10 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
127159 return err
128160 }
129161 }
130- begin := vulkan.CommandBufferBeginInfo{SType: vulkan.StructureTypeCommandBufferBeginInfo, Flags: vulkan.CommandBufferUsageOneTimeSubmitBit}
162+ sc := &f.scratch
163+ sc.begin = vulkan.CommandBufferBeginInfo{SType: vulkan.StructureTypeCommandBufferBeginInfo, Flags: vulkan.CommandBufferUsageOneTimeSubmitBit}
131164 d := f.device.Dispatch
132- if err = vulkan.Check(d.BeginCommandBuffer(f.Commands, &begin)); err != nil {
165+ if err = vulkan.Check(d.BeginCommandBuffer(f.Commands, &sc.begin)); err != nil {
133166 return err
134167 }
135168 f.atlasRecorded = !f.device.atlas.initialized
@@ -147,25 +180,26 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
147180 if initial {
148181 old = vulkan.ImageLayoutUndefined
149182 }
150- barrier := vulkan.ImageMemoryBarrier2{SType: vulkan.StructureTypeImageMemoryBarrier2, SrcStageMask: vulkan.PipelineStage2None, DstStageMask: vulkan.PipelineStage2ColorAttachmentOutputBit, DstAccessMask: vulkan.Access2ColorAttachmentWriteBit, OldLayout: old, NewLayout: vulkan.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: ^uint32(0), DstQueueFamilyIndex: ^uint32(0), Image: img.Image, SubresourceRange: rangeInfo}
151- dependency := vulkan.DependencyInfo{SType: vulkan.StructureTypeDependencyInfo, ImageMemoryBarrierCount: 1, ImageMemoryBarriers: &barrier}
152- d.CmdPipelineBarrier2(f.Commands, &dependency)
183+ barrier, dependency := &sc.barrier, &sc.dependency
184+ *barrier = vulkan.ImageMemoryBarrier2{SType: vulkan.StructureTypeImageMemoryBarrier2, SrcStageMask: vulkan.PipelineStage2None, DstStageMask: vulkan.PipelineStage2ColorAttachmentOutputBit, DstAccessMask: vulkan.Access2ColorAttachmentWriteBit, OldLayout: old, NewLayout: vulkan.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: ^uint32(0), DstQueueFamilyIndex: ^uint32(0), Image: img.Image, SubresourceRange: rangeInfo}
185+ *dependency = vulkan.DependencyInfo{SType: vulkan.StructureTypeDependencyInfo, ImageMemoryBarrierCount: 1, ImageMemoryBarriers: barrier}
186+ d.CmdPipelineBarrier2(f.Commands, dependency)
153187 clear := vulkan.ClearValue{}
154- attachment := vulkan.RenderingAttachmentInfo{SType: vulkan.StructureTypeRenderingAttachmentInfo, ImageView: f.View, ImageLayout: vulkan.ImageLayoutColorAttachmentOptimal, LoadOp: vulkan.AttachmentLoadOpClear, StoreOp: vulkan.AttachmentStoreOpStore, ClearValue: clear}
188+ sc.attachment = vulkan.RenderingAttachmentInfo{SType: vulkan.StructureTypeRenderingAttachmentInfo, ImageView: f.View, ImageLayout: vulkan.ImageLayoutColorAttachmentOptimal, LoadOp: vulkan.AttachmentLoadOpClear, StoreOp: vulkan.AttachmentStoreOpStore, ClearValue: clear}
155189 area := vulkan.Rect2D{Extent: vulkan.Extent2D{Width: uint32(width), Height: uint32(height)}}
156- rendering := vulkan.RenderingInfo{SType: vulkan.StructureTypeRenderingInfo, RenderArea: area, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}
157- d.CmdBeginRendering(f.Commands, &rendering)
190+ sc.rendering = vulkan.RenderingInfo{SType: vulkan.StructureTypeRenderingInfo, RenderArea: area, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &sc.attachment}
191+ d.CmdBeginRendering(f.Commands, &sc.rendering)
158192 d.CmdBindPipeline(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Handle)
159193 // The fragment shader statically references both descriptor sets even
160194 // for non-image draws. Bind a valid initialized image set before any
161195 // draw; each image batch then replaces set 1 in painter's order.
162- sets := [2]vulkan.DescriptorSet{f.device.atlas.Set, f.device.images.fallback.Texture.Set}
163- d.CmdBindDescriptorSets(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Layout, 0, 2, &sets[0], 0, nil)
164- screen := [2]float32{float32(width), float32(height)}
165- d.CmdPushConstants(f.Commands, pipe.Layout, vulkan.ShaderStageVertexBit|vulkan.ShaderStageFragmentBit, 0, 8, unsafe.Pointer(&screen))
196+ sc.sets = [2]vulkan.DescriptorSet{f.device.atlas.Set, f.device.images.fallback.Texture.Set}
197+ d.CmdBindDescriptorSets(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Layout, 0, 2, &sc.sets[0], 0, nil)
198+ sc.screen = [2]float32{float32(width), float32(height)}
199+ d.CmdPushConstants(f.Commands, pipe.Layout, vulkan.ShaderStageVertexBit|vulkan.ShaderStageFragmentBit, 0, 8, unsafe.Pointer(&sc.screen))
166200 if len(instances) > 0 {
167- offset := vulkan.DeviceSize(0)
168- d.CmdBindVertexBuffers(f.Commands, 0, 1, &f.instances.Buffer, &offset)
201+ sc.offset = 0
202+ d.CmdBindVertexBuffers(f.Commands, 0, 1, &f.instances.Buffer, &sc.offset)
169203 for _, batch := range batches {
170204 if batch.Count == 0 || uint64(batch.First)+uint64(batch.Count) > uint64(len(instances)) {
171205 return fmt.Errorf("invalid list batch range")
@@ -187,7 +221,7 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
187221 barrier.DstAccessMask = vulkan.Access2TransferReadBit
188222 barrier.OldLayout = vulkan.ImageLayoutColorAttachmentOptimal
189223 barrier.NewLayout = vulkan.ImageLayoutTransferSrcOptimal
190- d.CmdPipelineBarrier2(f.Commands, &dependency)
224+ d.CmdPipelineBarrier2(f.Commands, dependency)
191225 f.Readback.record(f.Commands, img.Image)
192226 barrier.SrcStageMask = vulkan.PipelineStage2TransferBit
193227 barrier.SrcAccessMask = vulkan.Access2TransferReadBit
@@ -200,22 +234,23 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
200234 barrier.DstStageMask = vulkan.PipelineStage2AllCommandsBit
201235 barrier.DstAccessMask = vulkan.Access2MemoryReadBit
202236 barrier.NewLayout = vulkan.ImageLayoutGeneral
203- d.CmdPipelineBarrier2(f.Commands, &dependency)
237+ d.CmdPipelineBarrier2(f.Commands, dependency)
204238 return vulkan.Check(d.EndCommandBuffer(f.Commands))
205239 }
206240
207241 // Submit has no CPU wait: the next render waits on a temporarily imported
208242 // release SYNC_FD, and the compositor waits on the exported acquire SYNC_FD.
209243 func (f *Frame) Submit(wait *BinaryFence) error {
210- cmd := vulkan.CommandBufferSubmitInfo{SType: vulkan.StructureTypeCommandBufferSubmitInfo, CommandBuffer: f.Commands}
211- signal := vulkan.SemaphoreSubmitInfo{SType: vulkan.StructureTypeSemaphoreSubmitInfo, Semaphore: f.Signal.Semaphore, StageMask: vulkan.PipelineStage2AllCommandsBit}
212- info := vulkan.SubmitInfo2{SType: vulkan.StructureTypeSubmitInfo2, CommandBufferInfoCount: 1, CommandBufferInfos: &cmd, SignalSemaphoreInfoCount: 1, SignalSemaphoreInfos: &signal}
244+ sc := &f.scratch
245+ sc.cmd = vulkan.CommandBufferSubmitInfo{SType: vulkan.StructureTypeCommandBufferSubmitInfo, CommandBuffer: f.Commands}
246+ sc.signal = vulkan.SemaphoreSubmitInfo{SType: vulkan.StructureTypeSemaphoreSubmitInfo, Semaphore: f.Signal.Semaphore, StageMask: vulkan.PipelineStage2AllCommandsBit}
247+ sc.submit = vulkan.SubmitInfo2{SType: vulkan.StructureTypeSubmitInfo2, CommandBufferInfoCount: 1, CommandBufferInfos: &sc.cmd, SignalSemaphoreInfoCount: 1, SignalSemaphoreInfos: &sc.signal}
213248 if wait != nil {
214- waiting := vulkan.SemaphoreSubmitInfo{SType: vulkan.StructureTypeSemaphoreSubmitInfo, Semaphore: wait.Semaphore, StageMask: vulkan.PipelineStage2AllCommandsBit}
215- info.WaitSemaphoreInfoCount = 1
216- info.WaitSemaphoreInfos = &waiting
249+ sc.waiting = vulkan.SemaphoreSubmitInfo{SType: vulkan.StructureTypeSemaphoreSubmitInfo, Semaphore: wait.Semaphore, StageMask: vulkan.PipelineStage2AllCommandsBit}
250+ sc.submit.WaitSemaphoreInfoCount = 1
251+ sc.submit.WaitSemaphoreInfos = &sc.waiting
217252 }
218- if err := vulkan.Check(f.device.Dispatch.QueueSubmit2(f.device.Queue, 1, &info, f.Done)); err != nil {
253+ if err := vulkan.Check(f.device.Dispatch.QueueSubmit2(f.device.Queue, 1, &sc.submit, f.Done)); err != nil {
219254 // Only this recording's unsubmitted image state is released; a
220255 // previous submission's serial was completed by record.
221256 if f.device.images != nil && f.imageSerial != 0 {
@@ -224,6 +259,7 @@ func (f *Frame) Submit(wait *BinaryFence) error {
224259 return err
225260 }
226261 f.submitted = true
262+ f.signalExported = false
227263 if f.device.images != nil && f.imageSerial != 0 {
228264 f.device.images.inflight[f] = struct{}{}
229265 }
@@ -236,6 +272,16 @@ func (f *Frame) Submit(wait *BinaryFence) error {
236272 return nil
237273 }
238274
275+// ExportSignal exports the submitted frame's acquire SYNC_FD. The export
276+// resets the semaphore, which lets the next recording of this frame reuse it.
277+func (f *Frame) ExportSignal() (int, error) {
278+ fd, err := f.Signal.Export()
279+ if err == nil {
280+ f.signalExported = true
281+ }
282+ return fd, err
283+}
284+
239285 // settleImages runs once the frame is Ready. A submitted frame's serial is
240286 // completed and its upload list forgotten, so a later failed submission can
241287 // never abort textures that submitted work used. A recorded but unsubmitted
@@ -30,6 +30,33 @@ type Frame struct {
30 imageSerial uint6430 imageSerial uint64
31 atlasRecorded bool31 atlasRecorded bool
32 fallbackRecorded bool32 fallbackRecorded bool
33+ // signalExported is set once the acquire SYNC_FD was exported. Exporting
34+ // with copy transference resets the binary semaphore, so Signal may be
35+ // signaled again after the frame's fence completes.
36+ signalExported bool
37+ scratch frameScratch
38+}
39+
40+// frameScratch holds the Vulkan info structs and out-parameters of one
41+// recording and submission. The purego bindings force every pointer argument
42+// to the heap, so building these per call allocated each frame. A Frame is
43+// used by a single owner and its structs are only read by the driver during
44+// the call that receives them (the command buffer copies recorded state), so
45+// they are rebuilt and reused on every call.
46+type frameScratch struct {
47+ begin vulkan.CommandBufferBeginInfo
48+ barrier vulkan.ImageMemoryBarrier2
49+ dependency vulkan.DependencyInfo
50+ attachment vulkan.RenderingAttachmentInfo
51+ rendering vulkan.RenderingInfo
52+ sets [2]vulkan.DescriptorSet
53+ screen [2]float32
54+ offset vulkan.DeviceSize
55+ cmd vulkan.CommandBufferSubmitInfo
56+ signal vulkan.SemaphoreSubmitInfo
57+ waiting vulkan.SemaphoreSubmitInfo
58+ submit vulkan.SubmitInfo2
59+ mapped unsafe.Pointer
33 }60 }
34 61
35 func (d *Device) NewFrame(img *Image) (f *Frame, err error) {62 func (d *Device) NewFrame(img *Image) (f *Frame, err error) {
@@ -98,10 +125,15 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
98 if err = vulkan.Check(f.device.Dispatch.ResetCommandBuffer(f.Commands, 0)); err != nil {125 if err = vulkan.Check(f.device.Dispatch.ResetCommandBuffer(f.Commands, 0)); err != nil {
99 return err126 return err
100 }127 }
101- f.Signal.Close()128+ // The fence is signaled, so the previous submission finished. Its
102- f.Signal, err = f.device.NewBinaryFence(true)129+ // semaphore is reusable only if the SYNC_FD export consumed the signal;
103- if err != nil {130+ // otherwise it is still signaled and must be replaced.
104- return err131+ if !f.signalExported {
132+ f.Signal.Close()
133+ f.Signal, err = f.device.NewBinaryFence(true)
134+ if err != nil {
135+ return err
136+ }
105 }137 }
106 f.submitted = false138 f.submitted = false
107 }139 }
@@ -127,9 +159,10 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
127 return err159 return err
128 }160 }
129 }161 }
130- begin := vulkan.CommandBufferBeginInfo{SType: vulkan.StructureTypeCommandBufferBeginInfo, Flags: vulkan.CommandBufferUsageOneTimeSubmitBit}162+ sc := &f.scratch
163+ sc.begin = vulkan.CommandBufferBeginInfo{SType: vulkan.StructureTypeCommandBufferBeginInfo, Flags: vulkan.CommandBufferUsageOneTimeSubmitBit}
131 d := f.device.Dispatch164 d := f.device.Dispatch
132- if err = vulkan.Check(d.BeginCommandBuffer(f.Commands, &begin)); err != nil {165+ if err = vulkan.Check(d.BeginCommandBuffer(f.Commands, &sc.begin)); err != nil {
133 return err166 return err
134 }167 }
135 f.atlasRecorded = !f.device.atlas.initialized168 f.atlasRecorded = !f.device.atlas.initialized
@@ -147,25 +180,26 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
147 if initial {180 if initial {
148 old = vulkan.ImageLayoutUndefined181 old = vulkan.ImageLayoutUndefined
149 }182 }
150- barrier := vulkan.ImageMemoryBarrier2{SType: vulkan.StructureTypeImageMemoryBarrier2, SrcStageMask: vulkan.PipelineStage2None, DstStageMask: vulkan.PipelineStage2ColorAttachmentOutputBit, DstAccessMask: vulkan.Access2ColorAttachmentWriteBit, OldLayout: old, NewLayout: vulkan.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: ^uint32(0), DstQueueFamilyIndex: ^uint32(0), Image: img.Image, SubresourceRange: rangeInfo}183+ barrier, dependency := &sc.barrier, &sc.dependency
151- dependency := vulkan.DependencyInfo{SType: vulkan.StructureTypeDependencyInfo, ImageMemoryBarrierCount: 1, ImageMemoryBarriers: &barrier}184+ *barrier = vulkan.ImageMemoryBarrier2{SType: vulkan.StructureTypeImageMemoryBarrier2, SrcStageMask: vulkan.PipelineStage2None, DstStageMask: vulkan.PipelineStage2ColorAttachmentOutputBit, DstAccessMask: vulkan.Access2ColorAttachmentWriteBit, OldLayout: old, NewLayout: vulkan.ImageLayoutColorAttachmentOptimal, SrcQueueFamilyIndex: ^uint32(0), DstQueueFamilyIndex: ^uint32(0), Image: img.Image, SubresourceRange: rangeInfo}
152- d.CmdPipelineBarrier2(f.Commands, &dependency)185+ *dependency = vulkan.DependencyInfo{SType: vulkan.StructureTypeDependencyInfo, ImageMemoryBarrierCount: 1, ImageMemoryBarriers: barrier}
186+ d.CmdPipelineBarrier2(f.Commands, dependency)
153 clear := vulkan.ClearValue{}187 clear := vulkan.ClearValue{}
154- attachment := vulkan.RenderingAttachmentInfo{SType: vulkan.StructureTypeRenderingAttachmentInfo, ImageView: f.View, ImageLayout: vulkan.ImageLayoutColorAttachmentOptimal, LoadOp: vulkan.AttachmentLoadOpClear, StoreOp: vulkan.AttachmentStoreOpStore, ClearValue: clear}188+ sc.attachment = vulkan.RenderingAttachmentInfo{SType: vulkan.StructureTypeRenderingAttachmentInfo, ImageView: f.View, ImageLayout: vulkan.ImageLayoutColorAttachmentOptimal, LoadOp: vulkan.AttachmentLoadOpClear, StoreOp: vulkan.AttachmentStoreOpStore, ClearValue: clear}
155 area := vulkan.Rect2D{Extent: vulkan.Extent2D{Width: uint32(width), Height: uint32(height)}}189 area := vulkan.Rect2D{Extent: vulkan.Extent2D{Width: uint32(width), Height: uint32(height)}}
156- rendering := vulkan.RenderingInfo{SType: vulkan.StructureTypeRenderingInfo, RenderArea: area, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &attachment}190+ sc.rendering = vulkan.RenderingInfo{SType: vulkan.StructureTypeRenderingInfo, RenderArea: area, LayerCount: 1, ColorAttachmentCount: 1, ColorAttachments: &sc.attachment}
157- d.CmdBeginRendering(f.Commands, &rendering)191+ d.CmdBeginRendering(f.Commands, &sc.rendering)
158 d.CmdBindPipeline(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Handle)192 d.CmdBindPipeline(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Handle)
159 // The fragment shader statically references both descriptor sets even193 // The fragment shader statically references both descriptor sets even
160 // for non-image draws. Bind a valid initialized image set before any194 // for non-image draws. Bind a valid initialized image set before any
161 // draw; each image batch then replaces set 1 in painter's order.195 // draw; each image batch then replaces set 1 in painter's order.
162- sets := [2]vulkan.DescriptorSet{f.device.atlas.Set, f.device.images.fallback.Texture.Set}196+ sc.sets = [2]vulkan.DescriptorSet{f.device.atlas.Set, f.device.images.fallback.Texture.Set}
163- d.CmdBindDescriptorSets(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Layout, 0, 2, &sets[0], 0, nil)197+ d.CmdBindDescriptorSets(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Layout, 0, 2, &sc.sets[0], 0, nil)
164- screen := [2]float32{float32(width), float32(height)}198+ sc.screen = [2]float32{float32(width), float32(height)}
165- d.CmdPushConstants(f.Commands, pipe.Layout, vulkan.ShaderStageVertexBit|vulkan.ShaderStageFragmentBit, 0, 8, unsafe.Pointer(&screen))199+ d.CmdPushConstants(f.Commands, pipe.Layout, vulkan.ShaderStageVertexBit|vulkan.ShaderStageFragmentBit, 0, 8, unsafe.Pointer(&sc.screen))
166 if len(instances) > 0 {200 if len(instances) > 0 {
167- offset := vulkan.DeviceSize(0)201+ sc.offset = 0
168- d.CmdBindVertexBuffers(f.Commands, 0, 1, &f.instances.Buffer, &offset)202+ d.CmdBindVertexBuffers(f.Commands, 0, 1, &f.instances.Buffer, &sc.offset)
169 for _, batch := range batches {203 for _, batch := range batches {
170 if batch.Count == 0 || uint64(batch.First)+uint64(batch.Count) > uint64(len(instances)) {204 if batch.Count == 0 || uint64(batch.First)+uint64(batch.Count) > uint64(len(instances)) {
171 return fmt.Errorf("invalid list batch range")205 return fmt.Errorf("invalid list batch range")
@@ -187,7 +221,7 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
187 barrier.DstAccessMask = vulkan.Access2TransferReadBit221 barrier.DstAccessMask = vulkan.Access2TransferReadBit
188 barrier.OldLayout = vulkan.ImageLayoutColorAttachmentOptimal222 barrier.OldLayout = vulkan.ImageLayoutColorAttachmentOptimal
189 barrier.NewLayout = vulkan.ImageLayoutTransferSrcOptimal223 barrier.NewLayout = vulkan.ImageLayoutTransferSrcOptimal
190- d.CmdPipelineBarrier2(f.Commands, &dependency)224+ d.CmdPipelineBarrier2(f.Commands, dependency)
191 f.Readback.record(f.Commands, img.Image)225 f.Readback.record(f.Commands, img.Image)
192 barrier.SrcStageMask = vulkan.PipelineStage2TransferBit226 barrier.SrcStageMask = vulkan.PipelineStage2TransferBit
193 barrier.SrcAccessMask = vulkan.Access2TransferReadBit227 barrier.SrcAccessMask = vulkan.Access2TransferReadBit
@@ -200,22 +234,23 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial,
200 barrier.DstStageMask = vulkan.PipelineStage2AllCommandsBit234 barrier.DstStageMask = vulkan.PipelineStage2AllCommandsBit
201 barrier.DstAccessMask = vulkan.Access2MemoryReadBit235 barrier.DstAccessMask = vulkan.Access2MemoryReadBit
202 barrier.NewLayout = vulkan.ImageLayoutGeneral236 barrier.NewLayout = vulkan.ImageLayoutGeneral
203- d.CmdPipelineBarrier2(f.Commands, &dependency)237+ d.CmdPipelineBarrier2(f.Commands, dependency)
204 return vulkan.Check(d.EndCommandBuffer(f.Commands))238 return vulkan.Check(d.EndCommandBuffer(f.Commands))
205 }239 }
206 240
207 // Submit has no CPU wait: the next render waits on a temporarily imported241 // Submit has no CPU wait: the next render waits on a temporarily imported
208 // release SYNC_FD, and the compositor waits on the exported acquire SYNC_FD.242 // release SYNC_FD, and the compositor waits on the exported acquire SYNC_FD.
209 func (f *Frame) Submit(wait *BinaryFence) error {243 func (f *Frame) Submit(wait *BinaryFence) error {
210- cmd := vulkan.CommandBufferSubmitInfo{SType: vulkan.StructureTypeCommandBufferSubmitInfo, CommandBuffer: f.Commands}244+ sc := &f.scratch
211- signal := vulkan.SemaphoreSubmitInfo{SType: vulkan.StructureTypeSemaphoreSubmitInfo, Semaphore: f.Signal.Semaphore, StageMask: vulkan.PipelineStage2AllCommandsBit}245+ sc.cmd = vulkan.CommandBufferSubmitInfo{SType: vulkan.StructureTypeCommandBufferSubmitInfo, CommandBuffer: f.Commands}
212- info := vulkan.SubmitInfo2{SType: vulkan.StructureTypeSubmitInfo2, CommandBufferInfoCount: 1, CommandBufferInfos: &cmd, SignalSemaphoreInfoCount: 1, SignalSemaphoreInfos: &signal}246+ sc.signal = vulkan.SemaphoreSubmitInfo{SType: vulkan.StructureTypeSemaphoreSubmitInfo, Semaphore: f.Signal.Semaphore, StageMask: vulkan.PipelineStage2AllCommandsBit}
247+ sc.submit = vulkan.SubmitInfo2{SType: vulkan.StructureTypeSubmitInfo2, CommandBufferInfoCount: 1, CommandBufferInfos: &sc.cmd, SignalSemaphoreInfoCount: 1, SignalSemaphoreInfos: &sc.signal}
213 if wait != nil {248 if wait != nil {
214- waiting := vulkan.SemaphoreSubmitInfo{SType: vulkan.StructureTypeSemaphoreSubmitInfo, Semaphore: wait.Semaphore, StageMask: vulkan.PipelineStage2AllCommandsBit}249+ sc.waiting = vulkan.SemaphoreSubmitInfo{SType: vulkan.StructureTypeSemaphoreSubmitInfo, Semaphore: wait.Semaphore, StageMask: vulkan.PipelineStage2AllCommandsBit}
215- info.WaitSemaphoreInfoCount = 1250+ sc.submit.WaitSemaphoreInfoCount = 1
216- info.WaitSemaphoreInfos = &waiting251+ sc.submit.WaitSemaphoreInfos = &sc.waiting
217 }252 }
218- if err := vulkan.Check(f.device.Dispatch.QueueSubmit2(f.device.Queue, 1, &info, f.Done)); err != nil {253+ if err := vulkan.Check(f.device.Dispatch.QueueSubmit2(f.device.Queue, 1, &sc.submit, f.Done)); err != nil {
219 // Only this recording's unsubmitted image state is released; a254 // Only this recording's unsubmitted image state is released; a
220 // previous submission's serial was completed by record.255 // previous submission's serial was completed by record.
221 if f.device.images != nil && f.imageSerial != 0 {256 if f.device.images != nil && f.imageSerial != 0 {
@@ -224,6 +259,7 @@ func (f *Frame) Submit(wait *BinaryFence) error {
224 return err259 return err
225 }260 }
226 f.submitted = true261 f.submitted = true
262+ f.signalExported = false
227 if f.device.images != nil && f.imageSerial != 0 {263 if f.device.images != nil && f.imageSerial != 0 {
228 f.device.images.inflight[f] = struct{}{}264 f.device.images.inflight[f] = struct{}{}
229 }265 }
@@ -236,6 +272,16 @@ func (f *Frame) Submit(wait *BinaryFence) error {
236 return nil272 return nil
237 }273 }
238 274
275+// ExportSignal exports the submitted frame's acquire SYNC_FD. The export
276+// resets the semaphore, which lets the next recording of this frame reuse it.
277+func (f *Frame) ExportSignal() (int, error) {
278+ fd, err := f.Signal.Export()
279+ if err == nil {
280+ f.signalExported = true
281+ }
282+ return fd, err
283+}
284+
239 // settleImages runs once the frame is Ready. A submitted frame's serial is285 // settleImages runs once the frame is Ready. A submitted frame's serial is
240 // completed and its upload list forgotten, so a later failed submission can286 // completed and its upload list forgotten, so a later failed submission can
241 // never abort textures that submitted work used. A recorded but unsubmitted287 // never abort textures that submitted work used. A recorded but unsubmitted
M internal/text/fonts.go +36 -0
@@ -13,6 +13,7 @@ import (
1313 "math"
1414 "os"
1515 "path/filepath"
16+ "slices"
1617 "sort"
1718 "strings"
1819
@@ -282,6 +283,20 @@ type Catalog struct {
282283 Faces []*Face
283284 source IndexSource
284285 Diagnostics []error
286+ // ranked memoizes candidates per request; a face failure clears it,
287+ // because failed faces are the only catalog state match depends on.
288+ ranked []rankedFaces
289+}
290+
291+// maxRanked bounds the candidates memo; distinct font requests per
292+// application are few.
293+const maxRanked = 64
294+
295+type rankedFaces struct {
296+ families []string
297+ weight, stretch float32
298+ italic bool
299+ faces []*Face
285300 }
286301
287302 func Load(source FontSource) (*Catalog, error) {
@@ -411,6 +426,11 @@ func (f *Face) ready() bool {
411426 }
412427 func (f *Face) fail(err error) {
413428 f.bad = true
429+ if f.catalog == nil {
430+ return
431+ }
432+ clear(f.catalog.ranked)
433+ f.catalog.ranked = f.catalog.ranked[:0]
414434 f.catalog.Diagnostics = append(f.catalog.Diagnostics, fmt.Errorf("font %s face %d: %w", f.path, f.index, err))
415435 }
416436
@@ -507,7 +527,23 @@ func normalizeFamilies(m map[string][]string) map[string][]string {
507527 return out
508528 }
509529
530+// candidates ranks the faces a request may use. The result is shared: callers
531+// must not modify it.
510532 func (c *Catalog) candidates(r Request) []*Face {
533+ for _, e := range c.ranked {
534+ if e.weight == r.Weight && e.stretch == r.Stretch && e.italic == r.Italic && slices.Equal(e.families, r.Families) {
535+ return e.faces
536+ }
537+ }
538+ out := c.rank(r)
539+ out = out[:len(out):len(out)] // an append by a caller cannot write into the memo
540+ if len(c.ranked) < maxRanked {
541+ c.ranked = append(c.ranked, rankedFaces{families: slices.Clone(r.Families), weight: r.Weight, stretch: r.Stretch, italic: r.Italic, faces: out})
542+ }
543+ return out
544+}
545+
546+func (c *Catalog) rank(r Request) []*Face {
511547 seen := map[*Face]bool{}
512548 var out []*Face
513549 add := func(f *Face) {
@@ -13,6 +13,7 @@ import (
13 "math"13 "math"
14 "os"14 "os"
15 "path/filepath"15 "path/filepath"
16+ "slices"
16 "sort"17 "sort"
17 "strings"18 "strings"
18 19
@@ -282,6 +283,20 @@ type Catalog struct {
282 Faces []*Face283 Faces []*Face
283 source IndexSource284 source IndexSource
284 Diagnostics []error285 Diagnostics []error
286+ // ranked memoizes candidates per request; a face failure clears it,
287+ // because failed faces are the only catalog state match depends on.
288+ ranked []rankedFaces
289+}
290+
291+// maxRanked bounds the candidates memo; distinct font requests per
292+// application are few.
293+const maxRanked = 64
294+
295+type rankedFaces struct {
296+ families []string
297+ weight, stretch float32
298+ italic bool
299+ faces []*Face
285 }300 }
286 301
287 func Load(source FontSource) (*Catalog, error) {302 func Load(source FontSource) (*Catalog, error) {
@@ -411,6 +426,11 @@ func (f *Face) ready() bool {
411 }426 }
412 func (f *Face) fail(err error) {427 func (f *Face) fail(err error) {
413 f.bad = true428 f.bad = true
429+ if f.catalog == nil {
430+ return
431+ }
432+ clear(f.catalog.ranked)
433+ f.catalog.ranked = f.catalog.ranked[:0]
414 f.catalog.Diagnostics = append(f.catalog.Diagnostics, fmt.Errorf("font %s face %d: %w", f.path, f.index, err))434 f.catalog.Diagnostics = append(f.catalog.Diagnostics, fmt.Errorf("font %s face %d: %w", f.path, f.index, err))
415 }435 }
416 436
@@ -507,7 +527,23 @@ func normalizeFamilies(m map[string][]string) map[string][]string {
507 return out527 return out
508 }528 }
509 529
530+// candidates ranks the faces a request may use. The result is shared: callers
531+// must not modify it.
510 func (c *Catalog) candidates(r Request) []*Face {532 func (c *Catalog) candidates(r Request) []*Face {
533+ for _, e := range c.ranked {
534+ if e.weight == r.Weight && e.stretch == r.Stretch && e.italic == r.Italic && slices.Equal(e.families, r.Families) {
535+ return e.faces
536+ }
537+ }
538+ out := c.rank(r)
539+ out = out[:len(out):len(out)] // an append by a caller cannot write into the memo
540+ if len(c.ranked) < maxRanked {
541+ c.ranked = append(c.ranked, rankedFaces{families: slices.Clone(r.Families), weight: r.Weight, stretch: r.Stretch, italic: r.Italic, faces: out})
542+ }
543+ return out
544+}
545+
546+func (c *Catalog) rank(r Request) []*Face {
511 seen := map[*Face]bool{}547 seen := map[*Face]bool{}
512 var out []*Face548 var out []*Face
513 add := func(f *Face) {549 add := func(f *Face) {
A internal/text/race_off_test.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package text
4+
5+const raceEnabled = false
@@ -0,0 +1,5 @@
1+//go:build !race
2+
3+package text
4+
5+const raceEnabled = false
A internal/text/race_on_test.go +6 -0
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package text
4+
5+// raceEnabled: the race detector adds allocations, so alloc guards skip.
6+const raceEnabled = true
@@ -0,0 +1,6 @@
1+//go:build race
2+
3+package text
4+
5+// raceEnabled: the race detector adds allocations, so alloc guards skip.
6+const raceEnabled = true
M internal/text/raster.go +10 -8
@@ -28,17 +28,19 @@ type Mask struct {
2828 Origin image.Point
2929 }
3030
31+// VariationHash hashes the axis coordinates without allocating for up to 16
32+// axes (fonts rarely have more).
3133 func VariationHash(v []font.Variation) [32]byte {
32- h := sha256.New()
33- var b [8]byte
34+ var buf [16 * 8]byte
35+ b := buf[:0]
36+ if len(v) > 16 {
37+ b = make([]byte, 0, len(v)*8)
38+ }
3439 for _, coord := range v {
35- binary.BigEndian.PutUint32(b[:4], uint32(coord.Tag))
36- binary.BigEndian.PutUint32(b[4:], math.Float32bits(coord.Value))
37- _, _ = h.Write(b[:])
40+ b = binary.BigEndian.AppendUint32(b, uint32(coord.Tag))
41+ b = binary.BigEndian.AppendUint32(b, math.Float32bits(coord.Value))
3842 }
39- var out [32]byte
40- copy(out[:], h.Sum(nil))
41- return out
43+ return sha256.Sum256(b)
4244 }
4345 func GlyphKey(face *Face, id font.GID, physicalSize, originX float64, variations []font.Variation) (Key, error) {
4446 if face == nil || VariationHash(variations) != VariationHash(face.Variations) || physicalSize <= 0 || physicalSize > 4096 || math.IsNaN(physicalSize) || math.IsInf(physicalSize, 0) || math.IsNaN(originX) || math.IsInf(originX, 0) {
@@ -28,17 +28,19 @@ type Mask struct {
28 Origin image.Point28 Origin image.Point
29 }29 }
30 30
31+// VariationHash hashes the axis coordinates without allocating for up to 16
32+// axes (fonts rarely have more).
31 func VariationHash(v []font.Variation) [32]byte {33 func VariationHash(v []font.Variation) [32]byte {
32- h := sha256.New()34+ var buf [16 * 8]byte
33- var b [8]byte35+ b := buf[:0]
36+ if len(v) > 16 {
37+ b = make([]byte, 0, len(v)*8)
38+ }
34 for _, coord := range v {39 for _, coord := range v {
35- binary.BigEndian.PutUint32(b[:4], uint32(coord.Tag))40+ b = binary.BigEndian.AppendUint32(b, uint32(coord.Tag))
36- binary.BigEndian.PutUint32(b[4:], math.Float32bits(coord.Value))41+ b = binary.BigEndian.AppendUint32(b, math.Float32bits(coord.Value))
37- _, _ = h.Write(b[:])
38 }42 }
39- var out [32]byte43+ return sha256.Sum256(b)
40- copy(out[:], h.Sum(nil))
41- return out
42 }44 }
43 func GlyphKey(face *Face, id font.GID, physicalSize, originX float64, variations []font.Variation) (Key, error) {45 func GlyphKey(face *Face, id font.GID, physicalSize, originX float64, variations []font.Variation) (Key, error) {
44 if face == nil || VariationHash(variations) != VariationHash(face.Variations) || physicalSize <= 0 || physicalSize > 4096 || math.IsNaN(physicalSize) || math.IsInf(physicalSize, 0) || math.IsNaN(originX) || math.IsInf(originX, 0) {46 if face == nil || VariationHash(variations) != VariationHash(face.Variations) || physicalSize <= 0 || physicalSize > 4096 || math.IsNaN(physicalSize) || math.IsInf(physicalSize, 0) || math.IsNaN(originX) || math.IsInf(originX, 0) {
M internal/text/review_test.go +1 -1
@@ -50,7 +50,7 @@ func TestClusterFallback(t *testing.T) {
5050 for _, s := range cases {
5151 t.Run(s, func(t *testing.T) {
5252 r := []rune(s)
53- faces := clusterFaces(r, choices)
53+ faces := clusterFaces(nil, r, choices)
5454 if len(faces) != len(r) {
5555 t.Fatalf("%d faces", len(faces))
5656 }
@@ -50,7 +50,7 @@ func TestClusterFallback(t *testing.T) {
50 for _, s := range cases {50 for _, s := range cases {
51 t.Run(s, func(t *testing.T) {51 t.Run(s, func(t *testing.T) {
52 r := []rune(s)52 r := []rune(s)
53- faces := clusterFaces(r, choices)53+ faces := clusterFaces(nil, r, choices)
54 if len(faces) != len(r) {54 if len(faces) != len(r) {
55 t.Fatalf("%d faces", len(faces))55 t.Fatalf("%d faces", len(faces))
56 }56 }
M internal/text/shape.go +81 -23
@@ -3,6 +3,7 @@ package text
33 import (
44 "errors"
55 "math"
6+ "slices"
67
78 "github.com/bnema/nefergui/internal/bidi"
89 "github.com/go-text/typesetting/di"
@@ -53,7 +54,33 @@ type Engine struct {
5354 seg shaping.Segmenter
5455 shaper shaping.HarfbuzzShaper
5556 wrapper shaping.LineWrapper
57+ never shaping.LineWrapper
5658 cache measureCache
59+ scratch measureScratch
60+}
61+
62+// measureScratch holds per-call buffers reused across measure calls. Nothing
63+// in it may be referenced by a returned Layout.
64+type measureScratch struct {
65+ runes []rune
66+ faces []*Face
67+ reverse map[*font.Face]*Face
68+ faceFor []*font.Face
69+ items []shaping.Input
70+ levelled []shaping.Input
71+ outs []shaping.Output
72+ runs shaping.RunIterator
73+ visual visualScratch
74+}
75+
76+// iterate returns the reusable run iterator positioned at the start of outs.
77+func (s *measureScratch) iterate(outs []shaping.Output) shaping.RunIterator {
78+ if r, ok := s.runs.(interface{ Reset([]shaping.Output) }); ok {
79+ r.Reset(outs)
80+ return s.runs
81+ }
82+ s.runs = shaping.NewSliceIterator(outs)
83+ return s.runs
5784 }
5885
5986 func NewEngine(c *Catalog) *Engine { return &Engine{catalog: c} }
@@ -65,8 +92,8 @@ func (m constantFace) ResolveFace(r rune) *font.Face { return m.face }
6592 // clusterFaces selects a single face for every UAX#29 extended grapheme. Format
6693 // characters (ZWJ and variation selectors) do not need outline coverage; they
6794 // remain in the cluster to let HarfBuzz apply substitutions.
68-func clusterFaces(text []rune, choices []*Face) []*font.Face {
69- selected := make([]*font.Face, len(text))
95+func clusterFaces(selected []*font.Face, text []rune, choices []*Face) []*font.Face {
96+ selected = slices.Grow(selected[:0], len(text))[:len(text)]
7097 var seg segmenter.Segmenter
7198 seg.Init(text)
7299 it := seg.GraphemeIterator()
@@ -110,8 +137,8 @@ func faceCovers(f *Face, cluster []rune) bool {
110137 }
111138
112139 // split cluster-aligned face runs after go-text's bidi/script itemization.
113-func splitFaces(items []shaping.Input, faceFor []*font.Face) []shaping.Input {
114- var out []shaping.Input
140+func splitFaces(out, items []shaping.Input, faceFor []*font.Face) []shaping.Input {
141+ out = out[:0]
115142 for _, item := range items {
116143 if item.RunStart >= item.RunEnd {
117144 continue
@@ -223,27 +250,34 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
223250 if len(choices) == 0 {
224251 return nil, nil, errors.New("no usable fonts")
225252 }
226- reverse := map[*font.Face]*Face{}
227- faces := make([]*Face, 0, len(choices))
253+ sc := &e.scratch
254+ if sc.reverse == nil {
255+ sc.reverse = map[*font.Face]*Face{}
256+ }
257+ clear(sc.reverse)
258+ faces := sc.faces[:0]
228259 // Variations belong to the engine invocation; do not mutate the catalog's shared faces.
229260 for _, f := range choices {
230261 if len(r.Variations) > 0 {
231262 f = f.WithVariations(r.Variations)
232263 }
233264 faces = append(faces, f)
234- reverse[f.Shape] = f
265+ sc.reverse[f.Shape] = f
235266 }
236- faceFor := clusterFaces(text, faces)
267+ sc.faces = faces
268+ sc.faceFor = clusterFaces(sc.faceFor, text, faces)
237269 input := shaping.Input{Text: text, RunEnd: len(text), Direction: direction, Size: fixed.Int26_6(math.Round(r.Size * 64))}
238- items := splitFaces(e.seg.Split(input, constantFace{face: faces[0].Shape}), faceFor)
239- items = splitLevels(items, levels)
240- out := make([]shaping.Output, 0, len(items))
241- for _, item := range items {
270+ sc.items = splitFaces(sc.items, e.seg.Split(input, constantFace{face: faces[0].Shape}), sc.faceFor)
271+ sc.levelled = splitLevels(sc.levelled, sc.items, levels)
272+ out := sc.outs[:0]
273+ for _, item := range sc.levelled {
242274 if item.RunEnd > item.RunStart {
243275 out = append(out, e.shaper.Shape(item))
244276 }
245277 }
246- return out, reverse, nil
278+ clear(out[len(out):cap(out)]) // drop glyph references from longer earlier calls
279+ sc.outs = out
280+ return out, sc.reverse, nil
247281 }
248282
249283 // Measure shapes, wraps (UAX#14) and positions runs. Width <= 0 means unbounded.
@@ -268,6 +302,13 @@ func (e *Engine) Measure(s string, r Request, width float64) (Layout, error) {
268302 // once after each frame that may have measured text.
269303 func (e *Engine) EndFrame() { e.cache.endFrame() }
270304
305+func appendRunes(dst []rune, s string) []rune {
306+ for _, c := range s {
307+ dst = append(dst, c)
308+ }
309+ return dst
310+}
311+
271312 func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
272313 var result Layout
273314 if math.IsNaN(width) || math.IsInf(width, 0) || width > 1<<24 {
@@ -276,9 +317,11 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
276317 if math.IsNaN(r.LetterSpacing) || math.IsInf(r.LetterSpacing, 0) || math.Abs(r.LetterSpacing) > 4096 {
277318 return result, errors.New("invalid letter spacing")
278319 }
279- text := []rune(s)
320+ e.scratch.runes = appendRunes(e.scratch.runes[:0], s)
321+ text := e.scratch.runes
280322 if paragraphs, split := paragraphRanges(text); split {
281- return e.measureParagraphs(text, paragraphs, r, width)
323+ // Each paragraph measure reuses the scratch runes; keep a copy.
324+ return e.measureParagraphs(slices.Clone(text), paragraphs, r, width)
282325 }
283326 if r.Direction > bidi.RTL {
284327 return result, errors.New("invalid paragraph direction")
@@ -317,8 +360,7 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
317360 var minWidth float64
318361 // Compute exact word widths even when a shaped run crosses a break opportunity.
319362 // Wrapper's Never policy at zero width supplies min-content using its native glyph-cluster mapping.
320- var never shaping.LineWrapper
321- words, _ := never.WrapParagraphF(shaping.WrapConfig{Direction: direction, BreakPolicy: shaping.Never, DisableTrailingWhitespaceTrim: true}, 1, text, shaping.NewSliceIterator(outs))
363+ words, _ := e.never.WrapParagraphF(shaping.WrapConfig{Direction: direction, BreakPolicy: shaping.Never, DisableTrailingWhitespaceTrim: true}, 1, text, e.scratch.iterate(outs))
322364 for _, line := range words {
323365 w := 0.0
324366 for _, o := range line {
@@ -329,7 +371,7 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
329371 }
330372 }
331373 result.MinContent = minWidth
332- maxLines, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, fixed.Int26_6(1<<30), text, shaping.NewSliceIterator(outs))
374+ maxLines, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, fixed.Int26_6(1<<30), text, e.scratch.iterate(outs))
333375 for _, line := range maxLines {
334376 w := 0.0
335377 for _, o := range line {
@@ -343,22 +385,38 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
343385 if width > 0 {
344386 limit = fixed.Int26_6(math.Round(width * 64))
345387 }
346- wrapped, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, limit, text, shaping.NewSliceIterator(outs))
388+ wrapped, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, limit, text, e.scratch.iterate(outs))
389+ if len(wrapped) > 0 {
390+ result.Lines = make([]Line, 0, len(wrapped))
391+ }
347392 for idx, line := range wrapped {
348- ordered, levelFor, err := visualRuns(text, line, r.Direction)
393+ ordered, err := e.scratch.visual.order(text, line, r.Direction)
349394 if err != nil {
350395 return result, err
351396 }
352397 dst := Line{Baseline: baseline + float64(idx)*height, Height: height, Direction: direction}
398+ glyphCount := 0
353399 for _, o := range ordered {
400+ glyphCount += len(o.output.Glyphs)
401+ }
402+ var glyphs []Glyph
403+ if len(ordered) > 0 {
404+ dst.Runs = make([]Run, 0, len(ordered))
405+ glyphs = make([]Glyph, 0, glyphCount)
406+ }
407+ for _, item := range ordered {
408+ o := item.output
354409 f := reverse[o.Face]
355- level := levelFor[o.Runes.Offset]
356- run := Run{Face: f, Direction: o.Direction, Level: level, Start: o.Runes.Offset, End: o.Runes.Offset + o.Runes.Count, X: dst.Width, Y: dst.Baseline, Advance: float64(o.Advance) / 64}
410+ run := Run{Face: f, Direction: o.Direction, Level: item.level, Start: o.Runes.Offset, End: o.Runes.Offset + o.Runes.Count, X: dst.Width, Y: dst.Baseline, Advance: float64(o.Advance) / 64}
357411 cursor := 0.0
412+ first := len(glyphs)
358413 for _, g := range o.Glyphs {
359- run.Glyphs = append(run.Glyphs, Glyph{ID: g.GlyphID, X: run.X + cursor + float64(g.XOffset)/64, Y: run.Y - float64(g.YOffset)/64, Advance: float64(g.Advance) / 64, Cluster: g.TextIndex(), Missing: g.GlyphID == 0})
414+ glyphs = append(glyphs, Glyph{ID: g.GlyphID, X: run.X + cursor + float64(g.XOffset)/64, Y: run.Y - float64(g.YOffset)/64, Advance: float64(g.Advance) / 64, Cluster: g.TextIndex(), Missing: g.GlyphID == 0})
360415 cursor += float64(g.Advance) / 64
361416 }
417+ if len(glyphs) > first {
418+ run.Glyphs = glyphs[first:len(glyphs):len(glyphs)]
419+ }
362420 dst.Runs = append(dst.Runs, run)
363421 dst.Width += run.Advance
364422 }
@@ -3,6 +3,7 @@ package text
3 import (3 import (
4 "errors"4 "errors"
5 "math"5 "math"
6+ "slices"
6 7
7 "github.com/bnema/nefergui/internal/bidi"8 "github.com/bnema/nefergui/internal/bidi"
8 "github.com/go-text/typesetting/di"9 "github.com/go-text/typesetting/di"
@@ -53,7 +54,33 @@ type Engine struct {
53 seg shaping.Segmenter54 seg shaping.Segmenter
54 shaper shaping.HarfbuzzShaper55 shaper shaping.HarfbuzzShaper
55 wrapper shaping.LineWrapper56 wrapper shaping.LineWrapper
57+ never shaping.LineWrapper
56 cache measureCache58 cache measureCache
59+ scratch measureScratch
60+}
61+
62+// measureScratch holds per-call buffers reused across measure calls. Nothing
63+// in it may be referenced by a returned Layout.
64+type measureScratch struct {
65+ runes []rune
66+ faces []*Face
67+ reverse map[*font.Face]*Face
68+ faceFor []*font.Face
69+ items []shaping.Input
70+ levelled []shaping.Input
71+ outs []shaping.Output
72+ runs shaping.RunIterator
73+ visual visualScratch
74+}
75+
76+// iterate returns the reusable run iterator positioned at the start of outs.
77+func (s *measureScratch) iterate(outs []shaping.Output) shaping.RunIterator {
78+ if r, ok := s.runs.(interface{ Reset([]shaping.Output) }); ok {
79+ r.Reset(outs)
80+ return s.runs
81+ }
82+ s.runs = shaping.NewSliceIterator(outs)
83+ return s.runs
57 }84 }
58 85
59 func NewEngine(c *Catalog) *Engine { return &Engine{catalog: c} }86 func NewEngine(c *Catalog) *Engine { return &Engine{catalog: c} }
@@ -65,8 +92,8 @@ func (m constantFace) ResolveFace(r rune) *font.Face { return m.face }
65 // clusterFaces selects a single face for every UAX#29 extended grapheme. Format92 // clusterFaces selects a single face for every UAX#29 extended grapheme. Format
66 // characters (ZWJ and variation selectors) do not need outline coverage; they93 // characters (ZWJ and variation selectors) do not need outline coverage; they
67 // remain in the cluster to let HarfBuzz apply substitutions.94 // remain in the cluster to let HarfBuzz apply substitutions.
68-func clusterFaces(text []rune, choices []*Face) []*font.Face {95+func clusterFaces(selected []*font.Face, text []rune, choices []*Face) []*font.Face {
69- selected := make([]*font.Face, len(text))96+ selected = slices.Grow(selected[:0], len(text))[:len(text)]
70 var seg segmenter.Segmenter97 var seg segmenter.Segmenter
71 seg.Init(text)98 seg.Init(text)
72 it := seg.GraphemeIterator()99 it := seg.GraphemeIterator()
@@ -110,8 +137,8 @@ func faceCovers(f *Face, cluster []rune) bool {
110 }137 }
111 138
112 // split cluster-aligned face runs after go-text's bidi/script itemization.139 // split cluster-aligned face runs after go-text's bidi/script itemization.
113-func splitFaces(items []shaping.Input, faceFor []*font.Face) []shaping.Input {140+func splitFaces(out, items []shaping.Input, faceFor []*font.Face) []shaping.Input {
114- var out []shaping.Input141+ out = out[:0]
115 for _, item := range items {142 for _, item := range items {
116 if item.RunStart >= item.RunEnd {143 if item.RunStart >= item.RunEnd {
117 continue144 continue
@@ -223,27 +250,34 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels []
223 if len(choices) == 0 {250 if len(choices) == 0 {
224 return nil, nil, errors.New("no usable fonts")251 return nil, nil, errors.New("no usable fonts")
225 }252 }
226- reverse := map[*font.Face]*Face{}253+ sc := &e.scratch
227- faces := make([]*Face, 0, len(choices))254+ if sc.reverse == nil {
255+ sc.reverse = map[*font.Face]*Face{}
256+ }
257+ clear(sc.reverse)
258+ faces := sc.faces[:0]
228 // Variations belong to the engine invocation; do not mutate the catalog's shared faces.259 // Variations belong to the engine invocation; do not mutate the catalog's shared faces.
229 for _, f := range choices {260 for _, f := range choices {
230 if len(r.Variations) > 0 {261 if len(r.Variations) > 0 {
231 f = f.WithVariations(r.Variations)262 f = f.WithVariations(r.Variations)
232 }263 }
233 faces = append(faces, f)264 faces = append(faces, f)
234- reverse[f.Shape] = f265+ sc.reverse[f.Shape] = f
235 }266 }
236- faceFor := clusterFaces(text, faces)267+ sc.faces = faces
268+ sc.faceFor = clusterFaces(sc.faceFor, text, faces)
237 input := shaping.Input{Text: text, RunEnd: len(text), Direction: direction, Size: fixed.Int26_6(math.Round(r.Size * 64))}269 input := shaping.Input{Text: text, RunEnd: len(text), Direction: direction, Size: fixed.Int26_6(math.Round(r.Size * 64))}
238- items := splitFaces(e.seg.Split(input, constantFace{face: faces[0].Shape}), faceFor)270+ sc.items = splitFaces(sc.items, e.seg.Split(input, constantFace{face: faces[0].Shape}), sc.faceFor)
239- items = splitLevels(items, levels)271+ sc.levelled = splitLevels(sc.levelled, sc.items, levels)
240- out := make([]shaping.Output, 0, len(items))272+ out := sc.outs[:0]
241- for _, item := range items {273+ for _, item := range sc.levelled {
242 if item.RunEnd > item.RunStart {274 if item.RunEnd > item.RunStart {
243 out = append(out, e.shaper.Shape(item))275 out = append(out, e.shaper.Shape(item))
244 }276 }
245 }277 }
246- return out, reverse, nil278+ clear(out[len(out):cap(out)]) // drop glyph references from longer earlier calls
279+ sc.outs = out
280+ return out, sc.reverse, nil
247 }281 }
248 282
249 // Measure shapes, wraps (UAX#14) and positions runs. Width <= 0 means unbounded.283 // Measure shapes, wraps (UAX#14) and positions runs. Width <= 0 means unbounded.
@@ -268,6 +302,13 @@ func (e *Engine) Measure(s string, r Request, width float64) (Layout, error) {
268 // once after each frame that may have measured text.302 // once after each frame that may have measured text.
269 func (e *Engine) EndFrame() { e.cache.endFrame() }303 func (e *Engine) EndFrame() { e.cache.endFrame() }
270 304
305+func appendRunes(dst []rune, s string) []rune {
306+ for _, c := range s {
307+ dst = append(dst, c)
308+ }
309+ return dst
310+}
311+
271 func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {312 func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
272 var result Layout313 var result Layout
273 if math.IsNaN(width) || math.IsInf(width, 0) || width > 1<<24 {314 if math.IsNaN(width) || math.IsInf(width, 0) || width > 1<<24 {
@@ -276,9 +317,11 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
276 if math.IsNaN(r.LetterSpacing) || math.IsInf(r.LetterSpacing, 0) || math.Abs(r.LetterSpacing) > 4096 {317 if math.IsNaN(r.LetterSpacing) || math.IsInf(r.LetterSpacing, 0) || math.Abs(r.LetterSpacing) > 4096 {
277 return result, errors.New("invalid letter spacing")318 return result, errors.New("invalid letter spacing")
278 }319 }
279- text := []rune(s)320+ e.scratch.runes = appendRunes(e.scratch.runes[:0], s)
321+ text := e.scratch.runes
280 if paragraphs, split := paragraphRanges(text); split {322 if paragraphs, split := paragraphRanges(text); split {
281- return e.measureParagraphs(text, paragraphs, r, width)323+ // Each paragraph measure reuses the scratch runes; keep a copy.
324+ return e.measureParagraphs(slices.Clone(text), paragraphs, r, width)
282 }325 }
283 if r.Direction > bidi.RTL {326 if r.Direction > bidi.RTL {
284 return result, errors.New("invalid paragraph direction")327 return result, errors.New("invalid paragraph direction")
@@ -317,8 +360,7 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
317 var minWidth float64360 var minWidth float64
318 // Compute exact word widths even when a shaped run crosses a break opportunity.361 // Compute exact word widths even when a shaped run crosses a break opportunity.
319 // Wrapper's Never policy at zero width supplies min-content using its native glyph-cluster mapping.362 // Wrapper's Never policy at zero width supplies min-content using its native glyph-cluster mapping.
320- var never shaping.LineWrapper363+ words, _ := e.never.WrapParagraphF(shaping.WrapConfig{Direction: direction, BreakPolicy: shaping.Never, DisableTrailingWhitespaceTrim: true}, 1, text, e.scratch.iterate(outs))
321- words, _ := never.WrapParagraphF(shaping.WrapConfig{Direction: direction, BreakPolicy: shaping.Never, DisableTrailingWhitespaceTrim: true}, 1, text, shaping.NewSliceIterator(outs))
322 for _, line := range words {364 for _, line := range words {
323 w := 0.0365 w := 0.0
324 for _, o := range line {366 for _, o := range line {
@@ -329,7 +371,7 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
329 }371 }
330 }372 }
331 result.MinContent = minWidth373 result.MinContent = minWidth
332- maxLines, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, fixed.Int26_6(1<<30), text, shaping.NewSliceIterator(outs))374+ maxLines, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, fixed.Int26_6(1<<30), text, e.scratch.iterate(outs))
333 for _, line := range maxLines {375 for _, line := range maxLines {
334 w := 0.0376 w := 0.0
335 for _, o := range line {377 for _, o := range line {
@@ -343,22 +385,38 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) {
343 if width > 0 {385 if width > 0 {
344 limit = fixed.Int26_6(math.Round(width * 64))386 limit = fixed.Int26_6(math.Round(width * 64))
345 }387 }
346- wrapped, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, limit, text, shaping.NewSliceIterator(outs))388+ wrapped, _ := e.wrapper.WrapParagraphF(shaping.WrapConfig{Direction: direction, DisableTrailingWhitespaceTrim: true}, limit, text, e.scratch.iterate(outs))
389+ if len(wrapped) > 0 {
390+ result.Lines = make([]Line, 0, len(wrapped))
391+ }
347 for idx, line := range wrapped {392 for idx, line := range wrapped {
348- ordered, levelFor, err := visualRuns(text, line, r.Direction)393+ ordered, err := e.scratch.visual.order(text, line, r.Direction)
349 if err != nil {394 if err != nil {
350 return result, err395 return result, err
351 }396 }
352 dst := Line{Baseline: baseline + float64(idx)*height, Height: height, Direction: direction}397 dst := Line{Baseline: baseline + float64(idx)*height, Height: height, Direction: direction}
398+ glyphCount := 0
353 for _, o := range ordered {399 for _, o := range ordered {
400+ glyphCount += len(o.output.Glyphs)
401+ }
402+ var glyphs []Glyph
403+ if len(ordered) > 0 {
404+ dst.Runs = make([]Run, 0, len(ordered))
405+ glyphs = make([]Glyph, 0, glyphCount)
406+ }
407+ for _, item := range ordered {
408+ o := item.output
354 f := reverse[o.Face]409 f := reverse[o.Face]
355- level := levelFor[o.Runes.Offset]410+ run := Run{Face: f, Direction: o.Direction, Level: item.level, Start: o.Runes.Offset, End: o.Runes.Offset + o.Runes.Count, X: dst.Width, Y: dst.Baseline, Advance: float64(o.Advance) / 64}
356- run := Run{Face: f, Direction: o.Direction, Level: level, Start: o.Runes.Offset, End: o.Runes.Offset + o.Runes.Count, X: dst.Width, Y: dst.Baseline, Advance: float64(o.Advance) / 64}
357 cursor := 0.0411 cursor := 0.0
412+ first := len(glyphs)
358 for _, g := range o.Glyphs {413 for _, g := range o.Glyphs {
359- run.Glyphs = append(run.Glyphs, Glyph{ID: g.GlyphID, X: run.X + cursor + float64(g.XOffset)/64, Y: run.Y - float64(g.YOffset)/64, Advance: float64(g.Advance) / 64, Cluster: g.TextIndex(), Missing: g.GlyphID == 0})414+ glyphs = append(glyphs, Glyph{ID: g.GlyphID, X: run.X + cursor + float64(g.XOffset)/64, Y: run.Y - float64(g.YOffset)/64, Advance: float64(g.Advance) / 64, Cluster: g.TextIndex(), Missing: g.GlyphID == 0})
360 cursor += float64(g.Advance) / 64415 cursor += float64(g.Advance) / 64
361 }416 }
417+ if len(glyphs) > first {
418+ run.Glyphs = glyphs[first:len(glyphs):len(glyphs)]
419+ }
362 dst.Runs = append(dst.Runs, run)420 dst.Runs = append(dst.Runs, run)
363 dst.Width += run.Advance421 dst.Width += run.Advance
364 }422 }
M internal/text/text_test.go +35 -0
@@ -434,3 +434,38 @@ func TestLetterSpacingClusterGeometry(t *testing.T) {
434434 })
435435 }
436436 }
437+
438+// BenchmarkMeasureMissShort measures an uncached short label, the common
439+// per-frame miss when a counter or clock changes.
440+func BenchmarkMeasureMissShort(b *testing.B) {
441+ e := NewEngine(fixture(b))
442+ r := Request{Families: []string{"Noto Sans"}, Size: 16}
443+ b.ReportAllocs()
444+ for b.Loop() {
445+ if _, err := e.measure("Value: 42", r, 0); err != nil {
446+ b.Fatal(err)
447+ }
448+ }
449+}
450+
451+// allocBaselineMeasureMiss is the measured allocation count of an uncached
452+// short label; most of the remainder is inside go-text segmentation and
453+// wrapping. Lower it with each optimization.
454+const allocBaselineMeasureMiss = 33
455+
456+func TestAllocMeasureMissShort(t *testing.T) {
457+ if raceEnabled {
458+ t.Skip("race instrumentation changes allocation counts")
459+ }
460+ e := NewEngine(fixture(t))
461+ r := Request{Families: []string{"Noto Sans"}, Size: 16}
462+ measure := func() {
463+ if _, err := e.measure("Value: 42", r, 0); err != nil {
464+ t.Fatal(err)
465+ }
466+ }
467+ measure() // load fonts and fill scratch buffers
468+ if got := testing.AllocsPerRun(50, measure); got > allocBaselineMeasureMiss {
469+ t.Fatalf("allocs per uncached measure = %v, baseline %v", got, allocBaselineMeasureMiss)
470+ }
471+}
@@ -434,3 +434,38 @@ func TestLetterSpacingClusterGeometry(t *testing.T) {
434 })434 })
435 }435 }
436 }436 }
437+
438+// BenchmarkMeasureMissShort measures an uncached short label, the common
439+// per-frame miss when a counter or clock changes.
440+func BenchmarkMeasureMissShort(b *testing.B) {
441+ e := NewEngine(fixture(b))
442+ r := Request{Families: []string{"Noto Sans"}, Size: 16}
443+ b.ReportAllocs()
444+ for b.Loop() {
445+ if _, err := e.measure("Value: 42", r, 0); err != nil {
446+ b.Fatal(err)
447+ }
448+ }
449+}
450+
451+// allocBaselineMeasureMiss is the measured allocation count of an uncached
452+// short label; most of the remainder is inside go-text segmentation and
453+// wrapping. Lower it with each optimization.
454+const allocBaselineMeasureMiss = 33
455+
456+func TestAllocMeasureMissShort(t *testing.T) {
457+ if raceEnabled {
458+ t.Skip("race instrumentation changes allocation counts")
459+ }
460+ e := NewEngine(fixture(t))
461+ r := Request{Families: []string{"Noto Sans"}, Size: 16}
462+ measure := func() {
463+ if _, err := e.measure("Value: 42", r, 0); err != nil {
464+ t.Fatal(err)
465+ }
466+ }
467+ measure() // load fonts and fill scratch buffers
468+ if got := testing.AllocsPerRun(50, measure); got > allocBaselineMeasureMiss {
469+ t.Fatalf("allocs per uncached measure = %v, baseline %v", got, allocBaselineMeasureMiss)
470+ }
471+}
M internal/text/visual.go +36 -27
@@ -19,8 +19,8 @@ func fullBreak(text []rune) []int {
1919 // splitLevels prevents a single shaping run from crossing resolved paragraph
2020 // levels (not merely direction parity). The wrapper can subsequently split
2121 // further at UAX#14 break opportunities.
22-func splitLevels(items []shaping.Input, levels []uint8) []shaping.Input {
23- var out []shaping.Input
22+func splitLevels(out, items []shaping.Input, levels []uint8) []shaping.Input {
23+ out = out[:0]
2424 for _, item := range items {
2525 if item.RunStart >= item.RunEnd {
2626 continue
@@ -52,23 +52,29 @@ func directionFor(level uint8) di.Direction {
5252 type runItem struct {
5353 output shaping.Output
5454 level uint8
55- pos int
5655 }
5756
58-// visualRuns resets trailing whitespace to the paragraph level (L1) by
57+// visualScratch holds order buffers reused across lines; the returned runs
58+// are valid until the next call.
59+type visualScratch struct {
60+ breaks []int
61+ runs []runItem
62+}
63+
64+// order resets trailing whitespace to the paragraph level (L1) by
5965 // resolving levels against the *actual wrapped line breaks*, then applies L2
6066 // to complete runs. The shaper already emits glyphs in visual order within an
6167 // odd-level run: never reverse its glyph slice a second time.
62-func visualRuns(text []rune, line shaping.Line, base bidi.BaseDirection) (shaping.Line, map[int]uint8, error) {
68+func (v *visualScratch) order(text []rune, line shaping.Line, base bidi.BaseDirection) ([]runItem, error) {
6369 if len(line) == 0 {
64- return nil, nil, nil
70+ return nil, nil
6571 }
6672 start, end := line[0].Runes.Offset, line[len(line)-1].Runes.Offset+line[len(line)-1].Runes.Count
6773 if start < 0 || end > len(text) || end <= start {
68- return nil, nil, fmt.Errorf("invalid line range %d:%d", start, end)
74+ return nil, fmt.Errorf("invalid line range %d:%d", start, end)
6975 }
7076 // Include earlier lines so weak and neutral resolution use paragraph context.
71- breaks := []int{}
77+ breaks := v.breaks[:0]
7278 if start > 0 {
7379 breaks = append(breaks, start)
7480 }
@@ -76,28 +82,35 @@ func visualRuns(text []rune, line shaping.Line, base bidi.BaseDirection) (shapin
7682 if end < len(text) {
7783 breaks = append(breaks, len(text))
7884 }
85+ v.breaks = breaks
7986 levels, _, err := bidi.Resolve(text, base, breaks)
8087 if err != nil {
81- return nil, nil, err
88+ return nil, err
8289 }
8390 // The wrapper's runs are in logical order. A level change within one output
8491 // requires reshaping at that boundary; splitLevels does this before wrapping.
85- runs := make([]runItem, 0, len(line))
92+ clear(v.runs) // drop glyph references from the previous line
93+ runs := v.runs[:0]
8694 for _, o := range line {
8795 s, e := o.Runes.Offset, o.Runes.Offset+o.Runes.Count
8896 if s < start || e > end || s >= e {
89- return nil, nil, fmt.Errorf("invalid shaped run %d:%d", s, e)
97+ return nil, fmt.Errorf("invalid shaped run %d:%d", s, e)
98+ }
99+ if sameLevel(levels, s, e) {
100+ runs = append(runs, runItem{output: o, level: levels[s]})
101+ continue
90102 }
91103 // L1 may reset a trailing space in a shaped RTL run. Split the output at
92104 // cluster boundaries, preserving its glyph metrics and HarfBuzz order.
93105 parts, err := splitWrappedLevel(o, levels)
94106 if err != nil {
95- return nil, nil, err
107+ return nil, err
96108 }
97109 for _, part := range parts {
98- runs = append(runs, runItem{output: part, level: levels[part.Runes.Offset], pos: part.Runes.Offset})
110+ runs = append(runs, runItem{output: part, level: levels[part.Runes.Offset]})
99111 }
100112 }
113+ v.runs = runs
101114 // L2 on run levels: from maximum down to the minimum odd level, reverse
102115 // contiguous sequences whose levels are >= the current level.
103116 highest, lowestOdd := uint8(0), uint8(255)
@@ -127,13 +140,16 @@ func visualRuns(text []rune, line shaping.Line, base bidi.BaseDirection) (shapin
127140 }
128141 }
129142 }
130- out := make(shaping.Line, 0, len(runs))
131- lookup := make(map[int]uint8, len(runs))
132- for _, run := range runs {
133- out = append(out, run.output)
134- lookup[run.pos] = run.level
143+ return runs, nil
144+}
145+
146+func sameLevel(levels []uint8, start, end int) bool {
147+ for i := start + 1; i < end; i++ {
148+ if levels[i] != levels[start] {
149+ return false
150+ }
135151 }
136- return out, lookup, nil
152+ return true
137153 }
138154
139155 // splitWrappedLevel divides only at glyph cluster boundaries; UAX#14 wraps
@@ -143,14 +159,7 @@ func splitWrappedLevel(o shaping.Output, levels []uint8) ([]shaping.Output, erro
143159 if start < 0 || end > len(levels) || start >= end {
144160 return nil, fmt.Errorf("invalid output range")
145161 }
146- same := true
147- for i := start + 1; i < end; i++ {
148- if levels[i] != levels[start] {
149- same = false
150- break
151- }
152- }
153- if same {
162+ if sameLevel(levels, start, end) {
154163 return []shaping.Output{o}, nil
155164 }
156165 // Group glyphs by lowest text cluster index, then restore HB visual order.
@@ -19,8 +19,8 @@ func fullBreak(text []rune) []int {
19 // splitLevels prevents a single shaping run from crossing resolved paragraph19 // splitLevels prevents a single shaping run from crossing resolved paragraph
20 // levels (not merely direction parity). The wrapper can subsequently split20 // levels (not merely direction parity). The wrapper can subsequently split
21 // further at UAX#14 break opportunities.21 // further at UAX#14 break opportunities.
22-func splitLevels(items []shaping.Input, levels []uint8) []shaping.Input {22+func splitLevels(out, items []shaping.Input, levels []uint8) []shaping.Input {
23- var out []shaping.Input23+ out = out[:0]
24 for _, item := range items {24 for _, item := range items {
25 if item.RunStart >= item.RunEnd {25 if item.RunStart >= item.RunEnd {
26 continue26 continue
@@ -52,23 +52,29 @@ func directionFor(level uint8) di.Direction {
52 type runItem struct {52 type runItem struct {
53 output shaping.Output53 output shaping.Output
54 level uint854 level uint8
55- pos int
56 }55 }
57 56
58-// visualRuns resets trailing whitespace to the paragraph level (L1) by57+// visualScratch holds order buffers reused across lines; the returned runs
58+// are valid until the next call.
59+type visualScratch struct {
60+ breaks []int
61+ runs []runItem
62+}
63+
64+// order resets trailing whitespace to the paragraph level (L1) by
59 // resolving levels against the *actual wrapped line breaks*, then applies L265 // resolving levels against the *actual wrapped line breaks*, then applies L2
60 // to complete runs. The shaper already emits glyphs in visual order within an66 // to complete runs. The shaper already emits glyphs in visual order within an
61 // odd-level run: never reverse its glyph slice a second time.67 // odd-level run: never reverse its glyph slice a second time.
62-func visualRuns(text []rune, line shaping.Line, base bidi.BaseDirection) (shaping.Line, map[int]uint8, error) {68+func (v *visualScratch) order(text []rune, line shaping.Line, base bidi.BaseDirection) ([]runItem, error) {
63 if len(line) == 0 {69 if len(line) == 0 {
64- return nil, nil, nil70+ return nil, nil
65 }71 }
66 start, end := line[0].Runes.Offset, line[len(line)-1].Runes.Offset+line[len(line)-1].Runes.Count72 start, end := line[0].Runes.Offset, line[len(line)-1].Runes.Offset+line[len(line)-1].Runes.Count
67 if start < 0 || end > len(text) || end <= start {73 if start < 0 || end > len(text) || end <= start {
68- return nil, nil, fmt.Errorf("invalid line range %d:%d", start, end)74+ return nil, fmt.Errorf("invalid line range %d:%d", start, end)
69 }75 }
70 // Include earlier lines so weak and neutral resolution use paragraph context.76 // Include earlier lines so weak and neutral resolution use paragraph context.
71- breaks := []int{}77+ breaks := v.breaks[:0]
72 if start > 0 {78 if start > 0 {
73 breaks = append(breaks, start)79 breaks = append(breaks, start)
74 }80 }
@@ -76,28 +82,35 @@ func visualRuns(text []rune, line shaping.Line, base bidi.BaseDirection) (shapin
76 if end < len(text) {82 if end < len(text) {
77 breaks = append(breaks, len(text))83 breaks = append(breaks, len(text))
78 }84 }
85+ v.breaks = breaks
79 levels, _, err := bidi.Resolve(text, base, breaks)86 levels, _, err := bidi.Resolve(text, base, breaks)
80 if err != nil {87 if err != nil {
81- return nil, nil, err88+ return nil, err
82 }89 }
83 // The wrapper's runs are in logical order. A level change within one output90 // The wrapper's runs are in logical order. A level change within one output
84 // requires reshaping at that boundary; splitLevels does this before wrapping.91 // requires reshaping at that boundary; splitLevels does this before wrapping.
85- runs := make([]runItem, 0, len(line))92+ clear(v.runs) // drop glyph references from the previous line
93+ runs := v.runs[:0]
86 for _, o := range line {94 for _, o := range line {
87 s, e := o.Runes.Offset, o.Runes.Offset+o.Runes.Count95 s, e := o.Runes.Offset, o.Runes.Offset+o.Runes.Count
88 if s < start || e > end || s >= e {96 if s < start || e > end || s >= e {
89- return nil, nil, fmt.Errorf("invalid shaped run %d:%d", s, e)97+ return nil, fmt.Errorf("invalid shaped run %d:%d", s, e)
98+ }
99+ if sameLevel(levels, s, e) {
100+ runs = append(runs, runItem{output: o, level: levels[s]})
101+ continue
90 }102 }
91 // L1 may reset a trailing space in a shaped RTL run. Split the output at103 // L1 may reset a trailing space in a shaped RTL run. Split the output at
92 // cluster boundaries, preserving its glyph metrics and HarfBuzz order.104 // cluster boundaries, preserving its glyph metrics and HarfBuzz order.
93 parts, err := splitWrappedLevel(o, levels)105 parts, err := splitWrappedLevel(o, levels)
94 if err != nil {106 if err != nil {
95- return nil, nil, err107+ return nil, err
96 }108 }
97 for _, part := range parts {109 for _, part := range parts {
98- runs = append(runs, runItem{output: part, level: levels[part.Runes.Offset], pos: part.Runes.Offset})110+ runs = append(runs, runItem{output: part, level: levels[part.Runes.Offset]})
99 }111 }
100 }112 }
113+ v.runs = runs
101 // L2 on run levels: from maximum down to the minimum odd level, reverse114 // L2 on run levels: from maximum down to the minimum odd level, reverse
102 // contiguous sequences whose levels are >= the current level.115 // contiguous sequences whose levels are >= the current level.
103 highest, lowestOdd := uint8(0), uint8(255)116 highest, lowestOdd := uint8(0), uint8(255)
@@ -127,13 +140,16 @@ func visualRuns(text []rune, line shaping.Line, base bidi.BaseDirection) (shapin
127 }140 }
128 }141 }
129 }142 }
130- out := make(shaping.Line, 0, len(runs))143+ return runs, nil
131- lookup := make(map[int]uint8, len(runs))144+}
132- for _, run := range runs {145+
133- out = append(out, run.output)146+func sameLevel(levels []uint8, start, end int) bool {
134- lookup[run.pos] = run.level147+ for i := start + 1; i < end; i++ {
148+ if levels[i] != levels[start] {
149+ return false
150+ }
135 }151 }
136- return out, lookup, nil152+ return true
137 }153 }
138 154
139 // splitWrappedLevel divides only at glyph cluster boundaries; UAX#14 wraps155 // splitWrappedLevel divides only at glyph cluster boundaries; UAX#14 wraps
@@ -143,14 +159,7 @@ func splitWrappedLevel(o shaping.Output, levels []uint8) ([]shaping.Output, erro
143 if start < 0 || end > len(levels) || start >= end {159 if start < 0 || end > len(levels) || start >= end {
144 return nil, fmt.Errorf("invalid output range")160 return nil, fmt.Errorf("invalid output range")
145 }161 }
146- same := true162+ if sameLevel(levels, start, end) {
147- for i := start + 1; i < end; i++ {
148- if levels[i] != levels[start] {
149- same = false
150- break
151- }
152- }
153- if same {
154 return []shaping.Output{o}, nil163 return []shaping.Output{o}, nil
155 }164 }
156 // Group glyphs by lowest text cluster index, then restore HB visual order.165 // Group glyphs by lowest text cluster index, then restore HB visual order.
A internal/ui/arena.go +61 -0
@@ -0,0 +1,61 @@
1+package ui
2+
3+// elementArena hands out the elements of one frame from reusable chunks, so a
4+// steady frame allocates no elements and keeps each element's slice capacity.
5+// Chunks never move, so *element pointers stay valid until reset. The runtime
6+// alternates two arenas: one holds the committed tree, which the next frame
7+// reads as its previous tree, while the other is being rebuilt.
8+type elementArena struct {
9+ chunks [][]element
10+ n int // elements handed out since the last reset
11+}
12+
13+const (
14+ arenaChunk = 32 // elements per chunk
15+ arenaMaxChunks = 64 // retained chunks per arena (2048 elements)
16+ elementMaxKeep = 256 // retained capacity of a per-element slice
17+)
18+
19+func (a *elementArena) slot(i int) *element { return &a.chunks[i/arenaChunk][i%arenaChunk] }
20+
21+// newElement returns a zeroed element that is valid until the next reset.
22+func (a *elementArena) newElement() *element {
23+ if a == nil { // an arena-less frame, such as a hand-built test Frame
24+ return &element{}
25+ }
26+ if a.n/arenaChunk == len(a.chunks) {
27+ a.chunks = append(a.chunks, make([]element, arenaChunk))
28+ }
29+ e := a.slot(a.n)
30+ a.n++
31+ return e
32+}
33+
34+// reset recycles every element handed out. It drops references so the old
35+// tree can be collected, keeps modest slice capacity and releases chunks
36+// beyond twice what the last frame needed, or the retention cap.
37+func (a *elementArena) reset() {
38+ for i := 0; i < a.n; i++ {
39+ e := a.slot(i)
40+ children, events, classes, nodes := e.children, e.events, e.classes, e.ln.Children
41+ clear(children)
42+ clear(events)
43+ clear(classes)
44+ clear(nodes)
45+ *e = element{children: keep(children), events: keep(events), classes: keep(classes)}
46+ e.ln.Children = keep(nodes)
47+ }
48+ needed := (a.n + arenaChunk - 1) / arenaChunk
49+ if limit := min(2*needed+1, arenaMaxChunks); len(a.chunks) > limit {
50+ clear(a.chunks[limit:])
51+ a.chunks = a.chunks[:limit]
52+ }
53+ a.n = 0
54+}
55+
56+func keep[T any](s []T) []T {
57+ if cap(s) > elementMaxKeep {
58+ return nil
59+ }
60+ return s[:0]
61+}
@@ -0,0 +1,61 @@
1+package ui
2+
3+// elementArena hands out the elements of one frame from reusable chunks, so a
4+// steady frame allocates no elements and keeps each element's slice capacity.
5+// Chunks never move, so *element pointers stay valid until reset. The runtime
6+// alternates two arenas: one holds the committed tree, which the next frame
7+// reads as its previous tree, while the other is being rebuilt.
8+type elementArena struct {
9+ chunks [][]element
10+ n int // elements handed out since the last reset
11+}
12+
13+const (
14+ arenaChunk = 32 // elements per chunk
15+ arenaMaxChunks = 64 // retained chunks per arena (2048 elements)
16+ elementMaxKeep = 256 // retained capacity of a per-element slice
17+)
18+
19+func (a *elementArena) slot(i int) *element { return &a.chunks[i/arenaChunk][i%arenaChunk] }
20+
21+// newElement returns a zeroed element that is valid until the next reset.
22+func (a *elementArena) newElement() *element {
23+ if a == nil { // an arena-less frame, such as a hand-built test Frame
24+ return &element{}
25+ }
26+ if a.n/arenaChunk == len(a.chunks) {
27+ a.chunks = append(a.chunks, make([]element, arenaChunk))
28+ }
29+ e := a.slot(a.n)
30+ a.n++
31+ return e
32+}
33+
34+// reset recycles every element handed out. It drops references so the old
35+// tree can be collected, keeps modest slice capacity and releases chunks
36+// beyond twice what the last frame needed, or the retention cap.
37+func (a *elementArena) reset() {
38+ for i := 0; i < a.n; i++ {
39+ e := a.slot(i)
40+ children, events, classes, nodes := e.children, e.events, e.classes, e.ln.Children
41+ clear(children)
42+ clear(events)
43+ clear(classes)
44+ clear(nodes)
45+ *e = element{children: keep(children), events: keep(events), classes: keep(classes)}
46+ e.ln.Children = keep(nodes)
47+ }
48+ needed := (a.n + arenaChunk - 1) / arenaChunk
49+ if limit := min(2*needed+1, arenaMaxChunks); len(a.chunks) > limit {
50+ clear(a.chunks[limit:])
51+ a.chunks = a.chunks[:limit]
52+ }
53+ a.n = 0
54+}
55+
56+func keep[T any](s []T) []T {
57+ if cap(s) > elementMaxKeep {
58+ return nil
59+ }
60+ return s[:0]
61+}
M internal/ui/interaction.go +5 -1
@@ -108,8 +108,12 @@ func layoutKind(e *element) layout.Kind {
108108 return layout.Box
109109 }
110110 }
111+
112+// layoutTree fills the layout node embedded in each element. layout.Layout
113+// does not retain its input, so the nodes are rebuilt in place every call.
111114 func (r *runtime) layoutTree(e *element) *layout.Node {
112- n := &layout.Node{ID: idKey(e.identity), Kind: layoutKind(e), Content: e.text, ImageSize: e.imageSize, Image: e.image, NoWrap: e.typ == "input"}
115+ n := &e.ln
116+ *n = layout.Node{ID: idKey(e.identity), Kind: layoutKind(e), Content: e.text, ImageSize: e.imageSize, Image: e.image, NoWrap: e.typ == "input", Children: n.Children[:0]}
113117 if e.computed != nil {
114118 n.Style = e.computed.Style
115119 }
@@ -108,8 +108,12 @@ func layoutKind(e *element) layout.Kind {
108 return layout.Box108 return layout.Box
109 }109 }
110 }110 }
111+
112+// layoutTree fills the layout node embedded in each element. layout.Layout
113+// does not retain its input, so the nodes are rebuilt in place every call.
111 func (r *runtime) layoutTree(e *element) *layout.Node {114 func (r *runtime) layoutTree(e *element) *layout.Node {
112- n := &layout.Node{ID: idKey(e.identity), Kind: layoutKind(e), Content: e.text, ImageSize: e.imageSize, Image: e.image, NoWrap: e.typ == "input"}115+ n := &e.ln
116+ *n = layout.Node{ID: idKey(e.identity), Kind: layoutKind(e), Content: e.text, ImageSize: e.imageSize, Image: e.image, NoWrap: e.typ == "input", Children: n.Children[:0]}
113 if e.computed != nil {117 if e.computed != nil {
114 n.Style = e.computed.Style118 n.Style = e.computed.Style
115 }119 }
M internal/ui/options.go +30 -11
@@ -12,22 +12,41 @@ type ValueOption interface{ valueOption(*element) }
1212
1313 // CommonOption is the concrete type of Key, ID, Class and Inline; it belongs
1414 // to every option family. It is exported only so the root facade can return it.
15-type CommonOption struct{ apply func(*element) }
15+// It is plain data rather than a closure, so creating one never allocates.
16+type CommonOption struct {
17+ kind commonKind
18+ s string
19+}
1620
21+type commonKind uint8
22+
23+const (
24+ optKey commonKind = iota
25+ optID
26+ optClass
27+ optInline
28+)
29+
30+func (o CommonOption) apply(e *element) {
31+ switch o.kind {
32+ case optKey:
33+ e.key, e.hasKey = o.s, true
34+ case optID:
35+ e.id = o.s
36+ case optClass:
37+ e.classes = append(e.classes, o.s)
38+ case optInline:
39+ e.inline, _ = css.ParseInline(o.s)
40+ }
41+}
1742 func (o CommonOption) container(e *element) { o.apply(e) }
1843 func (o CommonOption) button(e *element) { o.apply(e) }
1944 func (o CommonOption) edit(e *element) { o.apply(e) }
2045 func (o CommonOption) valueOption(e *element) { o.apply(e) }
21-func Key(key string) CommonOption {
22- return CommonOption{func(e *element) { e.identity = &identity{key: key} }}
23-}
24-func ID(id string) CommonOption { return CommonOption{func(e *element) { e.id = id }} }
25-func Class(class string) CommonOption {
26- return CommonOption{func(e *element) { e.classes = append(e.classes, class) }}
27-}
28-func Inline(src string) CommonOption {
29- return CommonOption{func(e *element) { e.inline, _ = css.ParseInline(src) }}
30-}
46+func Key(key string) CommonOption { return CommonOption{optKey, key} }
47+func ID(id string) CommonOption { return CommonOption{optID, id} }
48+func Class(class string) CommonOption { return CommonOption{optClass, class} }
49+func Inline(src string) CommonOption { return CommonOption{optInline, src} }
3150 func applyContainer(e *element, opts []ContainerOption) {
3251 for _, o := range opts {
3352 o.container(e)
@@ -12,22 +12,41 @@ type ValueOption interface{ valueOption(*element) }
12 12
13 // CommonOption is the concrete type of Key, ID, Class and Inline; it belongs13 // CommonOption is the concrete type of Key, ID, Class and Inline; it belongs
14 // to every option family. It is exported only so the root facade can return it.14 // to every option family. It is exported only so the root facade can return it.
15-type CommonOption struct{ apply func(*element) }15+// It is plain data rather than a closure, so creating one never allocates.
16+type CommonOption struct {
17+ kind commonKind
18+ s string
19+}
16 20
21+type commonKind uint8
22+
23+const (
24+ optKey commonKind = iota
25+ optID
26+ optClass
27+ optInline
28+)
29+
30+func (o CommonOption) apply(e *element) {
31+ switch o.kind {
32+ case optKey:
33+ e.key, e.hasKey = o.s, true
34+ case optID:
35+ e.id = o.s
36+ case optClass:
37+ e.classes = append(e.classes, o.s)
38+ case optInline:
39+ e.inline, _ = css.ParseInline(o.s)
40+ }
41+}
17 func (o CommonOption) container(e *element) { o.apply(e) }42 func (o CommonOption) container(e *element) { o.apply(e) }
18 func (o CommonOption) button(e *element) { o.apply(e) }43 func (o CommonOption) button(e *element) { o.apply(e) }
19 func (o CommonOption) edit(e *element) { o.apply(e) }44 func (o CommonOption) edit(e *element) { o.apply(e) }
20 func (o CommonOption) valueOption(e *element) { o.apply(e) }45 func (o CommonOption) valueOption(e *element) { o.apply(e) }
21-func Key(key string) CommonOption {46+func Key(key string) CommonOption { return CommonOption{optKey, key} }
22- return CommonOption{func(e *element) { e.identity = &identity{key: key} }}47+func ID(id string) CommonOption { return CommonOption{optID, id} }
23-}48+func Class(class string) CommonOption { return CommonOption{optClass, class} }
24-func ID(id string) CommonOption { return CommonOption{func(e *element) { e.id = id }} }49+func Inline(src string) CommonOption { return CommonOption{optInline, src} }
25-func Class(class string) CommonOption {
26- return CommonOption{func(e *element) { e.classes = append(e.classes, class) }}
27-}
28-func Inline(src string) CommonOption {
29- return CommonOption{func(e *element) { e.inline, _ = css.ParseInline(src) }}
30-}
31 func applyContainer(e *element, opts []ContainerOption) {50 func applyContainer(e *element, opts []ContainerOption) {
32 for _, o := range opts {51 for _, o := range opts {
33 o.container(e)52 o.container(e)
M internal/ui/renderer_gpu_test.go +25 -8
@@ -182,9 +182,9 @@ func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
182182 }
183183
184184 // allocBaselineRenderer is the measured allocation count of one steady
185-// Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the
186-// target is 0, the remainder is frame build, layout and prepare.
187-const allocBaselineRenderer = 244
185+// Render+Released cycle (radv); it is lowered with each optimization until it
186+// reaches the target of 0.
187+const allocBaselineRenderer = 48
188188
189189 // TestAllocRendererSteadyFrame measures Render with a view whose number
190190 // changes on every frame and an unchanged structure. It runs on the real GPU
@@ -193,6 +193,27 @@ func TestAllocRendererSteadyFrame(t *testing.T) {
193193 if raceEnabled {
194194 t.Skip("race instrumentation changes allocation counts")
195195 }
196+ step := steadyFrame(t)
197+ got := testing.AllocsPerRun(50, step)
198+ t.Logf("allocs per steady Render+Released cycle: %v", got)
199+ if got > allocBaselineRenderer {
200+ t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
201+ }
202+}
203+
204+// BenchmarkRendererSteadyFrame profiles the TestAllocRendererSteadyFrame
205+// cycle; it needs NEFERGUI_RENDER_NODE.
206+func BenchmarkRendererSteadyFrame(b *testing.B) {
207+ step := steadyFrame(b)
208+ b.ReportAllocs()
209+ for b.Loop() {
210+ step()
211+ }
212+}
213+
214+// steadyFrame returns one warmed Render+Released cycle for a counter view.
215+func steadyFrame(t testing.TB) func() {
216+ t.Helper()
196217 r, tg := gpuRenderer(t)
197218 var out Output
198219 n := 0
@@ -225,11 +246,7 @@ func TestAllocRendererSteadyFrame(t *testing.T) {
225246 for i := 0; i < 20; i++ { // warm up caches, atlas, buffers
226247 step()
227248 }
228- got := testing.AllocsPerRun(50, step)
229- t.Logf("allocs per steady Render+Released cycle: %v", got)
230- if got > allocBaselineRenderer {
231- t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
232- }
249+ return step
233250 }
234251
235252 // A readable eventfd whose point is not signaled (a stale notification) must be
@@ -182,9 +182,9 @@ func TestRendererGPUResizeRetiresBuffers(t *testing.T) {
182 }182 }
183 183
184 // allocBaselineRenderer is the measured allocation count of one steady184 // allocBaselineRenderer is the measured allocation count of one steady
185-// Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the185+// Render+Released cycle (radv); it is lowered with each optimization until it
186-// target is 0, the remainder is frame build, layout and prepare.186+// reaches the target of 0.
187-const allocBaselineRenderer = 244187+const allocBaselineRenderer = 48
188 188
189 // TestAllocRendererSteadyFrame measures Render with a view whose number189 // TestAllocRendererSteadyFrame measures Render with a view whose number
190 // changes on every frame and an unchanged structure. It runs on the real GPU190 // changes on every frame and an unchanged structure. It runs on the real GPU
@@ -193,6 +193,27 @@ func TestAllocRendererSteadyFrame(t *testing.T) {
193 if raceEnabled {193 if raceEnabled {
194 t.Skip("race instrumentation changes allocation counts")194 t.Skip("race instrumentation changes allocation counts")
195 }195 }
196+ step := steadyFrame(t)
197+ got := testing.AllocsPerRun(50, step)
198+ t.Logf("allocs per steady Render+Released cycle: %v", got)
199+ if got > allocBaselineRenderer {
200+ t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
201+ }
202+}
203+
204+// BenchmarkRendererSteadyFrame profiles the TestAllocRendererSteadyFrame
205+// cycle; it needs NEFERGUI_RENDER_NODE.
206+func BenchmarkRendererSteadyFrame(b *testing.B) {
207+ step := steadyFrame(b)
208+ b.ReportAllocs()
209+ for b.Loop() {
210+ step()
211+ }
212+}
213+
214+// steadyFrame returns one warmed Render+Released cycle for a counter view.
215+func steadyFrame(t testing.TB) func() {
216+ t.Helper()
196 r, tg := gpuRenderer(t)217 r, tg := gpuRenderer(t)
197 var out Output218 var out Output
198 n := 0219 n := 0
@@ -225,11 +246,7 @@ func TestAllocRendererSteadyFrame(t *testing.T) {
225 for i := 0; i < 20; i++ { // warm up caches, atlas, buffers246 for i := 0; i < 20; i++ { // warm up caches, atlas, buffers
226 step()247 step()
227 }248 }
228- got := testing.AllocsPerRun(50, step)249+ return step
229- t.Logf("allocs per steady Render+Released cycle: %v", got)
230- if got > allocBaselineRenderer {
231- t.Fatalf("allocs per steady frame = %v, baseline %v", got, allocBaselineRenderer)
232- }
233 }250 }
234 251
235 // A readable eventfd whose point is not signaled (a stale notification) must be252 // A readable eventfd whose point is not signaled (a stale notification) must be
M internal/ui/runtime.go +12 -10
@@ -31,6 +31,9 @@ type runtime struct {
3131 wake chan struct{}
3232 generation uint64
3333 committed *element
34+ frame Frame // reused every build; Node handles are fenced by generation
35+ arenas [2]elementArena // one holds the committed tree, the other builds the next
36+ committedArena int
3437 styles *css.Engine
3538 output layout.Output
3639 state interactionState
@@ -120,7 +123,13 @@ func (r *runtime) Build(view func(*Frame)) bool {
120123 state := r.state
121124 width, height := r.width, r.height
122125 r.mu.Unlock()
123- f := &Frame{width: width, height: height, generation: gen, active: true, events: events, previous: previous, styles: r.styles, state: state, edits: r.edits, clipboard: r.clipboard, ime: r.ime, layout: r.output, owner: r}
126+ // Build in the arena that does not hold the committed tree, which this
127+ // frame reads as previous. A frame that fails to commit leaves it as is.
128+ next := 1 - r.committedArena
129+ arena := &r.arenas[next]
130+ arena.reset()
131+ r.frame = Frame{arena: arena, width: width, height: height, generation: gen, active: true, events: events, previous: previous, styles: r.styles, state: state, edits: r.edits, clipboard: r.clipboard, ime: r.ime, layout: r.output, owner: r}
132+ f := &r.frame
124133 defer func() {
125134 f.active = false
126135 r.styles.EndFrame()
@@ -138,14 +147,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
138147 // State can change while the view runs. Schedule another frame instead of
139148 // overwriting that newer input with this frame's snapshot.
140149 if f.root != nil {
141- var refresh func(*element)
142- refresh = func(e *element) {
143- f.compute(e)
144- for _, ch := range e.children {
145- refresh(ch)
146- }
147- }
148- refresh(f.root)
150+ f.refresh(f.root)
149151 input := r.layoutTree(f.root)
150152 out, err := layout.Layout(input, layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine})
151153 if err != nil {
@@ -168,7 +170,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
168170 r.paintIndicators(f.root, &out)
169171 r.output = out
170172 }
171- r.committed = f.root
173+ r.committed, r.committedArena = f.root, next
172174 // Events mutate the model while the view runs, so elements declared before
173175 // the handling control painted the previous value. One follow-up frame,
174176 // without events, shows the settled state; it cannot schedule another.
@@ -31,6 +31,9 @@ type runtime struct {
31 wake chan struct{}31 wake chan struct{}
32 generation uint6432 generation uint64
33 committed *element33 committed *element
34+ frame Frame // reused every build; Node handles are fenced by generation
35+ arenas [2]elementArena // one holds the committed tree, the other builds the next
36+ committedArena int
34 styles *css.Engine37 styles *css.Engine
35 output layout.Output38 output layout.Output
36 state interactionState39 state interactionState
@@ -120,7 +123,13 @@ func (r *runtime) Build(view func(*Frame)) bool {
120 state := r.state123 state := r.state
121 width, height := r.width, r.height124 width, height := r.width, r.height
122 r.mu.Unlock()125 r.mu.Unlock()
123- f := &Frame{width: width, height: height, generation: gen, active: true, events: events, previous: previous, styles: r.styles, state: state, edits: r.edits, clipboard: r.clipboard, ime: r.ime, layout: r.output, owner: r}126+ // Build in the arena that does not hold the committed tree, which this
127+ // frame reads as previous. A frame that fails to commit leaves it as is.
128+ next := 1 - r.committedArena
129+ arena := &r.arenas[next]
130+ arena.reset()
131+ r.frame = Frame{arena: arena, width: width, height: height, generation: gen, active: true, events: events, previous: previous, styles: r.styles, state: state, edits: r.edits, clipboard: r.clipboard, ime: r.ime, layout: r.output, owner: r}
132+ f := &r.frame
124 defer func() {133 defer func() {
125 f.active = false134 f.active = false
126 r.styles.EndFrame()135 r.styles.EndFrame()
@@ -138,14 +147,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
138 // State can change while the view runs. Schedule another frame instead of147 // State can change while the view runs. Schedule another frame instead of
139 // overwriting that newer input with this frame's snapshot.148 // overwriting that newer input with this frame's snapshot.
140 if f.root != nil {149 if f.root != nil {
141- var refresh func(*element)150+ f.refresh(f.root)
142- refresh = func(e *element) {
143- f.compute(e)
144- for _, ch := range e.children {
145- refresh(ch)
146- }
147- }
148- refresh(f.root)
149 input := r.layoutTree(f.root)151 input := r.layoutTree(f.root)
150 out, err := layout.Layout(input, layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine})152 out, err := layout.Layout(input, layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine})
151 if err != nil {153 if err != nil {
@@ -168,7 +170,7 @@ func (r *runtime) Build(view func(*Frame)) bool {
168 r.paintIndicators(f.root, &out)170 r.paintIndicators(f.root, &out)
169 r.output = out171 r.output = out
170 }172 }
171- r.committed = f.root173+ r.committed, r.committedArena = f.root, next
172 // Events mutate the model while the view runs, so elements declared before174 // Events mutate the model while the view runs, so elements declared before
173 // the handling control painted the previous value. One follow-up frame,175 // the handling control painted the previous value. One follow-up frame,
174 // without events, shows the settled state; it cannot schedule another.176 // without events, shows the settled state; it cannot schedule another.
M internal/ui/tree.go +73 -12
@@ -40,6 +40,7 @@ type Frame struct {
4040 edits map[string]*edit.State
4141 clipboard edit.Clipboard
4242 owner *runtime
43+ arena *elementArena
4344 ime edit.IME
4445 state interactionState
4546 layout layout.Output
@@ -56,12 +57,15 @@ type Node struct {
5657 type element struct {
5758 identity *identity
5859 typ, role, id string
60+ key string // Key option, applied to identity when the element is built
61+ hasKey bool
5962 classes []string
6063 inline *css.Declarations
6164 computed *css.Computed
6265 parent *element
6366 children []*element
64- usedKeys map[string]bool
67+ prev *element // previous-frame counterpart while building; its identity is reused
68+ usedKeys map[string]bool // debug builds only
6569 events []inputEvent
6670 disabled bool
6771 checked bool
@@ -74,6 +78,7 @@ type element struct {
7478 level int
7579 rect layout.Rect // absolute geometry, see Node.Rect
7680 hasRect bool
81+ ln layout.Node // reused by layoutTree; never read after layout.Layout returns
7782 }
7883
7984 func (n Node) valid() bool {
@@ -95,10 +100,18 @@ func (f *Frame) Root(options ...ContainerOption) Node {
95100 if f.root != nil {
96101 panic("nefergui: Root called twice")
97102 }
98- root := &element{typ: "app", identity: &identity{typ: "app", path: "root"}, usedKeys: make(map[string]bool)}
103+ root := f.arena.newElement()
104+ root.typ = "app"
105+ applyContainer(root, options)
106+ if root.hasKey {
107+ root.identity = &identity{key: root.key}
108+ } else if p := f.previous; p != nil && p.identity != nil && p.identity.typ == "app" && p.identity.parent == nil && !p.identity.explicit {
109+ root.identity, root.prev = p.identity, p
110+ } else {
111+ root.identity = &identity{typ: "app", path: "root"}
112+ }
99113 f.root = root
100114 n := Node{f, root, f.generation}
101- applyContainer(root, options)
102115 f.compute(root)
103116 f.deliver(root)
104117 return n
@@ -112,6 +125,16 @@ func (f *Frame) compute(e *element) {
112125 e.computed = f.styles.Compute(&css.Element{Type: e.typ, ID: e.id, Classes: e.classes, Inline: e.inline, State: f.state.flags(e)}, p)
113126 }
114127 }
128+
129+// refresh recomputes styles after the view ran and drops the previous-tree
130+// links, which only serve identity reuse while building.
131+func (f *Frame) refresh(e *element) {
132+ f.compute(e)
133+ e.prev = nil
134+ for _, ch := range e.children {
135+ f.refresh(ch)
136+ }
137+}
115138 func (f *Frame) deliver(e *element) {
116139 for _, ev := range f.events {
117140 if ev.Target.same(e.identity) {
@@ -123,20 +146,32 @@ func (n Node) child(typ, role string, options []ContainerOption) Node {
123146 if !n.check() {
124147 return Node{}
125148 }
126- e := &element{typ: typ, role: role, parent: n.element, usedKeys: make(map[string]bool)}
149+ e := n.frame.arena.newElement()
150+ e.typ, e.role, e.parent = typ, role, n.element
127151 applyContainer(e, options)
128- i := &identity{parent: n.element.identity, typ: typ, position: len(n.element.children)}
129- if e.identity != nil {
130- i.explicit = true
131- i.key = e.identity.key
132- i.position = 0
133- i.typ = ""
134- if n.element.usedKeys[i.key] && debugDiagnostics {
152+ position := len(n.element.children)
153+ i := n.reuseIdentity(e, position)
154+ if i == nil {
155+ i = &identity{parent: n.element.identity, typ: typ, position: position}
156+ if e.hasKey {
157+ i.explicit = true
158+ i.key = e.key
159+ i.position = 0
160+ i.typ = ""
161+ }
162+ i.path = identityPath(i)
163+ } else {
164+ e.prev = n.element.prev.children[position]
165+ }
166+ if e.hasKey && debugDiagnostics {
167+ if n.element.usedKeys == nil {
168+ n.element.usedKeys = make(map[string]bool)
169+ }
170+ if n.element.usedKeys[i.key] {
135171 n.frame.diagnostics = append(n.frame.diagnostics, fmt.Sprintf("duplicate sibling key %q", i.key))
136172 }
137173 n.element.usedKeys[i.key] = true
138174 }
139- i.path = identityPath(i)
140175 e.identity = i
141176 if debugDiagnostics && !i.explicit && n.frame.previous != nil {
142177 if prior := findIdentity(n.frame.previous, n.element.identity); prior != nil && i.position < len(prior.children) {
@@ -151,6 +186,32 @@ func (n Node) child(typ, role string, options []ContainerOption) Node {
151186 n.frame.deliver(e)
152187 return Node{n.frame, e, n.generation}
153188 }
189+
190+// reuseIdentity returns the previous frame's identity for the child about to
191+// be appended at position when it denotes the same path, so a steady frame
192+// neither allocates identities nor rebuilds their path strings. It only looks
193+// at the same sibling position; any other case builds a fresh identity, which
194+// compares equal by value, so behaviour is unchanged. n.element.prev is set
195+// only when n.element reuses its own previous identity, which makes pointer
196+// equality of the parent a complete parent comparison.
197+func (n Node) reuseIdentity(e *element, position int) *identity {
198+ p := n.element.prev
199+ if p == nil || position >= len(p.children) {
200+ return nil
201+ }
202+ old := p.children[position].identity
203+ if old == nil || old.parent != n.element.identity {
204+ return nil
205+ }
206+ if e.hasKey {
207+ if !old.explicit || old.key != e.key {
208+ return nil
209+ }
210+ } else if old.explicit || old.position != position || old.typ != e.typ {
211+ return nil
212+ }
213+ return old
214+}
154215 func findIdentity(e *element, id *identity) *element {
155216 if e == nil {
156217 return nil
@@ -40,6 +40,7 @@ type Frame struct {
40 edits map[string]*edit.State40 edits map[string]*edit.State
41 clipboard edit.Clipboard41 clipboard edit.Clipboard
42 owner *runtime42 owner *runtime
43+ arena *elementArena
43 ime edit.IME44 ime edit.IME
44 state interactionState45 state interactionState
45 layout layout.Output46 layout layout.Output
@@ -56,12 +57,15 @@ type Node struct {
56 type element struct {57 type element struct {
57 identity *identity58 identity *identity
58 typ, role, id string59 typ, role, id string
60+ key string // Key option, applied to identity when the element is built
61+ hasKey bool
59 classes []string62 classes []string
60 inline *css.Declarations63 inline *css.Declarations
61 computed *css.Computed64 computed *css.Computed
62 parent *element65 parent *element
63 children []*element66 children []*element
64- usedKeys map[string]bool67+ prev *element // previous-frame counterpart while building; its identity is reused
68+ usedKeys map[string]bool // debug builds only
65 events []inputEvent69 events []inputEvent
66 disabled bool70 disabled bool
67 checked bool71 checked bool
@@ -74,6 +78,7 @@ type element struct {
74 level int78 level int
75 rect layout.Rect // absolute geometry, see Node.Rect79 rect layout.Rect // absolute geometry, see Node.Rect
76 hasRect bool80 hasRect bool
81+ ln layout.Node // reused by layoutTree; never read after layout.Layout returns
77 }82 }
78 83
79 func (n Node) valid() bool {84 func (n Node) valid() bool {
@@ -95,10 +100,18 @@ func (f *Frame) Root(options ...ContainerOption) Node {
95 if f.root != nil {100 if f.root != nil {
96 panic("nefergui: Root called twice")101 panic("nefergui: Root called twice")
97 }102 }
98- root := &element{typ: "app", identity: &identity{typ: "app", path: "root"}, usedKeys: make(map[string]bool)}103+ root := f.arena.newElement()
104+ root.typ = "app"
105+ applyContainer(root, options)
106+ if root.hasKey {
107+ root.identity = &identity{key: root.key}
108+ } else if p := f.previous; p != nil && p.identity != nil && p.identity.typ == "app" && p.identity.parent == nil && !p.identity.explicit {
109+ root.identity, root.prev = p.identity, p
110+ } else {
111+ root.identity = &identity{typ: "app", path: "root"}
112+ }
99 f.root = root113 f.root = root
100 n := Node{f, root, f.generation}114 n := Node{f, root, f.generation}
101- applyContainer(root, options)
102 f.compute(root)115 f.compute(root)
103 f.deliver(root)116 f.deliver(root)
104 return n117 return n
@@ -112,6 +125,16 @@ func (f *Frame) compute(e *element) {
112 e.computed = f.styles.Compute(&css.Element{Type: e.typ, ID: e.id, Classes: e.classes, Inline: e.inline, State: f.state.flags(e)}, p)125 e.computed = f.styles.Compute(&css.Element{Type: e.typ, ID: e.id, Classes: e.classes, Inline: e.inline, State: f.state.flags(e)}, p)
113 }126 }
114 }127 }
128+
129+// refresh recomputes styles after the view ran and drops the previous-tree
130+// links, which only serve identity reuse while building.
131+func (f *Frame) refresh(e *element) {
132+ f.compute(e)
133+ e.prev = nil
134+ for _, ch := range e.children {
135+ f.refresh(ch)
136+ }
137+}
115 func (f *Frame) deliver(e *element) {138 func (f *Frame) deliver(e *element) {
116 for _, ev := range f.events {139 for _, ev := range f.events {
117 if ev.Target.same(e.identity) {140 if ev.Target.same(e.identity) {
@@ -123,20 +146,32 @@ func (n Node) child(typ, role string, options []ContainerOption) Node {
123 if !n.check() {146 if !n.check() {
124 return Node{}147 return Node{}
125 }148 }
126- e := &element{typ: typ, role: role, parent: n.element, usedKeys: make(map[string]bool)}149+ e := n.frame.arena.newElement()
150+ e.typ, e.role, e.parent = typ, role, n.element
127 applyContainer(e, options)151 applyContainer(e, options)
128- i := &identity{parent: n.element.identity, typ: typ, position: len(n.element.children)}152+ position := len(n.element.children)
129- if e.identity != nil {153+ i := n.reuseIdentity(e, position)
130- i.explicit = true154+ if i == nil {
131- i.key = e.identity.key155+ i = &identity{parent: n.element.identity, typ: typ, position: position}
132- i.position = 0156+ if e.hasKey {
133- i.typ = ""157+ i.explicit = true
134- if n.element.usedKeys[i.key] && debugDiagnostics {158+ i.key = e.key
159+ i.position = 0
160+ i.typ = ""
161+ }
162+ i.path = identityPath(i)
163+ } else {
164+ e.prev = n.element.prev.children[position]
165+ }
166+ if e.hasKey && debugDiagnostics {
167+ if n.element.usedKeys == nil {
168+ n.element.usedKeys = make(map[string]bool)
169+ }
170+ if n.element.usedKeys[i.key] {
135 n.frame.diagnostics = append(n.frame.diagnostics, fmt.Sprintf("duplicate sibling key %q", i.key))171 n.frame.diagnostics = append(n.frame.diagnostics, fmt.Sprintf("duplicate sibling key %q", i.key))
136 }172 }
137 n.element.usedKeys[i.key] = true173 n.element.usedKeys[i.key] = true
138 }174 }
139- i.path = identityPath(i)
140 e.identity = i175 e.identity = i
141 if debugDiagnostics && !i.explicit && n.frame.previous != nil {176 if debugDiagnostics && !i.explicit && n.frame.previous != nil {
142 if prior := findIdentity(n.frame.previous, n.element.identity); prior != nil && i.position < len(prior.children) {177 if prior := findIdentity(n.frame.previous, n.element.identity); prior != nil && i.position < len(prior.children) {
@@ -151,6 +186,32 @@ func (n Node) child(typ, role string, options []ContainerOption) Node {
151 n.frame.deliver(e)186 n.frame.deliver(e)
152 return Node{n.frame, e, n.generation}187 return Node{n.frame, e, n.generation}
153 }188 }
189+
190+// reuseIdentity returns the previous frame's identity for the child about to
191+// be appended at position when it denotes the same path, so a steady frame
192+// neither allocates identities nor rebuilds their path strings. It only looks
193+// at the same sibling position; any other case builds a fresh identity, which
194+// compares equal by value, so behaviour is unchanged. n.element.prev is set
195+// only when n.element reuses its own previous identity, which makes pointer
196+// equality of the parent a complete parent comparison.
197+func (n Node) reuseIdentity(e *element, position int) *identity {
198+ p := n.element.prev
199+ if p == nil || position >= len(p.children) {
200+ return nil
201+ }
202+ old := p.children[position].identity
203+ if old == nil || old.parent != n.element.identity {
204+ return nil
205+ }
206+ if e.hasKey {
207+ if !old.explicit || old.key != e.key {
208+ return nil
209+ }
210+ } else if old.explicit || old.position != position || old.typ != e.typ {
211+ return nil
212+ }
213+ return old
214+}
154 func findIdentity(e *element, id *identity) *element {215 func findIdentity(e *element, id *identity) *element {
155 if e == nil {216 if e == nil {
156 return nil217 return nil

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 12

↑↓ moveenter openesc close