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 Unverified
B5690EEEBB952194The 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 | |||
| 29 | 29 | ||
| 30 | 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 | 34 | Frame construction limits allocation churn through: | |
| 35 | 35 | ||
| @@ -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, | |||
| 47 | 47 | } | |
| 48 | 48 | 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 | 58 | classes := make([]Class, len(text)) | |
| 51 | 59 | pairTypes := make([]bracketType, len(text)) | |
| 52 | 60 | pairValues := make([]rune, len(text)) | |
| @@ -92,6 +100,24 @@ func Resolve(text []rune, base BaseDirection, lineBreaks []int) ([]uint8, uint8, | |||
| 92 | 100 | 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 | 121 | // Reorder returns a visual-to-logical rune map per line using L2; X9-removed | |
| 96 | 122 | // formatting characters are excluded from the result. Levels must be the | |
| 97 | 123 | // 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 = end | 48 | 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), nil | 100 | 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-removed | 121 | // 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 the | 122 | // formatting characters are excluded from the result. Levels must be the | ||
| 97 | // per-line L1 output of Resolve. Consumers may group neighboring indices into | 123 | // 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 { | |||
| 580 | 580 | if absolute { | |
| 581 | 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 | 584 | r.ContentSize.W = math.Max(r.ContentSize.W, cw) | |
| 585 | 585 | if n.Kind == Stack { | |
| 586 | 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 | 592 | 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 | 604 | type flexItem struct { | |
| 596 | 605 | node *Node | |
| 597 | 606 | 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 | 889 | return err | |
| 881 | 890 | } | |
| 882 | 891 | if child != nil { | |
| 883 | - | r.Children = append(r.Children, child) | |
| 892 | + | r.Children = appendChild(r.Children, child, len(n.Children)) | |
| 884 | 893 | cw, ch := envelope(child) | |
| 885 | 894 | r.ContentSize.W = math.Max(r.ContentSize.W, child.Border.X-r.Content.X+cw) | |
| 886 | 895 | 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 nil | 592 | 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 *Node | 605 | node *Node | ||
| 597 | base, size, cross, grow, shrink, marginMain, marginCross float64 // sizes are border boxes | 606 | 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 err | 889 | 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 { | |||
| 20 | 20 | Wait *vkdevice.BinaryFence | |
| 21 | 21 | PendingWait *vkdevice.BinaryFence | |
| 22 | 22 | WaitInFlight bool | |
| 23 | + | spare *vkdevice.BinaryFence // consumed, unsignaled wait semaphore kept for reuse | |
| 23 | 24 | ReleaseHandle uint32 | |
| 24 | 25 | ReleaseFD int // eventfd armed on each commit's release point | |
| 25 | 26 | 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 | 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 | 77 | slot.Wait, slot.PendingWait, slot.WaitInFlight = next, nil, false | |
| 73 | 78 | 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 | 92 | type retiredPipelines struct { | |
| 77 | 93 | generation uint64 | |
| 78 | 94 | list *vkdevice.Pipeline | |
| @@ -20,6 +20,7 @@ type Slot struct { | |||||
| 20 | Wait *vkdevice.BinaryFence | 20 | Wait *vkdevice.BinaryFence | ||
| 21 | PendingWait *vkdevice.BinaryFence | 21 | PendingWait *vkdevice.BinaryFence | ||
| 22 | WaitInFlight bool | 22 | WaitInFlight bool | ||
| 23 | + | spare *vkdevice.BinaryFence // consumed, unsignaled wait semaphore kept for reuse | |||
| 23 | ReleaseHandle uint32 | 24 | ReleaseHandle uint32 | ||
| 24 | ReleaseFD int // eventfd armed on each commit's release point | 25 | ReleaseFD int // eventfd armed on each commit's release point | ||
| 25 | ReleaseExport int // exported syncobj fd of the release timeline | 26 | 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, false | 77 | slot.Wait, slot.PendingWait, slot.WaitInFlight = next, nil, false | ||
| 73 | return true, nil | 78 | 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 uint64 | 93 | generation uint64 | ||
| 78 | list *vkdevice.Pipeline | 94 | list *vkdevice.Pipeline | ||
M internal/presentation/session/target.go +21 -4
| @@ -221,6 +221,7 @@ func (t *Target) closeSlot(slot *Slot) error { | |||
| 221 | 221 | if slot.PendingWait != nil { | |
| 222 | 222 | slot.PendingWait.Close() | |
| 223 | 223 | } | |
| 224 | + | slot.spare.Close() | |
| 224 | 225 | if slot.Wait != nil { | |
| 225 | 226 | slot.Wait.Close() | |
| 226 | 227 | } | |
| @@ -315,7 +316,9 @@ func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command, | |||
| 315 | 316 | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |
| 316 | 317 | } | |
| 317 | 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 | 322 | slot.Wait = nil | |
| 320 | 323 | slot.WaitInFlight = false | |
| 321 | 324 | } | |
| @@ -331,7 +334,7 @@ func (t *Target) submit(b *buffers.Buffer, slot *Slot, display []layout.Command, | |||
| 331 | 334 | } | |
| 332 | 335 | slot.First = false | |
| 333 | 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 | 338 | if err != nil { | |
| 336 | 339 | return false, t.fail(err) | |
| 337 | 340 | } | |
| @@ -419,6 +422,16 @@ func drainEventFD(fd int) error { | |||
| 419 | 422 | 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 | 435 | func (t *Target) release(slot *Slot, point uint64) error { | |
| 423 | 436 | b := slot.Buffer | |
| 424 | 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 | 446 | if err != nil { | |
| 434 | 447 | return err | |
| 435 | 448 | } | |
| 436 | - | wait, err := t.Device.NewBinaryFence(false) | |
| 449 | + | wait, err := t.acquireWait(slot) | |
| 437 | 450 | if err != nil { | |
| 438 | 451 | _ = unix.Close(fd) | |
| 439 | 452 | return err | |
| @@ -476,7 +489,11 @@ func (t *Target) finishPending() (bool, error) { | |||
| 476 | 489 | continue | |
| 477 | 490 | } | |
| 478 | 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 | 498 | slot.Wait, slot.PendingWait, slot.WaitInFlight = slot.PendingWait, nil, false | |
| 482 | 499 | 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 = nil | 322 | slot.Wait = nil | ||
| 320 | slot.WaitInFlight = false | 323 | 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 = false | 335 | 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 nil | 422 | 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.Buffer | 436 | 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 err | 447 | 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 err | 452 | return err | ||
| @@ -476,7 +489,11 @@ func (t *Target) finishPending() (bool, error) { | |||||
| 476 | continue | 489 | 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, false | 498 | 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 ( | |||
| 15 | 15 | type BinaryFence struct { | |
| 16 | 16 | Device *Device | |
| 17 | 17 | 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 | 25 | func (d *Device) NewBinaryFence(export bool) (*BinaryFence, error) { | |
| @@ -26,27 +31,27 @@ func (d *Device) NewBinaryFence(export bool) (*BinaryFence, error) { | |||
| 26 | 31 | if err := vulkan.Check(d.Dispatch.CreateSemaphore(d.Logical, &ci, nil, &sem)); err != nil { | |
| 27 | 32 | return nil, fmt.Errorf("create SYNC_FD semaphore: %w", err) | |
| 28 | 33 | } | |
| 29 | - | return &BinaryFence{d, sem}, nil | |
| 34 | + | return &BinaryFence{Device: d, Semaphore: sem}, nil | |
| 30 | 35 | } | |
| 31 | 36 | var sem vulkan.Semaphore | |
| 32 | 37 | if err := vulkan.Check(d.Dispatch.CreateSemaphore(d.Logical, &ci, nil, &sem)); err != nil { | |
| 33 | 38 | return nil, err | |
| 34 | 39 | } | |
| 35 | - | return &BinaryFence{d, sem}, nil | |
| 40 | + | return &BinaryFence{Device: d, Semaphore: sem}, nil | |
| 36 | 41 | } | |
| 37 | 42 | ||
| 38 | 43 | 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 { | |
| 42 | 47 | return -1, fmt.Errorf("export Vulkan SYNC_FD: %w", err) | |
| 43 | 48 | } | |
| 44 | - | return int(fd), nil | |
| 49 | + | return int(s.fd), nil | |
| 45 | 50 | } | |
| 46 | 51 | ||
| 47 | 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)} | |
| 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)) | |
| 50 | 55 | } | |
| 51 | 56 | ||
| 52 | 57 | func (s *BinaryFence) Close() { | |
| @@ -15,6 +15,11 @@ import ( | |||||
| 15 | type BinaryFence struct { | 15 | type BinaryFence struct { | ||
| 16 | Device *Device | 16 | Device *Device | ||
| 17 | Semaphore vulkan.Semaphore | 17 | 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}, nil | 34 | + | return &BinaryFence{Device: d, Semaphore: sem}, nil |
| 30 | } | 35 | } | ||
| 31 | var sem vulkan.Semaphore | 36 | 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, err | 38 | return nil, err | ||
| 34 | } | 39 | } | ||
| 35 | - | return &BinaryFence{d, sem}, nil | 40 | + | 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), nil | 49 | + | 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 { | |||
| 35 | 35 | // inflight holds submitted frames whose image serial is not settled yet. | |
| 36 | 36 | // resolve polls them so an idle frame slot cannot stall completion. | |
| 37 | 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 | 43 | func (c *imageCache) Close() { | |
| @@ -145,7 +148,8 @@ func (c *imageCache) resolve(f *Frame, batches []Batch) ([]Batch, error) { | |||
| 145 | 148 | f.imageSerial = c.serial | |
| 146 | 149 | f.imageCopies = f.imageCopies[:0] | |
| 147 | 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 | 153 | var pixels []byte | |
| 150 | 154 | // Validate the entire frame before creating or changing any GPU resources. | |
| 151 | 155 | // 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.serial | 148 | 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 []byte | 153 | 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 { | |||
| 71 | 71 | } | |
| 72 | 72 | b.capacity = len(instances) | |
| 73 | 73 | } | |
| 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 { | |
| 76 | 76 | return err | |
| 77 | 77 | } | |
| 78 | - | copy(unsafe.Slice((*Instance)(ptr), len(instances)), instances) | |
| 78 | + | copy(unsafe.Slice((*Instance)(*ptr), len(instances)), instances) | |
| 79 | 79 | f.device.Dispatch.UnmapMemory(f.device.Logical, f.instances.Memory) | |
| 80 | + | *ptr = nil | |
| 80 | 81 | return nil | |
| 81 | 82 | } | |
| @@ -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.Pointer | 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 { | 75 | + | if err := vulkan.Check(f.device.Dispatch.MapMemory(f.device.Logical, f.instances.Memory, 0, size, 0, ptr)); err != nil { |
| 76 | return err | 76 | 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 nil | 81 | return nil | ||
| 81 | } | 82 | } | ||
M internal/presentation/vkdevice/readback.go +3 -2
| @@ -19,6 +19,7 @@ type Readback struct { | |||
| 19 | 19 | Buffer vulkan.Buffer | |
| 20 | 20 | Memory vulkan.DeviceMemory | |
| 21 | 21 | width, height int32 | |
| 22 | + | region vulkan.BufferImageCopy // reused: bindings heap-allocate pointer arguments | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 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 | 64 | return r, nil | |
| 64 | 65 | } | |
| 65 | 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.device.Dispatch.CmdCopyImageToBuffer(cmd, img, vulkan.ImageLayoutTransferSrcOptimal, r.Buffer, 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}} | |
| 68 | + | r.device.Dispatch.CmdCopyImageToBuffer(cmd, img, vulkan.ImageLayoutTransferSrcOptimal, r.Buffer, 1, &r.region) | |
| 68 | 69 | } | |
| 69 | 70 | ||
| 70 | 71 | // Copy returns the completed readback as an owned image whose pixels are still | |
| @@ -19,6 +19,7 @@ type Readback struct { | |||||
| 19 | Buffer vulkan.Buffer | 19 | Buffer vulkan.Buffer | ||
| 20 | Memory vulkan.DeviceMemory | 20 | Memory vulkan.DeviceMemory | ||
| 21 | width, height int32 | 21 | 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, nil | 64 | 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, ©) | 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 still | 71 | // 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 { | |||
| 30 | 30 | imageSerial uint64 | |
| 31 | 31 | atlasRecorded bool | |
| 32 | 32 | 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 | 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 | 125 | if err = vulkan.Check(f.device.Dispatch.ResetCommandBuffer(f.Commands, 0)); err != nil { | |
| 99 | 126 | return err | |
| 100 | 127 | } | |
| 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 | + | } | |
| 105 | 137 | } | |
| 106 | 138 | f.submitted = false | |
| 107 | 139 | } | |
| @@ -127,9 +159,10 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||
| 127 | 159 | 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 | 164 | 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 | 166 | return err | |
| 134 | 167 | } | |
| 135 | 168 | f.atlasRecorded = !f.device.atlas.initialized | |
| @@ -147,25 +180,26 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||
| 147 | 180 | if initial { | |
| 148 | 181 | 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} | |
| 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) | |
| 153 | 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 | 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} | |
| 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) | |
| 158 | 192 | d.CmdBindPipeline(f.Commands, vulkan.PipelineBindPointGraphics, pipe.Handle) | |
| 159 | 193 | // The fragment shader statically references both descriptor sets even | |
| 160 | 194 | // for non-image draws. Bind a valid initialized image set before any | |
| 161 | 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} | |
| 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)) | |
| 166 | 200 | 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) | |
| 169 | 203 | for _, batch := range batches { | |
| 170 | 204 | if batch.Count == 0 || uint64(batch.First)+uint64(batch.Count) > uint64(len(instances)) { | |
| 171 | 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 | 221 | barrier.DstAccessMask = vulkan.Access2TransferReadBit | |
| 188 | 222 | barrier.OldLayout = vulkan.ImageLayoutColorAttachmentOptimal | |
| 189 | 223 | barrier.NewLayout = vulkan.ImageLayoutTransferSrcOptimal | |
| 190 | - | d.CmdPipelineBarrier2(f.Commands, &dependency) | |
| 224 | + | d.CmdPipelineBarrier2(f.Commands, dependency) | |
| 191 | 225 | f.Readback.record(f.Commands, img.Image) | |
| 192 | 226 | barrier.SrcStageMask = vulkan.PipelineStage2TransferBit | |
| 193 | 227 | barrier.SrcAccessMask = vulkan.Access2TransferReadBit | |
| @@ -200,22 +234,23 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||
| 200 | 234 | barrier.DstStageMask = vulkan.PipelineStage2AllCommandsBit | |
| 201 | 235 | barrier.DstAccessMask = vulkan.Access2MemoryReadBit | |
| 202 | 236 | barrier.NewLayout = vulkan.ImageLayoutGeneral | |
| 203 | - | d.CmdPipelineBarrier2(f.Commands, &dependency) | |
| 237 | + | d.CmdPipelineBarrier2(f.Commands, dependency) | |
| 204 | 238 | return vulkan.Check(d.EndCommandBuffer(f.Commands)) | |
| 205 | 239 | } | |
| 206 | 240 | ||
| 207 | 241 | // Submit has no CPU wait: the next render waits on a temporarily imported | |
| 208 | 242 | // release SYNC_FD, and the compositor waits on the exported acquire SYNC_FD. | |
| 209 | 243 | 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} | |
| 213 | 248 | 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 | |
| 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 | 254 | // Only this recording's unsubmitted image state is released; a | |
| 220 | 255 | // previous submission's serial was completed by record. | |
| 221 | 256 | if f.device.images != nil && f.imageSerial != 0 { | |
| @@ -224,6 +259,7 @@ func (f *Frame) Submit(wait *BinaryFence) error { | |||
| 224 | 259 | return err | |
| 225 | 260 | } | |
| 226 | 261 | f.submitted = true | |
| 262 | + | f.signalExported = false | |
| 227 | 263 | if f.device.images != nil && f.imageSerial != 0 { | |
| 228 | 264 | f.device.images.inflight[f] = struct{}{} | |
| 229 | 265 | } | |
| @@ -236,6 +272,16 @@ func (f *Frame) Submit(wait *BinaryFence) error { | |||
| 236 | 272 | 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 | 285 | // settleImages runs once the frame is Ready. A submitted frame's serial is | |
| 240 | 286 | // completed and its upload list forgotten, so a later failed submission can | |
| 241 | 287 | // never abort textures that submitted work used. A recorded but unsubmitted | |
| @@ -30,6 +30,33 @@ type Frame struct { | |||||
| 30 | imageSerial uint64 | 30 | imageSerial uint64 | ||
| 31 | atlasRecorded bool | 31 | atlasRecorded bool | ||
| 32 | fallbackRecorded bool | 32 | 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 err | 126 | 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 err | 131 | + | 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 = false | 138 | f.submitted = false | ||
| 107 | } | 139 | } | ||
| @@ -127,9 +159,10 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||||
| 127 | return err | 159 | 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.Dispatch | 164 | 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 err | 166 | return err | ||
| 134 | } | 167 | } | ||
| 135 | f.atlasRecorded = !f.device.atlas.initialized | 168 | 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.ImageLayoutUndefined | 181 | 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 even | 193 | // The fragment shader statically references both descriptor sets even | ||
| 160 | // for non-image draws. Bind a valid initialized image set before any | 194 | // 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.Access2TransferReadBit | 221 | barrier.DstAccessMask = vulkan.Access2TransferReadBit | ||
| 188 | barrier.OldLayout = vulkan.ImageLayoutColorAttachmentOptimal | 222 | barrier.OldLayout = vulkan.ImageLayoutColorAttachmentOptimal | ||
| 189 | barrier.NewLayout = vulkan.ImageLayoutTransferSrcOptimal | 223 | 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.PipelineStage2TransferBit | 226 | barrier.SrcStageMask = vulkan.PipelineStage2TransferBit | ||
| 193 | barrier.SrcAccessMask = vulkan.Access2TransferReadBit | 227 | barrier.SrcAccessMask = vulkan.Access2TransferReadBit | ||
| @@ -200,22 +234,23 @@ func (f *Frame) record(img *Image, pipe *Pipeline, width, height int32, initial, | |||||
| 200 | barrier.DstStageMask = vulkan.PipelineStage2AllCommandsBit | 234 | barrier.DstStageMask = vulkan.PipelineStage2AllCommandsBit | ||
| 201 | barrier.DstAccessMask = vulkan.Access2MemoryReadBit | 235 | barrier.DstAccessMask = vulkan.Access2MemoryReadBit | ||
| 202 | barrier.NewLayout = vulkan.ImageLayoutGeneral | 236 | 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 imported | 241 | // 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 = 1 | 250 | + | sc.submit.WaitSemaphoreInfoCount = 1 |
| 216 | - | info.WaitSemaphoreInfos = &waiting | 251 | + | 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; a | 254 | // 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 err | 259 | return err | ||
| 225 | } | 260 | } | ||
| 226 | f.submitted = true | 261 | 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 nil | 272 | 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 is | 285 | // 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 can | 286 | // completed and its upload list forgotten, so a later failed submission can | ||
| 241 | // never abort textures that submitted work used. A recorded but unsubmitted | 287 | // never abort textures that submitted work used. A recorded but unsubmitted | ||
M internal/text/fonts.go +36 -0
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "math" | |
| 14 | 14 | "os" | |
| 15 | 15 | "path/filepath" | |
| 16 | + | "slices" | |
| 16 | 17 | "sort" | |
| 17 | 18 | "strings" | |
| 18 | 19 | ||
| @@ -282,6 +283,20 @@ type Catalog struct { | |||
| 282 | 283 | Faces []*Face | |
| 283 | 284 | source IndexSource | |
| 284 | 285 | 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 | 302 | func Load(source FontSource) (*Catalog, error) { | |
| @@ -411,6 +426,11 @@ func (f *Face) ready() bool { | |||
| 411 | 426 | } | |
| 412 | 427 | func (f *Face) fail(err error) { | |
| 413 | 428 | 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 | 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 | 527 | 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 | 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 | 547 | seen := map[*Face]bool{} | |
| 512 | 548 | var out []*Face | |
| 513 | 549 | 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 []*Face | 283 | Faces []*Face | ||
| 283 | source IndexSource | 284 | source IndexSource | ||
| 284 | Diagnostics []error | 285 | 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 = true | 428 | 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 out | 527 | 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 []*Face | 548 | 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 { | |||
| 28 | 28 | 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 | 33 | 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 | + | } | |
| 34 | 39 | 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)) | |
| 38 | 42 | } | |
| 39 | - | var out [32]byte | |
| 40 | - | copy(out[:], h.Sum(nil)) | |
| 41 | - | return out | |
| 43 | + | return sha256.Sum256(b) | |
| 42 | 44 | } | |
| 43 | 45 | func GlyphKey(face *Face, id font.GID, physicalSize, originX float64, variations []font.Variation) (Key, error) { | |
| 44 | 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) { | |
| @@ -28,17 +28,19 @@ type Mask struct { | |||||
| 28 | Origin image.Point | 28 | 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]byte | 35 | + | 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]byte | 43 | + | 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) { | |||
| 50 | 50 | for _, s := range cases { | |
| 51 | 51 | t.Run(s, func(t *testing.T) { | |
| 52 | 52 | r := []rune(s) | |
| 53 | - | faces := clusterFaces(r, choices) | |
| 53 | + | faces := clusterFaces(nil, r, choices) | |
| 54 | 54 | if len(faces) != len(r) { | |
| 55 | 55 | t.Fatalf("%d faces", len(faces)) | |
| 56 | 56 | } | |
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "math" | |
| 6 | + | "slices" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/nefergui/internal/bidi" | |
| 8 | 9 | "github.com/go-text/typesetting/di" | |
| @@ -53,7 +54,33 @@ type Engine struct { | |||
| 53 | 54 | seg shaping.Segmenter | |
| 54 | 55 | shaper shaping.HarfbuzzShaper | |
| 55 | 56 | wrapper shaping.LineWrapper | |
| 57 | + | never shaping.LineWrapper | |
| 56 | 58 | 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 | 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 | 92 | // clusterFaces selects a single face for every UAX#29 extended grapheme. Format | |
| 66 | 93 | // characters (ZWJ and variation selectors) do not need outline coverage; they | |
| 67 | 94 | // 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)] | |
| 70 | 97 | var seg segmenter.Segmenter | |
| 71 | 98 | seg.Init(text) | |
| 72 | 99 | it := seg.GraphemeIterator() | |
| @@ -110,8 +137,8 @@ func faceCovers(f *Face, cluster []rune) bool { | |||
| 110 | 137 | } | |
| 111 | 138 | ||
| 112 | 139 | // 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] | |
| 115 | 142 | for _, item := range items { | |
| 116 | 143 | if item.RunStart >= item.RunEnd { | |
| 117 | 144 | continue | |
| @@ -223,27 +250,34 @@ func (e *Engine) shape(text []rune, r Request, direction di.Direction, levels [] | |||
| 223 | 250 | if len(choices) == 0 { | |
| 224 | 251 | return nil, nil, errors.New("no usable fonts") | |
| 225 | 252 | } | |
| 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] | |
| 228 | 259 | // Variations belong to the engine invocation; do not mutate the catalog's shared faces. | |
| 229 | 260 | for _, f := range choices { | |
| 230 | 261 | if len(r.Variations) > 0 { | |
| 231 | 262 | f = f.WithVariations(r.Variations) | |
| 232 | 263 | } | |
| 233 | 264 | faces = append(faces, f) | |
| 234 | - | reverse[f.Shape] = f | |
| 265 | + | 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 | 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) | |
| 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 { | |
| 242 | 274 | if item.RunEnd > item.RunStart { | |
| 243 | 275 | out = append(out, e.shaper.Shape(item)) | |
| 244 | 276 | } | |
| 245 | 277 | } | |
| 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 | |
| 247 | 281 | } | |
| 248 | 282 | ||
| 249 | 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 | 302 | // once after each frame that may have measured text. | |
| 269 | 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 | 312 | func (e *Engine) measure(s string, r Request, width float64) (Layout, error) { | |
| 272 | 313 | var result Layout | |
| 273 | 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 | 317 | if math.IsNaN(r.LetterSpacing) || math.IsInf(r.LetterSpacing, 0) || math.Abs(r.LetterSpacing) > 4096 { | |
| 277 | 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 | 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 | 326 | if r.Direction > bidi.RTL { | |
| 284 | 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 | 360 | var minWidth float64 | |
| 318 | 361 | // Compute exact word widths even when a shaped run crosses a break opportunity. | |
| 319 | 362 | // 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)) | |
| 322 | 364 | for _, line := range words { | |
| 323 | 365 | w := 0.0 | |
| 324 | 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 | 373 | 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 | 375 | for _, line := range maxLines { | |
| 334 | 376 | w := 0.0 | |
| 335 | 377 | for _, o := range line { | |
| @@ -343,22 +385,38 @@ func (e *Engine) measure(s string, r Request, width float64) (Layout, error) { | |||
| 343 | 385 | if width > 0 { | |
| 344 | 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 | 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 | 394 | if err != nil { | |
| 350 | 395 | return result, err | |
| 351 | 396 | } | |
| 352 | 397 | dst := Line{Baseline: baseline + float64(idx)*height, Height: height, Direction: direction} | |
| 398 | + | glyphCount := 0 | |
| 353 | 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 | 409 | 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} | |
| 357 | 411 | cursor := 0.0 | |
| 412 | + | first := len(glyphs) | |
| 358 | 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 | 415 | cursor += float64(g.Advance) / 64 | |
| 361 | 416 | } | |
| 417 | + | if len(glyphs) > first { | |
| 418 | + | run.Glyphs = glyphs[first:len(glyphs):len(glyphs)] | |
| 419 | + | } | |
| 362 | 420 | dst.Runs = append(dst.Runs, run) | |
| 363 | 421 | dst.Width += run.Advance | |
| 364 | 422 | } | |
| @@ -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.Segmenter | 54 | seg shaping.Segmenter | ||
| 54 | shaper shaping.HarfbuzzShaper | 55 | shaper shaping.HarfbuzzShaper | ||
| 55 | wrapper shaping.LineWrapper | 56 | wrapper shaping.LineWrapper | ||
| 57 | + | never shaping.LineWrapper | |||
| 56 | cache measureCache | 58 | 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. Format | 92 | // clusterFaces selects a single face for every UAX#29 extended grapheme. Format | ||
| 66 | // characters (ZWJ and variation selectors) do not need outline coverage; they | 93 | // 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.Segmenter | 97 | 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.Input | 141 | + | 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 | continue | 144 | 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] = f | 265 | + | 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, 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 | |||
| 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 Layout | 313 | 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 float64 | 360 | 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.LineWrapper | 363 | + | 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.0 | 365 | 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 = minWidth | 373 | 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.0 | 376 | 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, err | 395 | 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.0 | 411 | 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) / 64 | 415 | 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.Advance | 421 | dst.Width += run.Advance | ||
| 364 | } | 422 | } | ||
M internal/text/text_test.go +35 -0
| @@ -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 | + | } | |
| @@ -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 { | |||
| 19 | 19 | // splitLevels prevents a single shaping run from crossing resolved paragraph | |
| 20 | 20 | // levels (not merely direction parity). The wrapper can subsequently split | |
| 21 | 21 | // 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] | |
| 24 | 24 | for _, item := range items { | |
| 25 | 25 | if item.RunStart >= item.RunEnd { | |
| 26 | 26 | continue | |
| @@ -52,23 +52,29 @@ func directionFor(level uint8) di.Direction { | |||
| 52 | 52 | type runItem struct { | |
| 53 | 53 | output shaping.Output | |
| 54 | 54 | level uint8 | |
| 55 | - | pos int | |
| 56 | 55 | } | |
| 57 | 56 | ||
| 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 | |
| 59 | 65 | // resolving levels against the *actual wrapped line breaks*, then applies L2 | |
| 60 | 66 | // to complete runs. The shaper already emits glyphs in visual order within an | |
| 61 | 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 | 69 | if len(line) == 0 { | |
| 64 | - | return nil, nil, nil | |
| 70 | + | return nil, nil | |
| 65 | 71 | } | |
| 66 | 72 | start, end := line[0].Runes.Offset, line[len(line)-1].Runes.Offset+line[len(line)-1].Runes.Count | |
| 67 | 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 | 76 | // Include earlier lines so weak and neutral resolution use paragraph context. | |
| 71 | - | breaks := []int{} | |
| 77 | + | breaks := v.breaks[:0] | |
| 72 | 78 | if start > 0 { | |
| 73 | 79 | breaks = append(breaks, start) | |
| 74 | 80 | } | |
| @@ -76,28 +82,35 @@ func visualRuns(text []rune, line shaping.Line, base bidi.BaseDirection) (shapin | |||
| 76 | 82 | if end < len(text) { | |
| 77 | 83 | breaks = append(breaks, len(text)) | |
| 78 | 84 | } | |
| 85 | + | v.breaks = breaks | |
| 79 | 86 | levels, _, err := bidi.Resolve(text, base, breaks) | |
| 80 | 87 | if err != nil { | |
| 81 | - | return nil, nil, err | |
| 88 | + | return nil, err | |
| 82 | 89 | } | |
| 83 | 90 | // The wrapper's runs are in logical order. A level change within one output | |
| 84 | 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 | 94 | for _, o := range line { | |
| 87 | 95 | s, e := o.Runes.Offset, o.Runes.Offset+o.Runes.Count | |
| 88 | 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 | 103 | // L1 may reset a trailing space in a shaped RTL run. Split the output at | |
| 92 | 104 | // cluster boundaries, preserving its glyph metrics and HarfBuzz order. | |
| 93 | 105 | parts, err := splitWrappedLevel(o, levels) | |
| 94 | 106 | if err != nil { | |
| 95 | - | return nil, nil, err | |
| 107 | + | return nil, err | |
| 96 | 108 | } | |
| 97 | 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 | 114 | // L2 on run levels: from maximum down to the minimum odd level, reverse | |
| 102 | 115 | // contiguous sequences whose levels are >= the current level. | |
| 103 | 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)) | |
| 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 | + | } | |
| 135 | 151 | } | |
| 136 | - | return out, lookup, nil | |
| 152 | + | return true | |
| 137 | 153 | } | |
| 138 | 154 | ||
| 139 | 155 | // 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 | 159 | if start < 0 || end > len(levels) || start >= end { | |
| 144 | 160 | return nil, fmt.Errorf("invalid output range") | |
| 145 | 161 | } | |
| 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) { | |
| 154 | 163 | return []shaping.Output{o}, nil | |
| 155 | 164 | } | |
| 156 | 165 | // 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 paragraph | 19 | // splitLevels prevents a single shaping run from crossing resolved paragraph | ||
| 20 | // levels (not merely direction parity). The wrapper can subsequently split | 20 | // 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.Input | 23 | + | 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 | continue | 26 | continue | ||
| @@ -52,23 +52,29 @@ func directionFor(level uint8) di.Direction { | |||||
| 52 | type runItem struct { | 52 | type runItem struct { | ||
| 53 | output shaping.Output | 53 | output shaping.Output | ||
| 54 | level uint8 | 54 | level uint8 | ||
| 55 | - | pos int | |||
| 56 | } | 55 | } | ||
| 57 | 56 | ||||
| 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 | |||
| 59 | // resolving levels against the *actual wrapped line breaks*, then applies L2 | 65 | // resolving levels against the *actual wrapped line breaks*, then applies L2 | ||
| 60 | // to complete runs. The shaper already emits glyphs in visual order within an | 66 | // 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, nil | 70 | + | return nil, nil |
| 65 | } | 71 | } | ||
| 66 | start, end := line[0].Runes.Offset, line[len(line)-1].Runes.Offset+line[len(line)-1].Runes.Count | 72 | 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, err | 88 | + | return nil, err |
| 82 | } | 89 | } | ||
| 83 | // The wrapper's runs are in logical order. A level change within one output | 90 | // 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.Count | 95 | 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 at | 103 | // 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, err | 107 | + | 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, reverse | 114 | // 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.level | 147 | + | for i := start + 1; i < end; i++ { |
| 148 | + | if levels[i] != levels[start] { | |||
| 149 | + | return false | |||
| 150 | + | } | |||
| 135 | } | 151 | } | ||
| 136 | - | return out, lookup, nil | 152 | + | return true |
| 137 | } | 153 | } | ||
| 138 | 154 | ||||
| 139 | // splitWrappedLevel divides only at glyph cluster boundaries; UAX#14 wraps | 155 | // 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 := true | 162 | + | 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}, nil | 163 | 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 { | |||
| 108 | 108 | 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 | 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 | 117 | if e.computed != nil { | |
| 114 | 118 | n.Style = e.computed.Style | |
| 115 | 119 | } | |
| @@ -108,8 +108,12 @@ func layoutKind(e *element) layout.Kind { | |||||
| 108 | return layout.Box | 108 | 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.Style | 118 | n.Style = e.computed.Style | ||
| 115 | } | 119 | } | ||
M internal/ui/options.go +30 -11
| @@ -12,22 +12,41 @@ type ValueOption interface{ valueOption(*element) } | |||
| 12 | 12 | ||
| 13 | 13 | // CommonOption is the concrete type of Key, ID, Class and Inline; it belongs | |
| 14 | 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 | 42 | func (o CommonOption) container(e *element) { o.apply(e) } | |
| 18 | 43 | func (o CommonOption) button(e *element) { o.apply(e) } | |
| 19 | 44 | func (o CommonOption) edit(e *element) { o.apply(e) } | |
| 20 | 45 | 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} } | |
| 31 | 50 | func applyContainer(e *element, opts []ContainerOption) { | |
| 32 | 51 | for _, o := range opts { | |
| 33 | 52 | 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 belongs | 13 | // 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) { | |||
| 182 | 182 | } | |
| 183 | 183 | ||
| 184 | 184 | // 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 | |
| 188 | 188 | ||
| 189 | 189 | // TestAllocRendererSteadyFrame measures Render with a view whose number | |
| 190 | 190 | // changes on every frame and an unchanged structure. It runs on the real GPU | |
| @@ -193,6 +193,27 @@ func TestAllocRendererSteadyFrame(t *testing.T) { | |||
| 193 | 193 | if raceEnabled { | |
| 194 | 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 | 217 | r, tg := gpuRenderer(t) | |
| 197 | 218 | var out Output | |
| 198 | 219 | n := 0 | |
| @@ -225,11 +246,7 @@ func TestAllocRendererSteadyFrame(t *testing.T) { | |||
| 225 | 246 | for i := 0; i < 20; i++ { // warm up caches, atlas, buffers | |
| 226 | 247 | step() | |
| 227 | 248 | } | |
| 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 | |
| 233 | 250 | } | |
| 234 | 251 | ||
| 235 | 252 | // 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 steady | 184 | // allocBaselineRenderer is the measured allocation count of one steady | ||
| 185 | - | // Render+Released cycle on 2026-10-01 (radv, renderD128 and renderD129); the | 185 | + | // 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 = 244 | 187 | + | const allocBaselineRenderer = 48 |
| 188 | 188 | ||||
| 189 | // TestAllocRendererSteadyFrame measures Render with a view whose number | 189 | // TestAllocRendererSteadyFrame measures Render with a view whose number | ||
| 190 | // changes on every frame and an unchanged structure. It runs on the real GPU | 190 | // 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 Output | 218 | var out Output | ||
| 198 | n := 0 | 219 | n := 0 | ||
| @@ -225,11 +246,7 @@ func TestAllocRendererSteadyFrame(t *testing.T) { | |||||
| 225 | for i := 0; i < 20; i++ { // warm up caches, atlas, buffers | 246 | 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 be | 252 | // 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 { | |||
| 31 | 31 | wake chan struct{} | |
| 32 | 32 | generation uint64 | |
| 33 | 33 | 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 | 37 | styles *css.Engine | |
| 35 | 38 | output layout.Output | |
| 36 | 39 | state interactionState | |
| @@ -120,7 +123,13 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 120 | 123 | state := r.state | |
| 121 | 124 | width, height := r.width, r.height | |
| 122 | 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 | 133 | defer func() { | |
| 125 | 134 | f.active = false | |
| 126 | 135 | r.styles.EndFrame() | |
| @@ -138,14 +147,7 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 138 | 147 | // State can change while the view runs. Schedule another frame instead of | |
| 139 | 148 | // overwriting that newer input with this frame's snapshot. | |
| 140 | 149 | 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) | |
| 149 | 151 | input := r.layoutTree(f.root) | |
| 150 | 152 | out, err := layout.Layout(input, layout.Options{Width: r.width, Height: r.height, TextEngine: r.textEngine}) | |
| 151 | 153 | if err != nil { | |
| @@ -168,7 +170,7 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||
| 168 | 170 | r.paintIndicators(f.root, &out) | |
| 169 | 171 | r.output = out | |
| 170 | 172 | } | |
| 171 | - | r.committed = f.root | |
| 173 | + | r.committed, r.committedArena = f.root, next | |
| 172 | 174 | // Events mutate the model while the view runs, so elements declared before | |
| 173 | 175 | // the handling control painted the previous value. One follow-up frame, | |
| 174 | 176 | // 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 uint64 | 32 | generation uint64 | ||
| 33 | committed *element | 33 | 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.Engine | 37 | styles *css.Engine | ||
| 35 | output layout.Output | 38 | output layout.Output | ||
| 36 | state interactionState | 39 | state interactionState | ||
| @@ -120,7 +123,13 @@ func (r *runtime) Build(view func(*Frame)) bool { | |||||
| 120 | state := r.state | 123 | state := r.state | ||
| 121 | width, height := r.width, r.height | 124 | 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 = false | 134 | 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 of | 147 | // 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 = out | 171 | r.output = out | ||
| 170 | } | 172 | } | ||
| 171 | - | r.committed = f.root | 173 | + | r.committed, r.committedArena = f.root, next |
| 172 | // Events mutate the model while the view runs, so elements declared before | 174 | // 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 { | |||
| 40 | 40 | edits map[string]*edit.State | |
| 41 | 41 | clipboard edit.Clipboard | |
| 42 | 42 | owner *runtime | |
| 43 | + | arena *elementArena | |
| 43 | 44 | ime edit.IME | |
| 44 | 45 | state interactionState | |
| 45 | 46 | layout layout.Output | |
| @@ -56,12 +57,15 @@ type Node struct { | |||
| 56 | 57 | type element struct { | |
| 57 | 58 | identity *identity | |
| 58 | 59 | typ, role, id string | |
| 60 | + | key string // Key option, applied to identity when the element is built | |
| 61 | + | hasKey bool | |
| 59 | 62 | classes []string | |
| 60 | 63 | inline *css.Declarations | |
| 61 | 64 | computed *css.Computed | |
| 62 | 65 | parent *element | |
| 63 | 66 | 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 | |
| 65 | 69 | events []inputEvent | |
| 66 | 70 | disabled bool | |
| 67 | 71 | checked bool | |
| @@ -74,6 +78,7 @@ type element struct { | |||
| 74 | 78 | level int | |
| 75 | 79 | rect layout.Rect // absolute geometry, see Node.Rect | |
| 76 | 80 | hasRect bool | |
| 81 | + | ln layout.Node // reused by layoutTree; never read after layout.Layout returns | |
| 77 | 82 | } | |
| 78 | 83 | ||
| 79 | 84 | func (n Node) valid() bool { | |
| @@ -95,10 +100,18 @@ func (f *Frame) Root(options ...ContainerOption) Node { | |||
| 95 | 100 | if f.root != nil { | |
| 96 | 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 | 113 | f.root = root | |
| 100 | 114 | n := Node{f, root, f.generation} | |
| 101 | - | applyContainer(root, options) | |
| 102 | 115 | f.compute(root) | |
| 103 | 116 | f.deliver(root) | |
| 104 | 117 | return n | |
| @@ -112,6 +125,16 @@ func (f *Frame) compute(e *element) { | |||
| 112 | 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 | 138 | func (f *Frame) deliver(e *element) { | |
| 116 | 139 | for _, ev := range f.events { | |
| 117 | 140 | if ev.Target.same(e.identity) { | |
| @@ -123,20 +146,32 @@ func (n Node) child(typ, role string, options []ContainerOption) Node { | |||
| 123 | 146 | if !n.check() { | |
| 124 | 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 | 151 | 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] { | |
| 135 | 171 | n.frame.diagnostics = append(n.frame.diagnostics, fmt.Sprintf("duplicate sibling key %q", i.key)) | |
| 136 | 172 | } | |
| 137 | 173 | n.element.usedKeys[i.key] = true | |
| 138 | 174 | } | |
| 139 | - | i.path = identityPath(i) | |
| 140 | 175 | e.identity = i | |
| 141 | 176 | if debugDiagnostics && !i.explicit && n.frame.previous != nil { | |
| 142 | 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 | 186 | n.frame.deliver(e) | |
| 152 | 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 | 215 | func findIdentity(e *element, id *identity) *element { | |
| 155 | 216 | if e == nil { | |
| 156 | 217 | return nil | |
| @@ -40,6 +40,7 @@ type Frame struct { | |||||
| 40 | edits map[string]*edit.State | 40 | edits map[string]*edit.State | ||
| 41 | clipboard edit.Clipboard | 41 | clipboard edit.Clipboard | ||
| 42 | owner *runtime | 42 | owner *runtime | ||
| 43 | + | arena *elementArena | |||
| 43 | ime edit.IME | 44 | ime edit.IME | ||
| 44 | state interactionState | 45 | state interactionState | ||
| 45 | layout layout.Output | 46 | layout layout.Output | ||
| @@ -56,12 +57,15 @@ type Node struct { | |||||
| 56 | type element struct { | 57 | type element struct { | ||
| 57 | identity *identity | 58 | identity *identity | ||
| 58 | typ, role, id string | 59 | typ, role, id string | ||
| 60 | + | key string // Key option, applied to identity when the element is built | |||
| 61 | + | hasKey bool | |||
| 59 | classes []string | 62 | classes []string | ||
| 60 | inline *css.Declarations | 63 | inline *css.Declarations | ||
| 61 | computed *css.Computed | 64 | computed *css.Computed | ||
| 62 | parent *element | 65 | parent *element | ||
| 63 | children []*element | 66 | 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 | |||
| 65 | events []inputEvent | 69 | events []inputEvent | ||
| 66 | disabled bool | 70 | disabled bool | ||
| 67 | checked bool | 71 | checked bool | ||
| @@ -74,6 +78,7 @@ type element struct { | |||||
| 74 | level int | 78 | level int | ||
| 75 | rect layout.Rect // absolute geometry, see Node.Rect | 79 | rect layout.Rect // absolute geometry, see Node.Rect | ||
| 76 | hasRect bool | 80 | 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 = root | 113 | 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 n | 117 | 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 = true | 154 | + | if i == nil { |
| 131 | - | i.key = e.identity.key | 155 | + | i = &identity{parent: n.element.identity, typ: typ, position: position} |
| 132 | - | i.position = 0 | 156 | + | 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] = true | 173 | n.element.usedKeys[i.key] = true | ||
| 138 | } | 174 | } | ||
| 139 | - | i.path = identityPath(i) | |||
| 140 | e.identity = i | 175 | 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 nil | 217 | return nil | ||