perf(render): reduce frame construction and idle wait allocations
- commit
- 7e46072588c8dce90eaa8bc11f89a5564f182b04
- author
- brice · · 2026-09-30 04:11 UTC
- parent
- 1488e4a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +1007 -86
M internal/layout/layout.go +150 -18
| @@ -178,11 +178,29 @@ func Layout(root *Node, options Options) (Output, error) { | |||
| 178 | 178 | return Output{}, err | |
| 179 | 179 | } | |
| 180 | 180 | out := Output{Tree: tree} | |
| 181 | - | ctx.paint(root, tree, &out.Display) | |
| 181 | + | out.Display = ctx.display(root, tree) | |
| 182 | 182 | return out, nil | |
| 183 | 183 | } | |
| 184 | 184 | ||
| 185 | - | type context struct{ options Options } | |
| 185 | + | // display paints the placed tree into a list sized once from the tree. | |
| 186 | + | func (c context) display(root *Node, tree *Result) []Command { | |
| 187 | + | n := countCommands(root, tree) | |
| 188 | + | if n.commands == 0 { | |
| 189 | + | return nil | |
| 190 | + | } | |
| 191 | + | out := make([]Command, 0, n.commands) | |
| 192 | + | c.shadows, c.runs, c.glyphs = newSlab[css.Shadow](n.shadows), newSlab[Run](n.runs), newSlab[Glyph](n.glyphs) | |
| 193 | + | c.paint(root, tree, &out) | |
| 194 | + | return out | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | type context struct { | |
| 198 | + | options Options | |
| 199 | + | // Painter-owned display storage, sized by countCommands; nil is valid. | |
| 200 | + | shadows *slab[css.Shadow] | |
| 201 | + | runs *slab[Run] | |
| 202 | + | glyphs *slab[Glyph] | |
| 203 | + | } | |
| 186 | 204 | ||
| 187 | 205 | func style(n *Node) css.Style { | |
| 188 | 206 | if n.Style != nil { | |
| @@ -743,56 +761,157 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error { | |||
| 743 | 761 | return nil | |
| 744 | 762 | } | |
| 745 | 763 | ||
| 764 | + | // slab hands out detached values or sub-slices from one exactly sized block, | |
| 765 | + | // so a display list costs a few allocations rather than one per shadow, run or | |
| 766 | + | // text command. Blocks are never resized or reused, so pointers and slices | |
| 767 | + | // stay valid for the life of the returned Output. Sub-slices have their | |
| 768 | + | // capacity clipped, so a caller's append cannot overwrite a neighbour. A | |
| 769 | + | // miscount, or a nil slab, only falls back to individual allocations. | |
| 770 | + | type slab[T any] struct{ block []T } | |
| 771 | + | ||
| 772 | + | func newSlab[T any](n int) *slab[T] { | |
| 773 | + | if n <= 0 { | |
| 774 | + | return nil | |
| 775 | + | } | |
| 776 | + | return &slab[T]{block: make([]T, 0, n)} | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | // one stores v in the slab and returns its address. | |
| 780 | + | func (a *slab[T]) one(v T) *T { | |
| 781 | + | if a == nil || len(a.block) == cap(a.block) { | |
| 782 | + | p := new(T) // copy, so v itself never escapes | |
| 783 | + | *p = v | |
| 784 | + | return p | |
| 785 | + | } | |
| 786 | + | a.block = append(a.block, v) | |
| 787 | + | return &a.block[len(a.block)-1] | |
| 788 | + | } | |
| 789 | + | ||
| 790 | + | // take returns an empty slice with capacity exactly n (nil for n == 0). | |
| 791 | + | func (a *slab[T]) take(n int) []T { | |
| 792 | + | if n <= 0 { | |
| 793 | + | return nil | |
| 794 | + | } | |
| 795 | + | if a == nil || cap(a.block)-len(a.block) < n { | |
| 796 | + | return make([]T, 0, n) | |
| 797 | + | } | |
| 798 | + | i := len(a.block) | |
| 799 | + | a.block = a.block[:i+n] | |
| 800 | + | return a.block[i : i : i+n] | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | // radiiOf resolves border-radius percentages against basis. | |
| 804 | + | func radiiOf(s *css.Style, basis float64) [4]float64 { | |
| 805 | + | return [4]float64{positive(s.BorderRadius.TopLeft, basis), positive(s.BorderRadius.TopRight, basis), positive(s.BorderRadius.BottomRight, basis), positive(s.BorderRadius.BottomLeft, basis)} | |
| 806 | + | } | |
| 807 | + | ||
| 808 | + | func hasOutline(s *css.Style, r *Result) bool { | |
| 809 | + | return s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | // countCommands returns how many commands and shadow copies paint emits for | |
| 813 | + | // the subtree, so Layout can size the display list once. It mirrors paint's | |
| 814 | + | // conditions; if it ever drifts, paint still appends correctly and only regrows. | |
| 815 | + | func countCommands(n *Node, r *Result) (c counts) { | |
| 816 | + | if r == nil { | |
| 817 | + | return c | |
| 818 | + | } | |
| 819 | + | s := style(n) | |
| 820 | + | total := len(s.BoxShadow) | |
| 821 | + | c.shadows = len(s.BoxShadow) | |
| 822 | + | if s.BackgroundColor.A > 0 { | |
| 823 | + | total++ | |
| 824 | + | } | |
| 825 | + | if r.BorderWidths != (Edges{}) || radiiOf(&s, r.Border.W) != ([4]float64{}) { | |
| 826 | + | total++ | |
| 827 | + | } | |
| 828 | + | if hasOutline(&s, r) { | |
| 829 | + | total++ | |
| 830 | + | } | |
| 831 | + | if r.Clipped { | |
| 832 | + | total += 2 | |
| 833 | + | } | |
| 834 | + | if n.Kind == Image || n.Kind == Text { | |
| 835 | + | total++ | |
| 836 | + | } | |
| 837 | + | if n.Kind == Text { | |
| 838 | + | for i := range r.Lines { | |
| 839 | + | c.runs += len(r.Lines[i].Runs) | |
| 840 | + | for j := range r.Lines[i].Runs { | |
| 841 | + | c.glyphs += len(r.Lines[i].Runs[j].Glyphs) | |
| 842 | + | } | |
| 843 | + | } | |
| 844 | + | } | |
| 845 | + | j := 0 | |
| 846 | + | for _, child := range n.Children { | |
| 847 | + | if child == nil || style(child).Display == css.KeywordNone { | |
| 848 | + | continue | |
| 849 | + | } | |
| 850 | + | if j < len(r.Children) { | |
| 851 | + | cc := countCommands(child, r.Children[j]) | |
| 852 | + | total += cc.commands | |
| 853 | + | c.shadows += cc.shadows | |
| 854 | + | c.runs += cc.runs | |
| 855 | + | c.glyphs += cc.glyphs | |
| 856 | + | j++ | |
| 857 | + | } | |
| 858 | + | } | |
| 859 | + | c.commands += total | |
| 860 | + | return c | |
| 861 | + | } | |
| 862 | + | ||
| 863 | + | type counts struct{ commands, shadows, runs, glyphs int } | |
| 864 | + | ||
| 746 | 865 | func (c context) paint(n *Node, r *Result, out *[]Command) { | |
| 747 | 866 | if r == nil { | |
| 748 | 867 | return | |
| 749 | 868 | } | |
| 750 | 869 | s := style(n) | |
| 751 | 870 | base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))} | |
| 871 | + | borderRadii := radiiOf(&s, r.Border.W) | |
| 752 | 872 | for i := range s.BoxShadow { | |
| 753 | - | sh := s.BoxShadow[i] | |
| 754 | - | if sh.Inset { | |
| 873 | + | if s.BoxShadow[i].Inset { | |
| 755 | 874 | continue | |
| 756 | 875 | } | |
| 757 | 876 | cmd := base | |
| 758 | 877 | cmd.Op = "shadow" | |
| 759 | - | cmd.Shadow = &sh | |
| 760 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 878 | + | cmd.Shadow = c.shadows.one(s.BoxShadow[i]) | |
| 879 | + | cmd.Radii = borderRadii | |
| 761 | 880 | *out = append(*out, cmd) | |
| 762 | 881 | } | |
| 763 | 882 | if s.BackgroundColor.A > 0 { | |
| 764 | 883 | cmd := base | |
| 765 | 884 | cmd.Op = "rect" | |
| 766 | 885 | cmd.Color = s.BackgroundColor | |
| 767 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 886 | + | cmd.Radii = borderRadii | |
| 768 | 887 | *out = append(*out, cmd) | |
| 769 | 888 | } | |
| 770 | 889 | cmd := base | |
| 771 | 890 | cmd.Op = "border" | |
| 772 | 891 | cmd.Widths = r.BorderWidths | |
| 773 | 892 | cmd.Colors = s.BorderColor | |
| 774 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 893 | + | cmd.Radii = borderRadii | |
| 775 | 894 | if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) { | |
| 776 | 895 | cmd.Color = s.BorderColor.Top | |
| 777 | 896 | *out = append(*out, cmd) | |
| 778 | 897 | } | |
| 779 | 898 | for i := range s.BoxShadow { | |
| 780 | - | sh := s.BoxShadow[i] | |
| 781 | - | if !sh.Inset { | |
| 899 | + | if !s.BoxShadow[i].Inset { | |
| 782 | 900 | continue | |
| 783 | 901 | } | |
| 784 | 902 | cmd := base | |
| 785 | 903 | cmd.Op = "shadow" | |
| 786 | - | cmd.Shadow = &sh | |
| 787 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 904 | + | cmd.Shadow = c.shadows.one(s.BoxShadow[i]) | |
| 905 | + | cmd.Radii = borderRadii | |
| 788 | 906 | *out = append(*out, cmd) | |
| 789 | 907 | } | |
| 790 | - | if s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 { | |
| 908 | + | if hasOutline(&s, r) { | |
| 791 | 909 | cmd = base | |
| 792 | 910 | cmd.Op = "outline" | |
| 793 | 911 | cmd.Color = s.OutlineColor | |
| 794 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | |
| 795 | - | cmd.Widths = Edges{Top: positive(s.OutlineWidth, r.Border.W), Right: positive(s.OutlineWidth, r.Border.W), Bottom: positive(s.OutlineWidth, r.Border.W), Left: positive(s.OutlineWidth, r.Border.W)} | |
| 912 | + | cmd.Radii = borderRadii | |
| 913 | + | width := positive(s.OutlineWidth, r.Border.W) | |
| 914 | + | cmd.Widths = Edges{Top: width, Right: width, Bottom: width, Left: width} | |
| 796 | 915 | offset := coordValue(s.OutlineOffset, r.Border.W) | |
| 797 | 916 | cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)} | |
| 798 | 917 | *out = append(*out, cmd) | |
| @@ -808,7 +927,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||
| 808 | 927 | cmd.Op = "image" | |
| 809 | 928 | cmd.Rect = r.Content | |
| 810 | 929 | cmd.Image = n.Image | |
| 811 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Content.W), positive(s.BorderRadius.TopRight, r.Content.W), positive(s.BorderRadius.BottomRight, r.Content.W), positive(s.BorderRadius.BottomLeft, r.Content.W)} | |
| 930 | + | cmd.Radii = radiiOf(&s, r.Content.W) | |
| 812 | 931 | *out = append(*out, cmd) | |
| 813 | 932 | } | |
| 814 | 933 | if n.Kind == Text { | |
| @@ -817,6 +936,15 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||
| 817 | 936 | cmd.Rect = r.Content | |
| 818 | 937 | cmd.Text = n.Content | |
| 819 | 938 | cmd.Color = s.Color | |
| 939 | + | runs, glyphs := 0, 0 | |
| 940 | + | for i := range r.Lines { | |
| 941 | + | runs += len(r.Lines[i].Runs) | |
| 942 | + | for j := range r.Lines[i].Runs { | |
| 943 | + | glyphs += len(r.Lines[i].Runs[j].Glyphs) | |
| 944 | + | } | |
| 945 | + | } | |
| 946 | + | cmd.Runs = c.runs.take(runs) | |
| 947 | + | backing := c.glyphs.take(glyphs) | |
| 820 | 948 | for _, line := range r.Lines { | |
| 821 | 949 | offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width) | |
| 822 | 950 | for _, run := range line.Runs { | |
| @@ -827,8 +955,12 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||
| 827 | 955 | if run.Face != nil { | |
| 828 | 956 | rr.FaceID = run.Face.ID | |
| 829 | 957 | } | |
| 830 | - | for _, g := range run.Glyphs { | |
| 831 | - | rr.Glyphs = append(rr.Glyphs, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster}) | |
| 958 | + | if len(run.Glyphs) > 0 { // keep nil for glyph-less runs | |
| 959 | + | first := len(backing) | |
| 960 | + | for _, g := range run.Glyphs { | |
| 961 | + | backing = append(backing, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster}) | |
| 962 | + | } | |
| 963 | + | rr.Glyphs = backing[first:len(backing):len(backing)] | |
| 832 | 964 | } | |
| 833 | 965 | cmd.Runs = append(cmd.Runs, rr) | |
| 834 | 966 | } | |
| @@ -178,11 +178,29 @@ func Layout(root *Node, options Options) (Output, error) { | |||||
| 178 | return Output{}, err | 178 | return Output{}, err | ||
| 179 | } | 179 | } | ||
| 180 | out := Output{Tree: tree} | 180 | out := Output{Tree: tree} | ||
| 181 | - | ctx.paint(root, tree, &out.Display) | 181 | + | out.Display = ctx.display(root, tree) |
| 182 | return out, nil | 182 | return out, nil | ||
| 183 | } | 183 | } | ||
| 184 | 184 | ||||
| 185 | - | type context struct{ options Options } | 185 | + | // display paints the placed tree into a list sized once from the tree. |
| 186 | + | func (c context) display(root *Node, tree *Result) []Command { | |||
| 187 | + | n := countCommands(root, tree) | |||
| 188 | + | if n.commands == 0 { | |||
| 189 | + | return nil | |||
| 190 | + | } | |||
| 191 | + | out := make([]Command, 0, n.commands) | |||
| 192 | + | c.shadows, c.runs, c.glyphs = newSlab[css.Shadow](n.shadows), newSlab[Run](n.runs), newSlab[Glyph](n.glyphs) | |||
| 193 | + | c.paint(root, tree, &out) | |||
| 194 | + | return out | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | type context struct { | |||
| 198 | + | options Options | |||
| 199 | + | // Painter-owned display storage, sized by countCommands; nil is valid. | |||
| 200 | + | shadows *slab[css.Shadow] | |||
| 201 | + | runs *slab[Run] | |||
| 202 | + | glyphs *slab[Glyph] | |||
| 203 | + | } | |||
| 186 | 204 | ||||
| 187 | func style(n *Node) css.Style { | 205 | func style(n *Node) css.Style { | ||
| 188 | if n.Style != nil { | 206 | if n.Style != nil { | ||
| @@ -743,56 +761,157 @@ func (c context) flex(n *Node, r *Result, s css.Style, depth int) error { | |||||
| 743 | return nil | 761 | return nil | ||
| 744 | } | 762 | } | ||
| 745 | 763 | ||||
| 764 | + | // slab hands out detached values or sub-slices from one exactly sized block, | |||
| 765 | + | // so a display list costs a few allocations rather than one per shadow, run or | |||
| 766 | + | // text command. Blocks are never resized or reused, so pointers and slices | |||
| 767 | + | // stay valid for the life of the returned Output. Sub-slices have their | |||
| 768 | + | // capacity clipped, so a caller's append cannot overwrite a neighbour. A | |||
| 769 | + | // miscount, or a nil slab, only falls back to individual allocations. | |||
| 770 | + | type slab[T any] struct{ block []T } | |||
| 771 | + | ||||
| 772 | + | func newSlab[T any](n int) *slab[T] { | |||
| 773 | + | if n <= 0 { | |||
| 774 | + | return nil | |||
| 775 | + | } | |||
| 776 | + | return &slab[T]{block: make([]T, 0, n)} | |||
| 777 | + | } | |||
| 778 | + | ||||
| 779 | + | // one stores v in the slab and returns its address. | |||
| 780 | + | func (a *slab[T]) one(v T) *T { | |||
| 781 | + | if a == nil || len(a.block) == cap(a.block) { | |||
| 782 | + | p := new(T) // copy, so v itself never escapes | |||
| 783 | + | *p = v | |||
| 784 | + | return p | |||
| 785 | + | } | |||
| 786 | + | a.block = append(a.block, v) | |||
| 787 | + | return &a.block[len(a.block)-1] | |||
| 788 | + | } | |||
| 789 | + | ||||
| 790 | + | // take returns an empty slice with capacity exactly n (nil for n == 0). | |||
| 791 | + | func (a *slab[T]) take(n int) []T { | |||
| 792 | + | if n <= 0 { | |||
| 793 | + | return nil | |||
| 794 | + | } | |||
| 795 | + | if a == nil || cap(a.block)-len(a.block) < n { | |||
| 796 | + | return make([]T, 0, n) | |||
| 797 | + | } | |||
| 798 | + | i := len(a.block) | |||
| 799 | + | a.block = a.block[:i+n] | |||
| 800 | + | return a.block[i : i : i+n] | |||
| 801 | + | } | |||
| 802 | + | ||||
| 803 | + | // radiiOf resolves border-radius percentages against basis. | |||
| 804 | + | func radiiOf(s *css.Style, basis float64) [4]float64 { | |||
| 805 | + | return [4]float64{positive(s.BorderRadius.TopLeft, basis), positive(s.BorderRadius.TopRight, basis), positive(s.BorderRadius.BottomRight, basis), positive(s.BorderRadius.BottomLeft, basis)} | |||
| 806 | + | } | |||
| 807 | + | ||||
| 808 | + | func hasOutline(s *css.Style, r *Result) bool { | |||
| 809 | + | return s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 | |||
| 810 | + | } | |||
| 811 | + | ||||
| 812 | + | // countCommands returns how many commands and shadow copies paint emits for | |||
| 813 | + | // the subtree, so Layout can size the display list once. It mirrors paint's | |||
| 814 | + | // conditions; if it ever drifts, paint still appends correctly and only regrows. | |||
| 815 | + | func countCommands(n *Node, r *Result) (c counts) { | |||
| 816 | + | if r == nil { | |||
| 817 | + | return c | |||
| 818 | + | } | |||
| 819 | + | s := style(n) | |||
| 820 | + | total := len(s.BoxShadow) | |||
| 821 | + | c.shadows = len(s.BoxShadow) | |||
| 822 | + | if s.BackgroundColor.A > 0 { | |||
| 823 | + | total++ | |||
| 824 | + | } | |||
| 825 | + | if r.BorderWidths != (Edges{}) || radiiOf(&s, r.Border.W) != ([4]float64{}) { | |||
| 826 | + | total++ | |||
| 827 | + | } | |||
| 828 | + | if hasOutline(&s, r) { | |||
| 829 | + | total++ | |||
| 830 | + | } | |||
| 831 | + | if r.Clipped { | |||
| 832 | + | total += 2 | |||
| 833 | + | } | |||
| 834 | + | if n.Kind == Image || n.Kind == Text { | |||
| 835 | + | total++ | |||
| 836 | + | } | |||
| 837 | + | if n.Kind == Text { | |||
| 838 | + | for i := range r.Lines { | |||
| 839 | + | c.runs += len(r.Lines[i].Runs) | |||
| 840 | + | for j := range r.Lines[i].Runs { | |||
| 841 | + | c.glyphs += len(r.Lines[i].Runs[j].Glyphs) | |||
| 842 | + | } | |||
| 843 | + | } | |||
| 844 | + | } | |||
| 845 | + | j := 0 | |||
| 846 | + | for _, child := range n.Children { | |||
| 847 | + | if child == nil || style(child).Display == css.KeywordNone { | |||
| 848 | + | continue | |||
| 849 | + | } | |||
| 850 | + | if j < len(r.Children) { | |||
| 851 | + | cc := countCommands(child, r.Children[j]) | |||
| 852 | + | total += cc.commands | |||
| 853 | + | c.shadows += cc.shadows | |||
| 854 | + | c.runs += cc.runs | |||
| 855 | + | c.glyphs += cc.glyphs | |||
| 856 | + | j++ | |||
| 857 | + | } | |||
| 858 | + | } | |||
| 859 | + | c.commands += total | |||
| 860 | + | return c | |||
| 861 | + | } | |||
| 862 | + | ||||
| 863 | + | type counts struct{ commands, shadows, runs, glyphs int } | |||
| 864 | + | ||||
| 746 | func (c context) paint(n *Node, r *Result, out *[]Command) { | 865 | func (c context) paint(n *Node, r *Result, out *[]Command) { | ||
| 747 | if r == nil { | 866 | if r == nil { | ||
| 748 | return | 867 | return | ||
| 749 | } | 868 | } | ||
| 750 | s := style(n) | 869 | s := style(n) | ||
| 751 | base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))} | 870 | base := Command{ID: r.ID, Rect: r.Border, Opacity: math.Min(1, safe(s.Opacity))} | ||
| 871 | + | borderRadii := radiiOf(&s, r.Border.W) | |||
| 752 | for i := range s.BoxShadow { | 872 | for i := range s.BoxShadow { | ||
| 753 | - | sh := s.BoxShadow[i] | 873 | + | if s.BoxShadow[i].Inset { |
| 754 | - | if sh.Inset { | |||
| 755 | continue | 874 | continue | ||
| 756 | } | 875 | } | ||
| 757 | cmd := base | 876 | cmd := base | ||
| 758 | cmd.Op = "shadow" | 877 | cmd.Op = "shadow" | ||
| 759 | - | cmd.Shadow = &sh | 878 | + | cmd.Shadow = c.shadows.one(s.BoxShadow[i]) |
| 760 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | 879 | + | cmd.Radii = borderRadii |
| 761 | *out = append(*out, cmd) | 880 | *out = append(*out, cmd) | ||
| 762 | } | 881 | } | ||
| 763 | if s.BackgroundColor.A > 0 { | 882 | if s.BackgroundColor.A > 0 { | ||
| 764 | cmd := base | 883 | cmd := base | ||
| 765 | cmd.Op = "rect" | 884 | cmd.Op = "rect" | ||
| 766 | cmd.Color = s.BackgroundColor | 885 | cmd.Color = s.BackgroundColor | ||
| 767 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | 886 | + | cmd.Radii = borderRadii |
| 768 | *out = append(*out, cmd) | 887 | *out = append(*out, cmd) | ||
| 769 | } | 888 | } | ||
| 770 | cmd := base | 889 | cmd := base | ||
| 771 | cmd.Op = "border" | 890 | cmd.Op = "border" | ||
| 772 | cmd.Widths = r.BorderWidths | 891 | cmd.Widths = r.BorderWidths | ||
| 773 | cmd.Colors = s.BorderColor | 892 | cmd.Colors = s.BorderColor | ||
| 774 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | 893 | + | cmd.Radii = borderRadii |
| 775 | if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) { | 894 | if cmd.Widths != (Edges{}) || cmd.Radii != ([4]float64{}) { | ||
| 776 | cmd.Color = s.BorderColor.Top | 895 | cmd.Color = s.BorderColor.Top | ||
| 777 | *out = append(*out, cmd) | 896 | *out = append(*out, cmd) | ||
| 778 | } | 897 | } | ||
| 779 | for i := range s.BoxShadow { | 898 | for i := range s.BoxShadow { | ||
| 780 | - | sh := s.BoxShadow[i] | 899 | + | if !s.BoxShadow[i].Inset { |
| 781 | - | if !sh.Inset { | |||
| 782 | continue | 900 | continue | ||
| 783 | } | 901 | } | ||
| 784 | cmd := base | 902 | cmd := base | ||
| 785 | cmd.Op = "shadow" | 903 | cmd.Op = "shadow" | ||
| 786 | - | cmd.Shadow = &sh | 904 | + | cmd.Shadow = c.shadows.one(s.BoxShadow[i]) |
| 787 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | 905 | + | cmd.Radii = borderRadii |
| 788 | *out = append(*out, cmd) | 906 | *out = append(*out, cmd) | ||
| 789 | } | 907 | } | ||
| 790 | - | if s.OutlineStyle == css.KeywordSolid && positive(s.OutlineWidth, r.Border.W) > 0 { | 908 | + | if hasOutline(&s, r) { |
| 791 | cmd = base | 909 | cmd = base | ||
| 792 | cmd.Op = "outline" | 910 | cmd.Op = "outline" | ||
| 793 | cmd.Color = s.OutlineColor | 911 | cmd.Color = s.OutlineColor | ||
| 794 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Border.W), positive(s.BorderRadius.TopRight, r.Border.W), positive(s.BorderRadius.BottomRight, r.Border.W), positive(s.BorderRadius.BottomLeft, r.Border.W)} | 912 | + | cmd.Radii = borderRadii |
| 795 | - | cmd.Widths = Edges{Top: positive(s.OutlineWidth, r.Border.W), Right: positive(s.OutlineWidth, r.Border.W), Bottom: positive(s.OutlineWidth, r.Border.W), Left: positive(s.OutlineWidth, r.Border.W)} | 913 | + | width := positive(s.OutlineWidth, r.Border.W) |
| 914 | + | cmd.Widths = Edges{Top: width, Right: width, Bottom: width, Left: width} | |||
| 796 | offset := coordValue(s.OutlineOffset, r.Border.W) | 915 | offset := coordValue(s.OutlineOffset, r.Border.W) | ||
| 797 | cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)} | 916 | cmd.Rect = Rect{X: coord(r.Border.X - offset), Y: coord(r.Border.Y - offset), W: safe(r.Border.W + 2*offset), H: safe(r.Border.H + 2*offset)} | ||
| 798 | *out = append(*out, cmd) | 917 | *out = append(*out, cmd) | ||
| @@ -808,7 +927,7 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||||
| 808 | cmd.Op = "image" | 927 | cmd.Op = "image" | ||
| 809 | cmd.Rect = r.Content | 928 | cmd.Rect = r.Content | ||
| 810 | cmd.Image = n.Image | 929 | cmd.Image = n.Image | ||
| 811 | - | cmd.Radii = [4]float64{positive(s.BorderRadius.TopLeft, r.Content.W), positive(s.BorderRadius.TopRight, r.Content.W), positive(s.BorderRadius.BottomRight, r.Content.W), positive(s.BorderRadius.BottomLeft, r.Content.W)} | 930 | + | cmd.Radii = radiiOf(&s, r.Content.W) |
| 812 | *out = append(*out, cmd) | 931 | *out = append(*out, cmd) | ||
| 813 | } | 932 | } | ||
| 814 | if n.Kind == Text { | 933 | if n.Kind == Text { | ||
| @@ -817,6 +936,15 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||||
| 817 | cmd.Rect = r.Content | 936 | cmd.Rect = r.Content | ||
| 818 | cmd.Text = n.Content | 937 | cmd.Text = n.Content | ||
| 819 | cmd.Color = s.Color | 938 | cmd.Color = s.Color | ||
| 939 | + | runs, glyphs := 0, 0 | |||
| 940 | + | for i := range r.Lines { | |||
| 941 | + | runs += len(r.Lines[i].Runs) | |||
| 942 | + | for j := range r.Lines[i].Runs { | |||
| 943 | + | glyphs += len(r.Lines[i].Runs[j].Glyphs) | |||
| 944 | + | } | |||
| 945 | + | } | |||
| 946 | + | cmd.Runs = c.runs.take(runs) | |||
| 947 | + | backing := c.glyphs.take(glyphs) | |||
| 820 | for _, line := range r.Lines { | 948 | for _, line := range r.Lines { | ||
| 821 | offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width) | 949 | offset := AlignOffset(s.TextAlign, line.Direction, r.Content.W, line.Width) | ||
| 822 | for _, run := range line.Runs { | 950 | for _, run := range line.Runs { | ||
| @@ -827,8 +955,12 @@ func (c context) paint(n *Node, r *Result, out *[]Command) { | |||||
| 827 | if run.Face != nil { | 955 | if run.Face != nil { | ||
| 828 | rr.FaceID = run.Face.ID | 956 | rr.FaceID = run.Face.ID | ||
| 829 | } | 957 | } | ||
| 830 | - | for _, g := range run.Glyphs { | 958 | + | if len(run.Glyphs) > 0 { // keep nil for glyph-less runs |
| 831 | - | rr.Glyphs = append(rr.Glyphs, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster}) | 959 | + | first := len(backing) |
| 960 | + | for _, g := range run.Glyphs { | |||
| 961 | + | backing = append(backing, Glyph{uint32(g.ID), g.X + r.Content.X + offset - r.ScrollX, g.Y + r.Content.Y - r.ScrollY, g.Advance, g.Cluster}) | |||
| 962 | + | } | |||
| 963 | + | rr.Glyphs = backing[first:len(backing):len(backing)] | |||
| 832 | } | 964 | } | ||
| 833 | cmd.Runs = append(cmd.Runs, rr) | 965 | cmd.Runs = append(cmd.Runs, rr) | ||
| 834 | } | 966 | } | ||
A internal/layout/paint_bench_test.go +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | "github.com/bnema/nefergui/internal/text" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // paintFixture is a product-like tree: cards with background, border, shadows, | |
| 12 | + | // a clip and shaped text children. | |
| 13 | + | func paintFixture(tb testing.TB, cards int) (*Node, Options) { | |
| 14 | + | tb.Helper() | |
| 15 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |
| 16 | + | if err != nil { | |
| 17 | + | tb.Fatal(err) | |
| 18 | + | } | |
| 19 | + | engine := text.NewEngine(catalog) | |
| 20 | + | card := css.Style{ | |
| 21 | + | Display: css.KeywordBlock, Height: px(60), Padding: css.LengthSides{Top: px(4), Left: px(4)}, | |
| 22 | + | BackgroundColor: css.Color{B: 1, A: 1}, BorderWidth: css.LengthSides{Top: px(1), Right: px(1), Bottom: px(1), Left: px(1)}, | |
| 23 | + | BorderStyle: css.KeywordSides{Top: css.KeywordSolid, Right: css.KeywordSolid, Bottom: css.KeywordSolid, Left: css.KeywordSolid}, | |
| 24 | + | OverflowX: css.KeywordHidden, OverflowY: css.KeywordHidden, | |
| 25 | + | BoxShadow: []css.Shadow{{Y: px(2), Blur: px(4), Color: css.Color{A: .3}}, {Inset: true, Blur: px(2), Color: css.Color{A: .2}}}, | |
| 26 | + | } | |
| 27 | + | label := css.Style{Display: css.KeywordBlock, FontSize: px(14), Color: css.Color{A: 1}} | |
| 28 | + | children := make([]*Node, cards) | |
| 29 | + | for i := range children { | |
| 30 | + | t1 := node("t", Text, label) | |
| 31 | + | t1.Content = fmt.Sprintf("Élément %d — une interface native écrite en Go", i) | |
| 32 | + | children[i] = node("card", Box, card, t1) | |
| 33 | + | } | |
| 34 | + | return node("root", Column, css.Style{Display: css.KeywordBlock}, children...), Options{Width: 400, Height: 3000, TextEngine: engine} | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // BenchmarkPaint measures only context.paint on an already placed tree. | |
| 38 | + | func BenchmarkPaint(b *testing.B) { | |
| 39 | + | root, opts := paintFixture(b, 100) | |
| 40 | + | ctx := context{options: opts} | |
| 41 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |
| 42 | + | if err != nil { | |
| 43 | + | b.Fatal(err) | |
| 44 | + | } | |
| 45 | + | b.ReportAllocs() | |
| 46 | + | b.ResetTimer() | |
| 47 | + | for i := 0; i < b.N; i++ { | |
| 48 | + | if display := ctx.display(root, tree); len(display) == 0 { | |
| 49 | + | b.Fatal("empty") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func BenchmarkLayoutPaintTree(b *testing.B) { | |
| 55 | + | root, opts := paintFixture(b, 100) | |
| 56 | + | b.ReportAllocs() | |
| 57 | + | for i := 0; i < b.N; i++ { | |
| 58 | + | if _, err := Layout(root, opts); err != nil { | |
| 59 | + | b.Fatal(err) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | } | |
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |||
| 8 | + | "github.com/bnema/nefergui/internal/text" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // paintFixture is a product-like tree: cards with background, border, shadows, | |||
| 12 | + | // a clip and shaped text children. | |||
| 13 | + | func paintFixture(tb testing.TB, cards int) (*Node, Options) { | |||
| 14 | + | tb.Helper() | |||
| 15 | + | catalog, err := text.Load(text.DirectorySource("../../testdata/fonts")) | |||
| 16 | + | if err != nil { | |||
| 17 | + | tb.Fatal(err) | |||
| 18 | + | } | |||
| 19 | + | engine := text.NewEngine(catalog) | |||
| 20 | + | card := css.Style{ | |||
| 21 | + | Display: css.KeywordBlock, Height: px(60), Padding: css.LengthSides{Top: px(4), Left: px(4)}, | |||
| 22 | + | BackgroundColor: css.Color{B: 1, A: 1}, BorderWidth: css.LengthSides{Top: px(1), Right: px(1), Bottom: px(1), Left: px(1)}, | |||
| 23 | + | BorderStyle: css.KeywordSides{Top: css.KeywordSolid, Right: css.KeywordSolid, Bottom: css.KeywordSolid, Left: css.KeywordSolid}, | |||
| 24 | + | OverflowX: css.KeywordHidden, OverflowY: css.KeywordHidden, | |||
| 25 | + | BoxShadow: []css.Shadow{{Y: px(2), Blur: px(4), Color: css.Color{A: .3}}, {Inset: true, Blur: px(2), Color: css.Color{A: .2}}}, | |||
| 26 | + | } | |||
| 27 | + | label := css.Style{Display: css.KeywordBlock, FontSize: px(14), Color: css.Color{A: 1}} | |||
| 28 | + | children := make([]*Node, cards) | |||
| 29 | + | for i := range children { | |||
| 30 | + | t1 := node("t", Text, label) | |||
| 31 | + | t1.Content = fmt.Sprintf("Élément %d — une interface native écrite en Go", i) | |||
| 32 | + | children[i] = node("card", Box, card, t1) | |||
| 33 | + | } | |||
| 34 | + | return node("root", Column, css.Style{Display: css.KeywordBlock}, children...), Options{Width: 400, Height: 3000, TextEngine: engine} | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // BenchmarkPaint measures only context.paint on an already placed tree. | |||
| 38 | + | func BenchmarkPaint(b *testing.B) { | |||
| 39 | + | root, opts := paintFixture(b, 100) | |||
| 40 | + | ctx := context{options: opts} | |||
| 41 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |||
| 42 | + | if err != nil { | |||
| 43 | + | b.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | b.ReportAllocs() | |||
| 46 | + | b.ResetTimer() | |||
| 47 | + | for i := 0; i < b.N; i++ { | |||
| 48 | + | if display := ctx.display(root, tree); len(display) == 0 { | |||
| 49 | + | b.Fatal("empty") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func BenchmarkLayoutPaintTree(b *testing.B) { | |||
| 55 | + | root, opts := paintFixture(b, 100) | |||
| 56 | + | b.ReportAllocs() | |||
| 57 | + | for i := 0; i < b.N; i++ { | |||
| 58 | + | if _, err := Layout(root, opts); err != nil { | |||
| 59 | + | b.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | } | |||
A internal/layout/paint_test.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Layout output is detached: neither the shared paint storage nor a later | |
| 11 | + | // Layout call, nor a mutation of the input style, may change earlier results. | |
| 12 | + | func TestPaintOutputDetached(t *testing.T) { | |
| 13 | + | root, opts := paintFixture(t, 6) | |
| 14 | + | first, err := Layout(root, opts) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatal(err) | |
| 17 | + | } | |
| 18 | + | want := deepCopyDisplay(first.Display) | |
| 19 | + | shadows := 0 | |
| 20 | + | for _, cmd := range first.Display { | |
| 21 | + | if cmd.Shadow != nil { | |
| 22 | + | shadows++ | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | if shadows != 12 { | |
| 26 | + | t.Fatalf("fixture shadows=%d, want 12", shadows) | |
| 27 | + | } | |
| 28 | + | // Mutating the input styles must not reach painted shadows. | |
| 29 | + | for _, card := range root.Children { | |
| 30 | + | card.Style.BoxShadow[0].Blur = px(99) | |
| 31 | + | } | |
| 32 | + | second, err := Layout(root, opts) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if !reflect.DeepEqual(first.Display, want) { | |
| 37 | + | t.Fatal("first output changed after style mutation and second Layout") | |
| 38 | + | } | |
| 39 | + | if reflect.DeepEqual(second.Display, want) { | |
| 40 | + | t.Fatal("second output should reflect the mutated style") | |
| 41 | + | } | |
| 42 | + | // Writing through one command's storage must not change any other. | |
| 43 | + | for i := range second.Display { | |
| 44 | + | if second.Display[i].Shadow != nil { | |
| 45 | + | second.Display[i].Shadow.X = px(-7) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | if !reflect.DeepEqual(first.Display, want) { | |
| 49 | + | t.Fatal("outputs share shadow storage") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func deepCopyDisplay(in []Command) []Command { | |
| 54 | + | out := make([]Command, len(in)) | |
| 55 | + | for i, c := range in { | |
| 56 | + | if c.Shadow != nil { | |
| 57 | + | s := *c.Shadow | |
| 58 | + | c.Shadow = &s | |
| 59 | + | } | |
| 60 | + | c.Runs = append([]Run(nil), c.Runs...) | |
| 61 | + | for j := range c.Runs { | |
| 62 | + | c.Runs[j].Glyphs = append([]Glyph(nil), c.Runs[j].Glyphs...) | |
| 63 | + | } | |
| 64 | + | out[i] = c | |
| 65 | + | } | |
| 66 | + | return out | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Runs and glyphs come from shared blocks; a caller appending to one must | |
| 70 | + | // reallocate rather than overwrite the next run or command. | |
| 71 | + | func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) { | |
| 72 | + | root, opts := paintFixture(t, 3) | |
| 73 | + | out, err := Layout(root, opts) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | want := deepCopyDisplay(out.Display) | |
| 78 | + | texts := 0 | |
| 79 | + | for i := range out.Display { | |
| 80 | + | cmd := &out.Display[i] | |
| 81 | + | if cmd.Op != "text" { | |
| 82 | + | continue | |
| 83 | + | } | |
| 84 | + | texts++ | |
| 85 | + | if len(cmd.Runs) == 0 || cap(cmd.Runs) != len(cmd.Runs) { | |
| 86 | + | t.Fatalf("runs len=%d cap=%d", len(cmd.Runs), cap(cmd.Runs)) | |
| 87 | + | } | |
| 88 | + | for j := range cmd.Runs { | |
| 89 | + | if g := cmd.Runs[j].Glyphs; len(g) == 0 || cap(g) != len(g) { | |
| 90 | + | t.Fatalf("glyphs len=%d cap=%d", len(g), cap(g)) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | _ = append(cmd.Runs, Run{Start: -1}) | |
| 94 | + | for j := range cmd.Runs { | |
| 95 | + | _ = append(cmd.Runs[j].Glyphs, Glyph{ID: 0xdead}) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | if texts != 3 || !reflect.DeepEqual(out.Display, want) { | |
| 99 | + | t.Fatal("append through a command overwrote neighbouring data") | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // countCommands must match paint exactly, otherwise the single-allocation | |
| 104 | + | // guarantee silently degrades. | |
| 105 | + | func TestPaintCountsMatch(t *testing.T) { | |
| 106 | + | root, opts := paintFixture(t, 10) | |
| 107 | + | ctx := context{options: opts} | |
| 108 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |
| 109 | + | if err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | n := countCommands(root, tree) | |
| 113 | + | out := ctx.display(root, tree) | |
| 114 | + | runs, glyphs, shadows := 0, 0, 0 | |
| 115 | + | for _, cmd := range out { | |
| 116 | + | runs += len(cmd.Runs) | |
| 117 | + | for _, r := range cmd.Runs { | |
| 118 | + | glyphs += len(r.Glyphs) | |
| 119 | + | } | |
| 120 | + | if cmd.Shadow != nil { | |
| 121 | + | shadows++ | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | if n.commands != len(out) || cap(out) != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows { | |
| 125 | + | t.Fatalf("counts %+v vs commands=%d cap=%d runs=%d glyphs=%d shadows=%d", n, len(out), cap(out), runs, glyphs, shadows) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestPaintAllocations(t *testing.T) { | |
| 130 | + | root, opts := paintFixture(t, 50) | |
| 131 | + | ctx := context{options: opts} | |
| 132 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |
| 133 | + | if err != nil { | |
| 134 | + | t.Fatal(err) | |
| 135 | + | } | |
| 136 | + | // One block each for commands, shadows, runs and glyphs, whatever the tree size. | |
| 137 | + | if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n > 4 { | |
| 138 | + | t.Fatalf("paint allocs=%v, want <= 4", n) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestPaintEmptyAndShadowOrder(t *testing.T) { | |
| 143 | + | out, err := Layout(&Node{ID: "n", Kind: Box, Style: &css.Style{Display: css.KeywordBlock}}, Options{Width: 10, Height: 10}) | |
| 144 | + | if err != nil || out.Display != nil { | |
| 145 | + | t.Fatalf("empty paint: %+v %v", out.Display, err) | |
| 146 | + | } | |
| 147 | + | s := css.Style{Display: css.KeywordBlock, Width: px(10), Height: px(10), BackgroundColor: css.Color{A: 1}, BoxShadow: []css.Shadow{ | |
| 148 | + | {Inset: true, Blur: px(1)}, {Blur: px(2)}, {Blur: px(3)}, {Inset: true, Blur: px(4)}, | |
| 149 | + | }} | |
| 150 | + | out, err = Layout(node("n", Box, s), Options{Width: 10, Height: 10}) | |
| 151 | + | if err != nil { | |
| 152 | + | t.Fatal(err) | |
| 153 | + | } | |
| 154 | + | var got []float64 | |
| 155 | + | var ops []string | |
| 156 | + | for _, c := range out.Display { | |
| 157 | + | ops = append(ops, c.Op) | |
| 158 | + | if c.Shadow != nil { | |
| 159 | + | got = append(got, c.Shadow.Blur.Value) | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | if !reflect.DeepEqual(got, []float64{2, 3, 1, 4}) || !reflect.DeepEqual(ops, []string{"shadow", "shadow", "rect", "shadow", "shadow"}) { | |
| 163 | + | t.Fatalf("order ops=%v blur=%v", ops, got) | |
| 164 | + | } | |
| 165 | + | } | |
| @@ -0,0 +1,165 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "reflect" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Layout output is detached: neither the shared paint storage nor a later | |||
| 11 | + | // Layout call, nor a mutation of the input style, may change earlier results. | |||
| 12 | + | func TestPaintOutputDetached(t *testing.T) { | |||
| 13 | + | root, opts := paintFixture(t, 6) | |||
| 14 | + | first, err := Layout(root, opts) | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Fatal(err) | |||
| 17 | + | } | |||
| 18 | + | want := deepCopyDisplay(first.Display) | |||
| 19 | + | shadows := 0 | |||
| 20 | + | for _, cmd := range first.Display { | |||
| 21 | + | if cmd.Shadow != nil { | |||
| 22 | + | shadows++ | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | if shadows != 12 { | |||
| 26 | + | t.Fatalf("fixture shadows=%d, want 12", shadows) | |||
| 27 | + | } | |||
| 28 | + | // Mutating the input styles must not reach painted shadows. | |||
| 29 | + | for _, card := range root.Children { | |||
| 30 | + | card.Style.BoxShadow[0].Blur = px(99) | |||
| 31 | + | } | |||
| 32 | + | second, err := Layout(root, opts) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | if !reflect.DeepEqual(first.Display, want) { | |||
| 37 | + | t.Fatal("first output changed after style mutation and second Layout") | |||
| 38 | + | } | |||
| 39 | + | if reflect.DeepEqual(second.Display, want) { | |||
| 40 | + | t.Fatal("second output should reflect the mutated style") | |||
| 41 | + | } | |||
| 42 | + | // Writing through one command's storage must not change any other. | |||
| 43 | + | for i := range second.Display { | |||
| 44 | + | if second.Display[i].Shadow != nil { | |||
| 45 | + | second.Display[i].Shadow.X = px(-7) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | if !reflect.DeepEqual(first.Display, want) { | |||
| 49 | + | t.Fatal("outputs share shadow storage") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func deepCopyDisplay(in []Command) []Command { | |||
| 54 | + | out := make([]Command, len(in)) | |||
| 55 | + | for i, c := range in { | |||
| 56 | + | if c.Shadow != nil { | |||
| 57 | + | s := *c.Shadow | |||
| 58 | + | c.Shadow = &s | |||
| 59 | + | } | |||
| 60 | + | c.Runs = append([]Run(nil), c.Runs...) | |||
| 61 | + | for j := range c.Runs { | |||
| 62 | + | c.Runs[j].Glyphs = append([]Glyph(nil), c.Runs[j].Glyphs...) | |||
| 63 | + | } | |||
| 64 | + | out[i] = c | |||
| 65 | + | } | |||
| 66 | + | return out | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Runs and glyphs come from shared blocks; a caller appending to one must | |||
| 70 | + | // reallocate rather than overwrite the next run or command. | |||
| 71 | + | func TestPaintRunAndGlyphCapacityIsolated(t *testing.T) { | |||
| 72 | + | root, opts := paintFixture(t, 3) | |||
| 73 | + | out, err := Layout(root, opts) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | want := deepCopyDisplay(out.Display) | |||
| 78 | + | texts := 0 | |||
| 79 | + | for i := range out.Display { | |||
| 80 | + | cmd := &out.Display[i] | |||
| 81 | + | if cmd.Op != "text" { | |||
| 82 | + | continue | |||
| 83 | + | } | |||
| 84 | + | texts++ | |||
| 85 | + | if len(cmd.Runs) == 0 || cap(cmd.Runs) != len(cmd.Runs) { | |||
| 86 | + | t.Fatalf("runs len=%d cap=%d", len(cmd.Runs), cap(cmd.Runs)) | |||
| 87 | + | } | |||
| 88 | + | for j := range cmd.Runs { | |||
| 89 | + | if g := cmd.Runs[j].Glyphs; len(g) == 0 || cap(g) != len(g) { | |||
| 90 | + | t.Fatalf("glyphs len=%d cap=%d", len(g), cap(g)) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | _ = append(cmd.Runs, Run{Start: -1}) | |||
| 94 | + | for j := range cmd.Runs { | |||
| 95 | + | _ = append(cmd.Runs[j].Glyphs, Glyph{ID: 0xdead}) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | if texts != 3 || !reflect.DeepEqual(out.Display, want) { | |||
| 99 | + | t.Fatal("append through a command overwrote neighbouring data") | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // countCommands must match paint exactly, otherwise the single-allocation | |||
| 104 | + | // guarantee silently degrades. | |||
| 105 | + | func TestPaintCountsMatch(t *testing.T) { | |||
| 106 | + | root, opts := paintFixture(t, 10) | |||
| 107 | + | ctx := context{options: opts} | |||
| 108 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |||
| 109 | + | if err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | n := countCommands(root, tree) | |||
| 113 | + | out := ctx.display(root, tree) | |||
| 114 | + | runs, glyphs, shadows := 0, 0, 0 | |||
| 115 | + | for _, cmd := range out { | |||
| 116 | + | runs += len(cmd.Runs) | |||
| 117 | + | for _, r := range cmd.Runs { | |||
| 118 | + | glyphs += len(r.Glyphs) | |||
| 119 | + | } | |||
| 120 | + | if cmd.Shadow != nil { | |||
| 121 | + | shadows++ | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | if n.commands != len(out) || cap(out) != len(out) || n.runs != runs || n.glyphs != glyphs || n.shadows != shadows { | |||
| 125 | + | t.Fatalf("counts %+v vs commands=%d cap=%d runs=%d glyphs=%d shadows=%d", n, len(out), cap(out), runs, glyphs, shadows) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestPaintAllocations(t *testing.T) { | |||
| 130 | + | root, opts := paintFixture(t, 50) | |||
| 131 | + | ctx := context{options: opts} | |||
| 132 | + | tree, err := ctx.place(root, 0, 0, opts.Width, opts.Height, nil, nil, 0) | |||
| 133 | + | if err != nil { | |||
| 134 | + | t.Fatal(err) | |||
| 135 | + | } | |||
| 136 | + | // One block each for commands, shadows, runs and glyphs, whatever the tree size. | |||
| 137 | + | if n := testing.AllocsPerRun(20, func() { ctx.display(root, tree) }); n > 4 { | |||
| 138 | + | t.Fatalf("paint allocs=%v, want <= 4", n) | |||
| 139 | + | } | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestPaintEmptyAndShadowOrder(t *testing.T) { | |||
| 143 | + | out, err := Layout(&Node{ID: "n", Kind: Box, Style: &css.Style{Display: css.KeywordBlock}}, Options{Width: 10, Height: 10}) | |||
| 144 | + | if err != nil || out.Display != nil { | |||
| 145 | + | t.Fatalf("empty paint: %+v %v", out.Display, err) | |||
| 146 | + | } | |||
| 147 | + | s := css.Style{Display: css.KeywordBlock, Width: px(10), Height: px(10), BackgroundColor: css.Color{A: 1}, BoxShadow: []css.Shadow{ | |||
| 148 | + | {Inset: true, Blur: px(1)}, {Blur: px(2)}, {Blur: px(3)}, {Inset: true, Blur: px(4)}, | |||
| 149 | + | }} | |||
| 150 | + | out, err = Layout(node("n", Box, s), Options{Width: 10, Height: 10}) | |||
| 151 | + | if err != nil { | |||
| 152 | + | t.Fatal(err) | |||
| 153 | + | } | |||
| 154 | + | var got []float64 | |||
| 155 | + | var ops []string | |||
| 156 | + | for _, c := range out.Display { | |||
| 157 | + | ops = append(ops, c.Op) | |||
| 158 | + | if c.Shadow != nil { | |||
| 159 | + | got = append(got, c.Shadow.Blur.Value) | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | if !reflect.DeepEqual(got, []float64{2, 3, 1, 4}) || !reflect.DeepEqual(ops, []string{"shadow", "shadow", "rect", "shadow", "shadow"}) { | |||
| 163 | + | t.Fatalf("order ops=%v blur=%v", ops, got) | |||
| 164 | + | } | |||
| 165 | + | } | |||
M internal/presentation/session/app.go +1 -5
| @@ -63,11 +63,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||
| 63 | 63 | } | |
| 64 | 64 | } | |
| 65 | 65 | committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) { | |
| 66 | - | instances, batches, stats := render.ListBatches(prepared) | |
| 67 | - | if len(stats.Skipped) != 0 { | |
| 68 | - | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |
| 69 | - | } | |
| 70 | - | return s.tick(nil, instances, prepared.Uploads, batches) | |
| 66 | + | return s.tickFrame(prepared) | |
| 71 | 67 | }) | |
| 72 | 68 | if err != nil { | |
| 73 | 69 | return err | |
| @@ -63,11 +63,7 @@ func (s *Session) RunApp(ctx context.Context, app AppHooks) error { | |||||
| 63 | } | 63 | } | ||
| 64 | } | 64 | } | ||
| 65 | committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) { | 65 | committed, err := sched.Step(s.Window.FrameReady, app.Draw, func(prepared render.Frame) (bool, error) { | ||
| 66 | - | instances, batches, stats := render.ListBatches(prepared) | 66 | + | return s.tickFrame(prepared) |
| 67 | - | if len(stats.Skipped) != 0 { | |||
| 68 | - | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |||
| 69 | - | } | |||
| 70 | - | return s.tick(nil, instances, prepared.Uploads, batches) | |||
| 71 | }) | 67 | }) | ||
| 72 | if err != nil { | 68 | if err != nil { | ||
| 73 | return err | 69 | return err | ||
M internal/presentation/session/session.go +16 -5
| @@ -49,6 +49,7 @@ type Session struct { | |||
| 49 | 49 | Pipeline *vkdevice.Pipeline | |
| 50 | 50 | ListPipeline *vkdevice.Pipeline | |
| 51 | 51 | Preparer *render.Preparer | |
| 52 | + | list render.ListBuffer // owner-loop scratch; see tickFrame | |
| 52 | 53 | Slots map[uint64]*Slot | |
| 53 | 54 | retiredPipelines []retiredPipelines | |
| 54 | 55 | Acquire uint32 | |
| @@ -342,6 +343,20 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload) | |||
| 342 | 343 | return s.tick(nil, instances, uploads, batches) | |
| 343 | 344 | } | |
| 344 | 345 | ||
| 346 | + | // tickFrame lists and submits one prepared frame on the owner loop. The list | |
| 347 | + | // buffer's slices are borrowed only for this call: tick copies instances into | |
| 348 | + | // mapped GPU memory and resolve copies batches before returning, and no pending | |
| 349 | + | // GPU work or retained frame refers to them (a blocked frame is re-listed from | |
| 350 | + | // its render.Frame on retry). Release then drops borrowed image references. | |
| 351 | + | func (s *Session) tickFrame(frame render.Frame) (bool, error) { | |
| 352 | + | defer s.list.Release() | |
| 353 | + | instances, batches, stats := s.list.List(frame) | |
| 354 | + | if len(stats.Skipped) != 0 { | |
| 355 | + | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |
| 356 | + | } | |
| 357 | + | return s.tick(nil, instances, frame.Uploads, batches) | |
| 358 | + | } | |
| 359 | + | ||
| 345 | 360 | func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) { | |
| 346 | 361 | started := time.Now() | |
| 347 | 362 | if s.Window.Dirty { | |
| @@ -682,11 +697,7 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra | |||
| 682 | 697 | if err != nil { | |
| 683 | 698 | return false, err | |
| 684 | 699 | } | |
| 685 | - | instances, batches, stats := render.ListBatches(frame) | |
| 686 | - | if len(stats.Skipped) != 0 { | |
| 687 | - | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |
| 688 | - | } | |
| 689 | - | return s.tick(nil, instances, frame.Uploads, batches) | |
| 700 | + | return s.tickFrame(frame) | |
| 690 | 701 | }, committed) | |
| 691 | 702 | } | |
| 692 | 703 | ||
| @@ -49,6 +49,7 @@ type Session struct { | |||||
| 49 | Pipeline *vkdevice.Pipeline | 49 | Pipeline *vkdevice.Pipeline | ||
| 50 | ListPipeline *vkdevice.Pipeline | 50 | ListPipeline *vkdevice.Pipeline | ||
| 51 | Preparer *render.Preparer | 51 | Preparer *render.Preparer | ||
| 52 | + | list render.ListBuffer // owner-loop scratch; see tickFrame | |||
| 52 | Slots map[uint64]*Slot | 53 | Slots map[uint64]*Slot | ||
| 53 | retiredPipelines []retiredPipelines | 54 | retiredPipelines []retiredPipelines | ||
| 54 | Acquire uint32 | 55 | Acquire uint32 | ||
| @@ -342,6 +343,20 @@ func (s *Session) TickList(instances []vkdevice.Instance, uploads []text.Upload) | |||||
| 342 | return s.tick(nil, instances, uploads, batches) | 343 | return s.tick(nil, instances, uploads, batches) | ||
| 343 | } | 344 | } | ||
| 344 | 345 | ||||
| 346 | + | // tickFrame lists and submits one prepared frame on the owner loop. The list | |||
| 347 | + | // buffer's slices are borrowed only for this call: tick copies instances into | |||
| 348 | + | // mapped GPU memory and resolve copies batches before returning, and no pending | |||
| 349 | + | // GPU work or retained frame refers to them (a blocked frame is re-listed from | |||
| 350 | + | // its render.Frame on retry). Release then drops borrowed image references. | |||
| 351 | + | func (s *Session) tickFrame(frame render.Frame) (bool, error) { | |||
| 352 | + | defer s.list.Release() | |||
| 353 | + | instances, batches, stats := s.list.List(frame) | |||
| 354 | + | if len(stats.Skipped) != 0 { | |||
| 355 | + | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |||
| 356 | + | } | |||
| 357 | + | return s.tick(nil, instances, frame.Uploads, batches) | |||
| 358 | + | } | |||
| 359 | + | ||||
| 345 | func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) { | 360 | func (s *Session) tick(rect *vkdevice.Rect, instances []vkdevice.Instance, uploads []text.Upload, batches []vkdevice.Batch) (bool, error) { | ||
| 346 | started := time.Now() | 361 | started := time.Now() | ||
| 347 | if s.Window.Dirty { | 362 | if s.Window.Dirty { | ||
| @@ -682,11 +697,7 @@ func (s *Session) RunPreparedListWithCommit(ctx context.Context, frames int, dra | |||||
| 682 | if err != nil { | 697 | if err != nil { | ||
| 683 | return false, err | 698 | return false, err | ||
| 684 | } | 699 | } | ||
| 685 | - | instances, batches, stats := render.ListBatches(frame) | 700 | + | return s.tickFrame(frame) |
| 686 | - | if len(stats.Skipped) != 0 { | |||
| 687 | - | return false, fmt.Errorf("unsupported list operations: %v", stats.Skipped) | |||
| 688 | - | } | |||
| 689 | - | return s.tick(nil, instances, frame.Uploads, batches) | |||
| 690 | }, committed) | 701 | }, committed) | ||
| 691 | } | 702 | } | ||
| 692 | 703 | ||||
A internal/presentation/session/tickframe_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package session | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "image" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/nefergui/internal/render" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // An unsupported quad is rejected before any GPU work, and the borrowed list | |
| 14 | + | // buffer is released so it never pins the frame's image. | |
| 15 | + | func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) { | |
| 16 | + | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |
| 17 | + | s := &Session{} | |
| 18 | + | _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}}) | |
| 19 | + | if err == nil || !strings.Contains(err.Error(), "unsupported list operations") { | |
| 20 | + | t.Fatalf("err=%v", err) | |
| 21 | + | } | |
| 22 | + | instances, batches, _ := s.list.List(render.Frame{}) | |
| 23 | + | if len(instances) != 0 || len(batches) != 0 { | |
| 24 | + | t.Fatal("stale list state") | |
| 25 | + | } | |
| 26 | + | s.list.Release() | |
| 27 | + | } | |
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package session | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "image" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/nefergui/internal/render" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // An unsupported quad is rejected before any GPU work, and the borrowed list | |||
| 14 | + | // buffer is released so it never pins the frame's image. | |||
| 15 | + | func TestTickFrameRejectsUnsupportedAndReleasesImages(t *testing.T) { | |||
| 16 | + | img := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |||
| 17 | + | s := &Session{} | |||
| 18 | + | _, err := s.tickFrame(render.Frame{Quads: []render.Quad{{Op: "image", Image: img}, {Op: "bogus"}}}) | |||
| 19 | + | if err == nil || !strings.Contains(err.Error(), "unsupported list operations") { | |||
| 20 | + | t.Fatalf("err=%v", err) | |||
| 21 | + | } | |||
| 22 | + | instances, batches, _ := s.list.List(render.Frame{}) | |||
| 23 | + | if len(instances) != 0 || len(batches) != 0 { | |||
| 24 | + | t.Fatal("stale list state") | |||
| 25 | + | } | |||
| 26 | + | s.list.Release() | |||
| 27 | + | } | |||
M internal/presentation/syncobj/syncobj.go +30 -11
| @@ -6,7 +6,6 @@ | |||
| 6 | 6 | package syncobj | |
| 7 | 7 | ||
| 8 | 8 | import ( | |
| 9 | - | "errors" | |
| 10 | 9 | "fmt" | |
| 11 | 10 | "runtime" | |
| 12 | 11 | "time" | |
| @@ -74,10 +73,23 @@ func (n *Node) Close() error { | |||
| 74 | 73 | n.fd = -1 | |
| 75 | 74 | return err | |
| 76 | 75 | } | |
| 77 | - | func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error { | |
| 76 | + | ||
| 77 | + | // ioctlErrno performs the ioctl and returns the raw errno without allocating. | |
| 78 | + | // arg must stay reachable by the caller until this returns (callers use | |
| 79 | + | // runtime.KeepAlive when arg embeds uintptr-encoded pointers). | |
| 80 | + | func (n *Node) ioctlErrno(request uintptr, arg unsafe.Pointer) unix.Errno { | |
| 78 | 81 | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg)) | |
| 79 | - | if errno != 0 { | |
| 80 | - | return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno) | |
| 82 | + | return errno | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // ioctlError adds request context to a real errno. Only failure paths pay for it. | |
| 86 | + | func ioctlError(request uintptr, errno unix.Errno) error { | |
| 87 | + | return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error { | |
| 91 | + | if errno := n.ioctlErrno(request, arg); errno != 0 { | |
| 92 | + | return ioctlError(request, errno) | |
| 81 | 93 | } | |
| 82 | 94 | return nil | |
| 83 | 95 | } | |
| @@ -174,16 +186,23 @@ func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bo | |||
| 174 | 186 | return false, err | |
| 175 | 187 | } | |
| 176 | 188 | a := timelineWaitArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), TimeoutNS: now.Nano() + timeout.Nanoseconds(), Count: 1, Flags: 2} | |
| 177 | - | err := n.ioctl(ioctlTimelineWait, unsafe.Pointer(&a)) | |
| 189 | + | errno := n.ioctlErrno(ioctlTimelineWait, unsafe.Pointer(&a)) | |
| 178 | 190 | runtime.KeepAlive(h) | |
| 179 | 191 | runtime.KeepAlive(p) | |
| 180 | - | if err != nil { | |
| 181 | - | if errors.Is(err, unix.ETIME) || errors.Is(err, unix.EBUSY) { | |
| 182 | - | return false, nil | |
| 183 | - | } | |
| 184 | - | return false, err | |
| 192 | + | return waitResult(errno) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | // waitResult maps a TIMELINE_WAIT errno. ETIME and EBUSY are the expected idle | |
| 196 | + | // timeout outcomes and build no error value; anything else keeps its context. | |
| 197 | + | func waitResult(errno unix.Errno) (bool, error) { | |
| 198 | + | switch errno { | |
| 199 | + | case 0: | |
| 200 | + | return true, nil | |
| 201 | + | case unix.ETIME, unix.EBUSY: | |
| 202 | + | return false, nil | |
| 203 | + | default: | |
| 204 | + | return false, ioctlError(ioctlTimelineWait, errno) | |
| 185 | 205 | } | |
| 186 | - | return true, nil | |
| 187 | 206 | } | |
| 188 | 207 | ||
| 189 | 208 | // Signaled performs a zero-time wait for tests and shutdown diagnostics. | |
| @@ -6,7 +6,6 @@ | |||||
| 6 | package syncobj | 6 | package syncobj | ||
| 7 | 7 | ||||
| 8 | import ( | 8 | import ( | ||
| 9 | - | "errors" | |||
| 10 | "fmt" | 9 | "fmt" | ||
| 11 | "runtime" | 10 | "runtime" | ||
| 12 | "time" | 11 | "time" | ||
| @@ -74,10 +73,23 @@ func (n *Node) Close() error { | |||||
| 74 | n.fd = -1 | 73 | n.fd = -1 | ||
| 75 | return err | 74 | return err | ||
| 76 | } | 75 | } | ||
| 77 | - | func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error { | 76 | + | |
| 77 | + | // ioctlErrno performs the ioctl and returns the raw errno without allocating. | |||
| 78 | + | // arg must stay reachable by the caller until this returns (callers use | |||
| 79 | + | // runtime.KeepAlive when arg embeds uintptr-encoded pointers). | |||
| 80 | + | func (n *Node) ioctlErrno(request uintptr, arg unsafe.Pointer) unix.Errno { | |||
| 78 | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg)) | 81 | _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(n.fd), request, uintptr(arg)) | ||
| 79 | - | if errno != 0 { | 82 | + | return errno |
| 80 | - | return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno) | 83 | + | } |
| 84 | + | ||||
| 85 | + | // ioctlError adds request context to a real errno. Only failure paths pay for it. | |||
| 86 | + | func ioctlError(request uintptr, errno unix.Errno) error { | |||
| 87 | + | return fmt.Errorf("DRM syncobj ioctl %#x: %w", request, errno) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (n *Node) ioctl(request uintptr, arg unsafe.Pointer) error { | |||
| 91 | + | if errno := n.ioctlErrno(request, arg); errno != 0 { | |||
| 92 | + | return ioctlError(request, errno) | |||
| 81 | } | 93 | } | ||
| 82 | return nil | 94 | return nil | ||
| 83 | } | 95 | } | ||
| @@ -174,16 +186,23 @@ func (n *Node) WaitPoint(handle uint32, point uint64, timeout time.Duration) (bo | |||||
| 174 | return false, err | 186 | return false, err | ||
| 175 | } | 187 | } | ||
| 176 | a := timelineWaitArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), TimeoutNS: now.Nano() + timeout.Nanoseconds(), Count: 1, Flags: 2} | 188 | a := timelineWaitArg{Handles: uint64(uintptr(unsafe.Pointer(&h))), Points: uint64(uintptr(unsafe.Pointer(&p))), TimeoutNS: now.Nano() + timeout.Nanoseconds(), Count: 1, Flags: 2} | ||
| 177 | - | err := n.ioctl(ioctlTimelineWait, unsafe.Pointer(&a)) | 189 | + | errno := n.ioctlErrno(ioctlTimelineWait, unsafe.Pointer(&a)) |
| 178 | runtime.KeepAlive(h) | 190 | runtime.KeepAlive(h) | ||
| 179 | runtime.KeepAlive(p) | 191 | runtime.KeepAlive(p) | ||
| 180 | - | if err != nil { | 192 | + | return waitResult(errno) |
| 181 | - | if errors.Is(err, unix.ETIME) || errors.Is(err, unix.EBUSY) { | 193 | + | } |
| 182 | - | return false, nil | 194 | + | |
| 183 | - | } | 195 | + | // waitResult maps a TIMELINE_WAIT errno. ETIME and EBUSY are the expected idle |
| 184 | - | return false, err | 196 | + | // timeout outcomes and build no error value; anything else keeps its context. |
| 197 | + | func waitResult(errno unix.Errno) (bool, error) { | |||
| 198 | + | switch errno { | |||
| 199 | + | case 0: | |||
| 200 | + | return true, nil | |||
| 201 | + | case unix.ETIME, unix.EBUSY: | |||
| 202 | + | return false, nil | |||
| 203 | + | default: | |||
| 204 | + | return false, ioctlError(ioctlTimelineWait, errno) | |||
| 185 | } | 205 | } | ||
| 186 | - | return true, nil | |||
| 187 | } | 206 | } | ||
| 188 | 207 | ||||
| 189 | // Signaled performs a zero-time wait for tests and shutdown diagnostics. | 208 | // Signaled performs a zero-time wait for tests and shutdown diagnostics. | ||
A internal/presentation/syncobj/wait_test.go +144 -0
| @@ -0,0 +1,144 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package syncobj | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestWaitResultMapping(t *testing.T) { | |
| 16 | + | if ok, err := waitResult(0); !ok || err != nil { | |
| 17 | + | t.Fatalf("success: got %v, %v", ok, err) | |
| 18 | + | } | |
| 19 | + | for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} { | |
| 20 | + | if ok, err := waitResult(e); ok || err != nil { | |
| 21 | + | t.Fatalf("%v: want (false,nil), got %v, %v", e, ok, err) | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | for _, e := range []unix.Errno{unix.EINTR, unix.EBADF, unix.EINVAL, unix.ENOENT, unix.ENOTTY, unix.EFAULT} { | |
| 25 | + | ok, err := waitResult(e) | |
| 26 | + | if ok || err == nil { | |
| 27 | + | t.Fatalf("%v: want error, got %v, %v", e, ok, err) | |
| 28 | + | } | |
| 29 | + | if !errors.Is(err, e) { | |
| 30 | + | t.Errorf("%v: error does not wrap errno: %v", e, err) | |
| 31 | + | } | |
| 32 | + | if !strings.Contains(err.Error(), "DRM syncobj ioctl 0xc03064ca") { | |
| 33 | + | t.Errorf("%v: lost request context: %v", e, err) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestWaitResultIdleDoesNotAllocate(t *testing.T) { | |
| 39 | + | for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} { | |
| 40 | + | if n := testing.AllocsPerRun(1000, func() { _, _ = waitResult(e) }); n != 0 { | |
| 41 | + | t.Errorf("%v: %v allocs per idle result, want 0", e, n) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestWaitPointBadFDKeepsRealError(t *testing.T) { | |
| 47 | + | for _, fd := range []int{-1, 1 << 30} { | |
| 48 | + | n := &Node{fd: fd} | |
| 49 | + | ok, err := n.WaitPoint(1, 1, 20*time.Millisecond) | |
| 50 | + | if ok || !errors.Is(err, unix.EBADF) { | |
| 51 | + | t.Fatalf("fd %d: want (false, EBADF), got %v, %v", fd, ok, err) | |
| 52 | + | } | |
| 53 | + | if !strings.Contains(err.Error(), "DRM syncobj ioctl") { | |
| 54 | + | t.Errorf("fd %d: lost context: %v", fd, err) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | // Closed Node (fd -1 after Close) must also surface EBADF. | |
| 58 | + | n := &Node{fd: -1} | |
| 59 | + | if _, err := n.Signaled(1, 1); !errors.Is(err, unix.EBADF) { | |
| 60 | + | t.Fatalf("Signaled on closed node: %v", err) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestWaitPointNonDRMFDIsNotSwallowed(t *testing.T) { | |
| 65 | + | fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC) | |
| 66 | + | if err != nil { | |
| 67 | + | t.Fatal(err) | |
| 68 | + | } | |
| 69 | + | defer unix.Close(fd) | |
| 70 | + | n := &Node{fd: fd} | |
| 71 | + | if ok, err := n.WaitPoint(1, 1, 0); ok || !errors.Is(err, unix.ENOTTY) { | |
| 72 | + | t.Fatalf("want (false, ENOTTY), got %v, %v", ok, err) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestWaitPointTimeoutBounds(t *testing.T) { | |
| 77 | + | n := &Node{fd: -1} | |
| 78 | + | for _, d := range []time.Duration{-1, time.Second + 1} { | |
| 79 | + | if _, err := n.WaitPoint(1, 1, d); err == nil || errors.Is(err, unix.EBADF) { | |
| 80 | + | t.Errorf("timeout %s: want bounds error before ioctl, got %v", d, err) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | // Boundaries stay accepted (fail later at the ioctl with EBADF). | |
| 84 | + | for _, d := range []time.Duration{0, time.Second} { | |
| 85 | + | if _, err := n.WaitPoint(1, 1, d); !errors.Is(err, unix.EBADF) { | |
| 86 | + | t.Errorf("timeout %s: want EBADF, got %v", d, err) | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // openBenchNode opts in to a real render node, like the other hardware tests. | |
| 92 | + | func openBenchNode(tb testing.TB) *Node { | |
| 93 | + | path := os.Getenv("NEFERGUI_RENDER_NODE") | |
| 94 | + | if path == "" { | |
| 95 | + | tb.Skip("set NEFERGUI_RENDER_NODE=/dev/dri/renderD... for real idle-wait measurement") | |
| 96 | + | } | |
| 97 | + | n, err := Open(path) | |
| 98 | + | if err != nil { | |
| 99 | + | tb.Fatal(err) | |
| 100 | + | } | |
| 101 | + | tb.Cleanup(func() { _ = n.Close() }) | |
| 102 | + | return n | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestWaitPointIdleTimeoutRealNodeNoAllocs(t *testing.T) { | |
| 106 | + | n := openBenchNode(t) | |
| 107 | + | h, err := n.Create() | |
| 108 | + | if err != nil { | |
| 109 | + | t.Fatal(err) | |
| 110 | + | } | |
| 111 | + | defer n.Destroy(h) | |
| 112 | + | ready, err := n.WaitPoint(h, 1, 0) | |
| 113 | + | if ready || err != nil { | |
| 114 | + | t.Fatalf("idle unsignaled point: got %v, %v", ready, err) | |
| 115 | + | } | |
| 116 | + | if a := testing.AllocsPerRun(200, func() { _, _ = n.WaitPoint(h, 1, 0) }); a != 0 { | |
| 117 | + | t.Errorf("idle WaitPoint allocs/op = %v, want 0", a) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func BenchmarkWaitResultIdle(b *testing.B) { | |
| 122 | + | b.ReportAllocs() | |
| 123 | + | for i := 0; i < b.N; i++ { | |
| 124 | + | _, _ = waitResult(unix.ETIME) | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // BenchmarkWaitPointIdle measures the presentation waiter's idle path on a | |
| 129 | + | // real render node: an unsignaled point that times out (ETIME/EBUSY). | |
| 130 | + | func BenchmarkWaitPointIdle(b *testing.B) { | |
| 131 | + | n := openBenchNode(b) | |
| 132 | + | h, err := n.Create() | |
| 133 | + | if err != nil { | |
| 134 | + | b.Fatal(err) | |
| 135 | + | } | |
| 136 | + | defer n.Destroy(h) | |
| 137 | + | b.ReportAllocs() | |
| 138 | + | b.ResetTimer() | |
| 139 | + | for i := 0; i < b.N; i++ { | |
| 140 | + | if ready, err := n.WaitPoint(h, 1, 20*time.Millisecond); ready || err != nil { | |
| 141 | + | b.Fatalf("got %v, %v", ready, err) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | } | |
| @@ -0,0 +1,144 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package syncobj | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "os" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "golang.org/x/sys/unix" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestWaitResultMapping(t *testing.T) { | |||
| 16 | + | if ok, err := waitResult(0); !ok || err != nil { | |||
| 17 | + | t.Fatalf("success: got %v, %v", ok, err) | |||
| 18 | + | } | |||
| 19 | + | for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} { | |||
| 20 | + | if ok, err := waitResult(e); ok || err != nil { | |||
| 21 | + | t.Fatalf("%v: want (false,nil), got %v, %v", e, ok, err) | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | for _, e := range []unix.Errno{unix.EINTR, unix.EBADF, unix.EINVAL, unix.ENOENT, unix.ENOTTY, unix.EFAULT} { | |||
| 25 | + | ok, err := waitResult(e) | |||
| 26 | + | if ok || err == nil { | |||
| 27 | + | t.Fatalf("%v: want error, got %v, %v", e, ok, err) | |||
| 28 | + | } | |||
| 29 | + | if !errors.Is(err, e) { | |||
| 30 | + | t.Errorf("%v: error does not wrap errno: %v", e, err) | |||
| 31 | + | } | |||
| 32 | + | if !strings.Contains(err.Error(), "DRM syncobj ioctl 0xc03064ca") { | |||
| 33 | + | t.Errorf("%v: lost request context: %v", e, err) | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestWaitResultIdleDoesNotAllocate(t *testing.T) { | |||
| 39 | + | for _, e := range []unix.Errno{unix.ETIME, unix.EBUSY} { | |||
| 40 | + | if n := testing.AllocsPerRun(1000, func() { _, _ = waitResult(e) }); n != 0 { | |||
| 41 | + | t.Errorf("%v: %v allocs per idle result, want 0", e, n) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestWaitPointBadFDKeepsRealError(t *testing.T) { | |||
| 47 | + | for _, fd := range []int{-1, 1 << 30} { | |||
| 48 | + | n := &Node{fd: fd} | |||
| 49 | + | ok, err := n.WaitPoint(1, 1, 20*time.Millisecond) | |||
| 50 | + | if ok || !errors.Is(err, unix.EBADF) { | |||
| 51 | + | t.Fatalf("fd %d: want (false, EBADF), got %v, %v", fd, ok, err) | |||
| 52 | + | } | |||
| 53 | + | if !strings.Contains(err.Error(), "DRM syncobj ioctl") { | |||
| 54 | + | t.Errorf("fd %d: lost context: %v", fd, err) | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | // Closed Node (fd -1 after Close) must also surface EBADF. | |||
| 58 | + | n := &Node{fd: -1} | |||
| 59 | + | if _, err := n.Signaled(1, 1); !errors.Is(err, unix.EBADF) { | |||
| 60 | + | t.Fatalf("Signaled on closed node: %v", err) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func TestWaitPointNonDRMFDIsNotSwallowed(t *testing.T) { | |||
| 65 | + | fd, err := unix.Eventfd(0, unix.EFD_CLOEXEC) | |||
| 66 | + | if err != nil { | |||
| 67 | + | t.Fatal(err) | |||
| 68 | + | } | |||
| 69 | + | defer unix.Close(fd) | |||
| 70 | + | n := &Node{fd: fd} | |||
| 71 | + | if ok, err := n.WaitPoint(1, 1, 0); ok || !errors.Is(err, unix.ENOTTY) { | |||
| 72 | + | t.Fatalf("want (false, ENOTTY), got %v, %v", ok, err) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestWaitPointTimeoutBounds(t *testing.T) { | |||
| 77 | + | n := &Node{fd: -1} | |||
| 78 | + | for _, d := range []time.Duration{-1, time.Second + 1} { | |||
| 79 | + | if _, err := n.WaitPoint(1, 1, d); err == nil || errors.Is(err, unix.EBADF) { | |||
| 80 | + | t.Errorf("timeout %s: want bounds error before ioctl, got %v", d, err) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | // Boundaries stay accepted (fail later at the ioctl with EBADF). | |||
| 84 | + | for _, d := range []time.Duration{0, time.Second} { | |||
| 85 | + | if _, err := n.WaitPoint(1, 1, d); !errors.Is(err, unix.EBADF) { | |||
| 86 | + | t.Errorf("timeout %s: want EBADF, got %v", d, err) | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // openBenchNode opts in to a real render node, like the other hardware tests. | |||
| 92 | + | func openBenchNode(tb testing.TB) *Node { | |||
| 93 | + | path := os.Getenv("NEFERGUI_RENDER_NODE") | |||
| 94 | + | if path == "" { | |||
| 95 | + | tb.Skip("set NEFERGUI_RENDER_NODE=/dev/dri/renderD... for real idle-wait measurement") | |||
| 96 | + | } | |||
| 97 | + | n, err := Open(path) | |||
| 98 | + | if err != nil { | |||
| 99 | + | tb.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | tb.Cleanup(func() { _ = n.Close() }) | |||
| 102 | + | return n | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestWaitPointIdleTimeoutRealNodeNoAllocs(t *testing.T) { | |||
| 106 | + | n := openBenchNode(t) | |||
| 107 | + | h, err := n.Create() | |||
| 108 | + | if err != nil { | |||
| 109 | + | t.Fatal(err) | |||
| 110 | + | } | |||
| 111 | + | defer n.Destroy(h) | |||
| 112 | + | ready, err := n.WaitPoint(h, 1, 0) | |||
| 113 | + | if ready || err != nil { | |||
| 114 | + | t.Fatalf("idle unsignaled point: got %v, %v", ready, err) | |||
| 115 | + | } | |||
| 116 | + | if a := testing.AllocsPerRun(200, func() { _, _ = n.WaitPoint(h, 1, 0) }); a != 0 { | |||
| 117 | + | t.Errorf("idle WaitPoint allocs/op = %v, want 0", a) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func BenchmarkWaitResultIdle(b *testing.B) { | |||
| 122 | + | b.ReportAllocs() | |||
| 123 | + | for i := 0; i < b.N; i++ { | |||
| 124 | + | _, _ = waitResult(unix.ETIME) | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // BenchmarkWaitPointIdle measures the presentation waiter's idle path on a | |||
| 129 | + | // real render node: an unsignaled point that times out (ETIME/EBUSY). | |||
| 130 | + | func BenchmarkWaitPointIdle(b *testing.B) { | |||
| 131 | + | n := openBenchNode(b) | |||
| 132 | + | h, err := n.Create() | |||
| 133 | + | if err != nil { | |||
| 134 | + | b.Fatal(err) | |||
| 135 | + | } | |||
| 136 | + | defer n.Destroy(h) | |||
| 137 | + | b.ReportAllocs() | |||
| 138 | + | b.ResetTimer() | |||
| 139 | + | for i := 0; i < b.N; i++ { | |||
| 140 | + | if ready, err := n.WaitPoint(h, 1, 20*time.Millisecond); ready || err != nil { | |||
| 141 | + | b.Fatalf("got %v, %v", ready, err) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | } | |||
M internal/render/instances.go +128 -47
| @@ -21,57 +21,138 @@ func ListInstances(frame Frame) ([]vkdevice.Instance, ListStats) { | |||
| 21 | 21 | ||
| 22 | 22 | // ListBatches groups consecutive atlas draws and isolates each image draw. | |
| 23 | 23 | // First indexes the returned instances, retaining exact painter's order. | |
| 24 | + | // The returned slices and map are freshly allocated and owned by the caller. | |
| 24 | 25 | func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | |
| 25 | - | out := make([]vkdevice.Instance, 0, len(frame.Quads)) | |
| 26 | - | stats := ListStats{Skipped: make(map[string]int)} | |
| 27 | - | var batches []vkdevice.Batch | |
| 28 | - | for _, q := range frame.Quads { | |
| 29 | - | switch q.Op { | |
| 30 | - | case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image": | |
| 31 | - | v := vkdevice.Instance{ | |
| 32 | - | Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)}, | |
| 33 | - | Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)}, | |
| 34 | - | Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)}, | |
| 35 | - | } | |
| 36 | - | v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)} | |
| 37 | - | v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)} | |
| 38 | - | v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])} | |
| 39 | - | for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} { | |
| 40 | - | v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))} | |
| 41 | - | } | |
| 42 | - | switch q.Op { | |
| 43 | - | case "image": | |
| 44 | - | v.KindLayer[0] = 6 | |
| 45 | - | v.UV = [4]float32{0, 0, 1, 1} | |
| 46 | - | case "border": | |
| 47 | - | v.KindLayer[0] = 2 | |
| 48 | - | case "outline": | |
| 49 | - | v.KindLayer[0] = 3 | |
| 50 | - | case "shadow": | |
| 51 | - | v.KindLayer[0] = 4 | |
| 52 | - | if q.Inset { | |
| 53 | - | v.KindLayer[0] = 5 | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | for i, r := range q.Radii { | |
| 57 | - | v.Radii[i] = float32(r) | |
| 58 | - | } | |
| 59 | - | if q.Op == "glyph" { | |
| 60 | - | v.KindLayer = [4]float32{1, float32(q.Glyph.Page)} | |
| 61 | - | v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize} | |
| 62 | - | } | |
| 63 | - | if q.Op == "image" { | |
| 64 | - | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image}) | |
| 65 | - | } else if len(batches) == 0 || batches[len(batches)-1].Source != nil { | |
| 66 | - | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1}) | |
| 67 | - | } else { | |
| 68 | - | batches[len(batches)-1].Count++ | |
| 69 | - | } | |
| 70 | - | out = append(out, v) | |
| 71 | - | default: | |
| 26 | + | return listBatches(frame, nil, nil, make(map[string]int)) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // ListBuffer is a single-owner scratch area for repeated ListBatches calls. | |
| 30 | + | // The slices it returns alias its storage: they are valid only until the next | |
| 31 | + | // List or Release call, and must not be retained, shared across goroutines, or | |
| 32 | + | // used while another List may run. This is safe for consumers that copy the | |
| 33 | + | // data synchronously (Session.tick uploads instances into GPU memory and | |
| 34 | + | // resolve copies the batches before recording returns); anything that keeps the | |
| 35 | + | // slices past that point must use ListBatches instead. Retained frames hold | |
| 36 | + | // only render.Frame, never these slices, so a blocked frame is re-listed on retry. | |
| 37 | + | // Stats.Skipped shares the same lifetime. The zero value is ready to use. | |
| 38 | + | type ListBuffer struct { | |
| 39 | + | instances []vkdevice.Instance | |
| 40 | + | batches []vkdevice.Batch | |
| 41 | + | skipped map[string]int | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // bufferSlack is how much larger than needed retained storage may be before | |
| 45 | + | // List drops it, so one huge frame does not pin memory forever. | |
| 46 | + | const bufferSlack = 4 | |
| 47 | + | ||
| 48 | + | // List is ListBatches reusing this buffer's storage where capacity allows. | |
| 49 | + | func (b *ListBuffer) List(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | |
| 50 | + | b.Release() | |
| 51 | + | if b.skipped == nil { | |
| 52 | + | b.skipped = make(map[string]int) | |
| 53 | + | } | |
| 54 | + | clear(b.skipped) | |
| 55 | + | instances, batches, stats := listBatches(frame, b.instances[:0], b.batches[:0], b.skipped) | |
| 56 | + | b.instances, b.batches = instances[:0], batches[:0] | |
| 57 | + | return instances, batches, stats | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Release drops borrowed image references from the previous result. Call it | |
| 61 | + | // once the result has been consumed so an idle buffer does not pin images. | |
| 62 | + | func (b *ListBuffer) Release() { | |
| 63 | + | clear(b.batches[:cap(b.batches)]) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | // drawn reports whether the operation draws an instance. | |
| 67 | + | func drawn(op string) bool { | |
| 68 | + | switch op { | |
| 69 | + | case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image": | |
| 70 | + | return true | |
| 71 | + | } | |
| 72 | + | return false | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // listBatches fills out and batches (both length zero), growing them only when | |
| 76 | + | // their capacity is short of the exact counts or absurdly larger than needed. | |
| 77 | + | func listBatches(frame Frame, out []vkdevice.Instance, batches []vkdevice.Batch, skipped map[string]int) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | |
| 78 | + | // Count first so both slices are sized exactly: no append growth, and no | |
| 79 | + | // over-allocation for frames that skip quads. | |
| 80 | + | drawCount, batchCount, open := 0, 0, false | |
| 81 | + | for i := range frame.Quads { | |
| 82 | + | op := frame.Quads[i].Op | |
| 83 | + | if !drawn(op) { | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | drawCount++ | |
| 87 | + | if op == "image" { | |
| 88 | + | batchCount++ | |
| 89 | + | open = false | |
| 90 | + | } else if !open { | |
| 91 | + | batchCount++ | |
| 92 | + | open = true | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | if out == nil || cap(out) < drawCount || cap(out) > bufferSlack*drawCount+64 { | |
| 96 | + | out = make([]vkdevice.Instance, 0, drawCount) | |
| 97 | + | } | |
| 98 | + | if batchCount == 0 { | |
| 99 | + | batches = nil // as before: no draws, no batches | |
| 100 | + | } else if cap(batches) < batchCount || cap(batches) > bufferSlack*batchCount+16 { | |
| 101 | + | batches = make([]vkdevice.Batch, 0, batchCount) | |
| 102 | + | } | |
| 103 | + | stats := ListStats{Skipped: skipped} | |
| 104 | + | for i := range frame.Quads { | |
| 105 | + | q := &frame.Quads[i] | |
| 106 | + | if !drawn(q.Op) { | |
| 72 | 107 | stats.Skipped[q.Op]++ | |
| 108 | + | continue | |
| 73 | 109 | } | |
| 110 | + | if q.Op == "image" { | |
| 111 | + | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image}) | |
| 112 | + | } else if len(batches) == 0 || batches[len(batches)-1].Source != nil { | |
| 113 | + | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1}) | |
| 114 | + | } else { | |
| 115 | + | batches[len(batches)-1].Count++ | |
| 116 | + | } | |
| 117 | + | out = append(out, instanceOf(q)) | |
| 74 | 118 | } | |
| 75 | 119 | stats.Drawn = len(out) | |
| 76 | 120 | return out, batches, stats | |
| 77 | 121 | } | |
| 122 | + | ||
| 123 | + | // instanceOf converts one drawable quad to its GPU instance. | |
| 124 | + | func instanceOf(q *Quad) vkdevice.Instance { | |
| 125 | + | v := vkdevice.Instance{ | |
| 126 | + | Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)}, | |
| 127 | + | Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)}, | |
| 128 | + | Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)}, | |
| 129 | + | } | |
| 130 | + | v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)} | |
| 131 | + | v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)} | |
| 132 | + | v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])} | |
| 133 | + | for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} { | |
| 134 | + | v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))} | |
| 135 | + | } | |
| 136 | + | switch q.Op { | |
| 137 | + | case "image": | |
| 138 | + | v.KindLayer[0] = 6 | |
| 139 | + | v.UV = [4]float32{0, 0, 1, 1} | |
| 140 | + | case "border": | |
| 141 | + | v.KindLayer[0] = 2 | |
| 142 | + | case "outline": | |
| 143 | + | v.KindLayer[0] = 3 | |
| 144 | + | case "shadow": | |
| 145 | + | v.KindLayer[0] = 4 | |
| 146 | + | if q.Inset { | |
| 147 | + | v.KindLayer[0] = 5 | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | for i, r := range q.Radii { | |
| 151 | + | v.Radii[i] = float32(r) | |
| 152 | + | } | |
| 153 | + | if q.Op == "glyph" { | |
| 154 | + | v.KindLayer = [4]float32{1, float32(q.Glyph.Page)} | |
| 155 | + | v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize} | |
| 156 | + | } | |
| 157 | + | return v | |
| 158 | + | } | |
| @@ -21,57 +21,138 @@ func ListInstances(frame Frame) ([]vkdevice.Instance, ListStats) { | |||||
| 21 | 21 | ||||
| 22 | // ListBatches groups consecutive atlas draws and isolates each image draw. | 22 | // ListBatches groups consecutive atlas draws and isolates each image draw. | ||
| 23 | // First indexes the returned instances, retaining exact painter's order. | 23 | // First indexes the returned instances, retaining exact painter's order. | ||
| 24 | + | // The returned slices and map are freshly allocated and owned by the caller. | |||
| 24 | func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | 25 | func ListBatches(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | ||
| 25 | - | out := make([]vkdevice.Instance, 0, len(frame.Quads)) | 26 | + | return listBatches(frame, nil, nil, make(map[string]int)) |
| 26 | - | stats := ListStats{Skipped: make(map[string]int)} | 27 | + | } |
| 27 | - | var batches []vkdevice.Batch | 28 | + | |
| 28 | - | for _, q := range frame.Quads { | 29 | + | // ListBuffer is a single-owner scratch area for repeated ListBatches calls. |
| 29 | - | switch q.Op { | 30 | + | // The slices it returns alias its storage: they are valid only until the next |
| 30 | - | case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image": | 31 | + | // List or Release call, and must not be retained, shared across goroutines, or |
| 31 | - | v := vkdevice.Instance{ | 32 | + | // used while another List may run. This is safe for consumers that copy the |
| 32 | - | Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)}, | 33 | + | // data synchronously (Session.tick uploads instances into GPU memory and |
| 33 | - | Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)}, | 34 | + | // resolve copies the batches before recording returns); anything that keeps the |
| 34 | - | Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)}, | 35 | + | // slices past that point must use ListBatches instead. Retained frames hold |
| 35 | - | } | 36 | + | // only render.Frame, never these slices, so a blocked frame is re-listed on retry. |
| 36 | - | v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)} | 37 | + | // Stats.Skipped shares the same lifetime. The zero value is ready to use. |
| 37 | - | v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)} | 38 | + | type ListBuffer struct { |
| 38 | - | v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])} | 39 | + | instances []vkdevice.Instance |
| 39 | - | for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} { | 40 | + | batches []vkdevice.Batch |
| 40 | - | v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))} | 41 | + | skipped map[string]int |
| 41 | - | } | 42 | + | } |
| 42 | - | switch q.Op { | 43 | + | |
| 43 | - | case "image": | 44 | + | // bufferSlack is how much larger than needed retained storage may be before |
| 44 | - | v.KindLayer[0] = 6 | 45 | + | // List drops it, so one huge frame does not pin memory forever. |
| 45 | - | v.UV = [4]float32{0, 0, 1, 1} | 46 | + | const bufferSlack = 4 |
| 46 | - | case "border": | 47 | + | |
| 47 | - | v.KindLayer[0] = 2 | 48 | + | // List is ListBatches reusing this buffer's storage where capacity allows. |
| 48 | - | case "outline": | 49 | + | func (b *ListBuffer) List(frame Frame) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { |
| 49 | - | v.KindLayer[0] = 3 | 50 | + | b.Release() |
| 50 | - | case "shadow": | 51 | + | if b.skipped == nil { |
| 51 | - | v.KindLayer[0] = 4 | 52 | + | b.skipped = make(map[string]int) |
| 52 | - | if q.Inset { | 53 | + | } |
| 53 | - | v.KindLayer[0] = 5 | 54 | + | clear(b.skipped) |
| 54 | - | } | 55 | + | instances, batches, stats := listBatches(frame, b.instances[:0], b.batches[:0], b.skipped) |
| 55 | - | } | 56 | + | b.instances, b.batches = instances[:0], batches[:0] |
| 56 | - | for i, r := range q.Radii { | 57 | + | return instances, batches, stats |
| 57 | - | v.Radii[i] = float32(r) | 58 | + | } |
| 58 | - | } | 59 | + | |
| 59 | - | if q.Op == "glyph" { | 60 | + | // Release drops borrowed image references from the previous result. Call it |
| 60 | - | v.KindLayer = [4]float32{1, float32(q.Glyph.Page)} | 61 | + | // once the result has been consumed so an idle buffer does not pin images. |
| 61 | - | v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize} | 62 | + | func (b *ListBuffer) Release() { |
| 62 | - | } | 63 | + | clear(b.batches[:cap(b.batches)]) |
| 63 | - | if q.Op == "image" { | 64 | + | } |
| 64 | - | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image}) | 65 | + | |
| 65 | - | } else if len(batches) == 0 || batches[len(batches)-1].Source != nil { | 66 | + | // drawn reports whether the operation draws an instance. |
| 66 | - | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1}) | 67 | + | func drawn(op string) bool { |
| 67 | - | } else { | 68 | + | switch op { |
| 68 | - | batches[len(batches)-1].Count++ | 69 | + | case "rect", "selection", "caret", "preedit-underline", "preedit-caret", "glyph", "border", "outline", "shadow", "image": |
| 69 | - | } | 70 | + | return true |
| 70 | - | out = append(out, v) | 71 | + | } |
| 71 | - | default: | 72 | + | return false |
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // listBatches fills out and batches (both length zero), growing them only when | |||
| 76 | + | // their capacity is short of the exact counts or absurdly larger than needed. | |||
| 77 | + | func listBatches(frame Frame, out []vkdevice.Instance, batches []vkdevice.Batch, skipped map[string]int) ([]vkdevice.Instance, []vkdevice.Batch, ListStats) { | |||
| 78 | + | // Count first so both slices are sized exactly: no append growth, and no | |||
| 79 | + | // over-allocation for frames that skip quads. | |||
| 80 | + | drawCount, batchCount, open := 0, 0, false | |||
| 81 | + | for i := range frame.Quads { | |||
| 82 | + | op := frame.Quads[i].Op | |||
| 83 | + | if !drawn(op) { | |||
| 84 | + | continue | |||
| 85 | + | } | |||
| 86 | + | drawCount++ | |||
| 87 | + | if op == "image" { | |||
| 88 | + | batchCount++ | |||
| 89 | + | open = false | |||
| 90 | + | } else if !open { | |||
| 91 | + | batchCount++ | |||
| 92 | + | open = true | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | if out == nil || cap(out) < drawCount || cap(out) > bufferSlack*drawCount+64 { | |||
| 96 | + | out = make([]vkdevice.Instance, 0, drawCount) | |||
| 97 | + | } | |||
| 98 | + | if batchCount == 0 { | |||
| 99 | + | batches = nil // as before: no draws, no batches | |||
| 100 | + | } else if cap(batches) < batchCount || cap(batches) > bufferSlack*batchCount+16 { | |||
| 101 | + | batches = make([]vkdevice.Batch, 0, batchCount) | |||
| 102 | + | } | |||
| 103 | + | stats := ListStats{Skipped: skipped} | |||
| 104 | + | for i := range frame.Quads { | |||
| 105 | + | q := &frame.Quads[i] | |||
| 106 | + | if !drawn(q.Op) { | |||
| 72 | stats.Skipped[q.Op]++ | 107 | stats.Skipped[q.Op]++ | ||
| 108 | + | continue | |||
| 73 | } | 109 | } | ||
| 110 | + | if q.Op == "image" { | |||
| 111 | + | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1, Source: q.Image}) | |||
| 112 | + | } else if len(batches) == 0 || batches[len(batches)-1].Source != nil { | |||
| 113 | + | batches = append(batches, vkdevice.Batch{First: uint32(len(out)), Count: 1}) | |||
| 114 | + | } else { | |||
| 115 | + | batches[len(batches)-1].Count++ | |||
| 116 | + | } | |||
| 117 | + | out = append(out, instanceOf(q)) | |||
| 74 | } | 118 | } | ||
| 75 | stats.Drawn = len(out) | 119 | stats.Drawn = len(out) | ||
| 76 | return out, batches, stats | 120 | return out, batches, stats | ||
| 77 | } | 121 | } | ||
| 122 | + | ||||
| 123 | + | // instanceOf converts one drawable quad to its GPU instance. | |||
| 124 | + | func instanceOf(q *Quad) vkdevice.Instance { | |||
| 125 | + | v := vkdevice.Instance{ | |||
| 126 | + | Bounds: [4]float32{float32(q.Rect.X), float32(q.Rect.Y), float32(q.Rect.W), float32(q.Rect.H)}, | |||
| 127 | + | Clip: [4]float32{float32(q.Clip.X), float32(q.Clip.Y), float32(q.Clip.W), float32(q.Clip.H)}, | |||
| 128 | + | Color: [4]float32{float32(q.Color.R), float32(q.Color.G), float32(q.Color.B), float32(q.Color.A)}, | |||
| 129 | + | } | |||
| 130 | + | v.Shape = [4]float32{float32(q.Shape.X), float32(q.Shape.Y), float32(q.Shape.W), float32(q.Shape.H)} | |||
| 131 | + | v.Widths = [4]float32{float32(q.Widths.Top), float32(q.Widths.Right), float32(q.Widths.Bottom), float32(q.Widths.Left)} | |||
| 132 | + | v.Shadow = [4]float32{float32(q.Shadow[0]), float32(q.Shadow[1]), float32(q.Shadow[2]), float32(q.Shadow[3])} | |||
| 133 | + | for i, c := range [4]css.Color{q.Colors.Top, q.Colors.Right, q.Colors.Bottom, q.Colors.Left} { | |||
| 134 | + | v.Sides[i] = [4]float32{float32(c.R), float32(c.G), float32(c.B), float32(c.A * float64(v.Color[3]))} | |||
| 135 | + | } | |||
| 136 | + | switch q.Op { | |||
| 137 | + | case "image": | |||
| 138 | + | v.KindLayer[0] = 6 | |||
| 139 | + | v.UV = [4]float32{0, 0, 1, 1} | |||
| 140 | + | case "border": | |||
| 141 | + | v.KindLayer[0] = 2 | |||
| 142 | + | case "outline": | |||
| 143 | + | v.KindLayer[0] = 3 | |||
| 144 | + | case "shadow": | |||
| 145 | + | v.KindLayer[0] = 4 | |||
| 146 | + | if q.Inset { | |||
| 147 | + | v.KindLayer[0] = 5 | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | for i, r := range q.Radii { | |||
| 151 | + | v.Radii[i] = float32(r) | |||
| 152 | + | } | |||
| 153 | + | if q.Op == "glyph" { | |||
| 154 | + | v.KindLayer = [4]float32{1, float32(q.Glyph.Page)} | |||
| 155 | + | v.UV = [4]float32{float32(q.Glyph.Rect.Min.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Min.Y) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.X) / vkdevice.AtlasSize, float32(q.Glyph.Rect.Max.Y) / vkdevice.AtlasSize} | |||
| 156 | + | } | |||
| 157 | + | return v | |||
| 158 | + | } | |||
A internal/render/instances_bench_test.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package render | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |
| 8 | + | "github.com/bnema/nefergui/internal/layout" | |
| 9 | + | "github.com/bnema/nefergui/internal/text" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // listFixture is a product-like mix: atlas draws with an image every 50 quads. | |
| 13 | + | func listFixture(n int) Frame { | |
| 14 | + | img := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |
| 15 | + | quads := make([]Quad, n) | |
| 16 | + | for i := range quads { | |
| 17 | + | switch { | |
| 18 | + | case i%50 == 49: | |
| 19 | + | quads[i] = Quad{Op: "image", Image: img, Rect: layout.Rect{W: 4, H: 4}} | |
| 20 | + | case i%3 == 0: | |
| 21 | + | quads[i] = Quad{Op: "glyph", Glyph: text.Placement{Page: 1, Rect: image.Rect(1, 2, 9, 12)}, Color: css.Color{A: 1}} | |
| 22 | + | case i%7 == 0: | |
| 23 | + | quads[i] = Quad{Op: "border", Color: css.Color{A: 1}} | |
| 24 | + | default: | |
| 25 | + | quads[i] = Quad{Op: "rect", Rect: layout.Rect{X: float64(i), W: 3, H: 3}, Color: css.Color{A: 1}} | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | return Frame{Quads: quads} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func BenchmarkListBatches(b *testing.B) { | |
| 32 | + | frame := listFixture(2000) | |
| 33 | + | b.ReportAllocs() | |
| 34 | + | for i := 0; i < b.N; i++ { | |
| 35 | + | instances, batches, stats := ListBatches(frame) | |
| 36 | + | if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 { | |
| 37 | + | b.Fatal("list") | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func BenchmarkListBufferReuse(b *testing.B) { | |
| 43 | + | frame := listFixture(2000) | |
| 44 | + | var buf ListBuffer | |
| 45 | + | b.ReportAllocs() | |
| 46 | + | for i := 0; i < b.N; i++ { | |
| 47 | + | instances, batches, stats := buf.List(frame) | |
| 48 | + | if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 { | |
| 49 | + | b.Fatal("list") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package render | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/nefergui/internal/css" | |||
| 8 | + | "github.com/bnema/nefergui/internal/layout" | |||
| 9 | + | "github.com/bnema/nefergui/internal/text" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // listFixture is a product-like mix: atlas draws with an image every 50 quads. | |||
| 13 | + | func listFixture(n int) Frame { | |||
| 14 | + | img := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |||
| 15 | + | quads := make([]Quad, n) | |||
| 16 | + | for i := range quads { | |||
| 17 | + | switch { | |||
| 18 | + | case i%50 == 49: | |||
| 19 | + | quads[i] = Quad{Op: "image", Image: img, Rect: layout.Rect{W: 4, H: 4}} | |||
| 20 | + | case i%3 == 0: | |||
| 21 | + | quads[i] = Quad{Op: "glyph", Glyph: text.Placement{Page: 1, Rect: image.Rect(1, 2, 9, 12)}, Color: css.Color{A: 1}} | |||
| 22 | + | case i%7 == 0: | |||
| 23 | + | quads[i] = Quad{Op: "border", Color: css.Color{A: 1}} | |||
| 24 | + | default: | |||
| 25 | + | quads[i] = Quad{Op: "rect", Rect: layout.Rect{X: float64(i), W: 3, H: 3}, Color: css.Color{A: 1}} | |||
| 26 | + | } | |||
| 27 | + | } | |||
| 28 | + | return Frame{Quads: quads} | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func BenchmarkListBatches(b *testing.B) { | |||
| 32 | + | frame := listFixture(2000) | |||
| 33 | + | b.ReportAllocs() | |||
| 34 | + | for i := 0; i < b.N; i++ { | |||
| 35 | + | instances, batches, stats := ListBatches(frame) | |||
| 36 | + | if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 { | |||
| 37 | + | b.Fatal("list") | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func BenchmarkListBufferReuse(b *testing.B) { | |||
| 43 | + | frame := listFixture(2000) | |||
| 44 | + | var buf ListBuffer | |||
| 45 | + | b.ReportAllocs() | |||
| 46 | + | for i := 0; i < b.N; i++ { | |||
| 47 | + | instances, batches, stats := buf.List(frame) | |||
| 48 | + | if len(instances) != 2000 || len(batches) == 0 || len(stats.Skipped) != 0 { | |||
| 49 | + | b.Fatal("list") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
M internal/render/instances_test.go +68 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "github.com/bnema/nefergui/internal/layout" | |
| 6 | 6 | "github.com/bnema/nefergui/internal/text" | |
| 7 | 7 | "image" | |
| 8 | + | "reflect" | |
| 8 | 9 | "testing" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -44,3 +45,70 @@ func TestListInstances(t *testing.T) { | |||
| 44 | 45 | t.Fatalf("glyph=%+v", got[2]) | |
| 45 | 46 | } | |
| 46 | 47 | } | |
| 48 | + | ||
| 49 | + | func TestListBufferMatchesListBatches(t *testing.T) { | |
| 50 | + | a := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |
| 51 | + | frames := []Frame{ | |
| 52 | + | listFixture(200), | |
| 53 | + | {Quads: []Quad{{Op: "rect"}, {Op: "mystery"}, {Op: "image", Image: a}, {Op: "mystery"}, {Op: "glyph"}, {Op: "glyph"}}}, | |
| 54 | + | {}, | |
| 55 | + | listFixture(7), | |
| 56 | + | } | |
| 57 | + | var buf ListBuffer | |
| 58 | + | for round := 0; round < 2; round++ { | |
| 59 | + | for i, f := range frames { | |
| 60 | + | wi, wb, ws := ListBatches(f) | |
| 61 | + | gi, gb, gs := buf.List(f) | |
| 62 | + | if !reflect.DeepEqual(wi, gi) || !reflect.DeepEqual(wb, gb) || !reflect.DeepEqual(ws, gs) { | |
| 63 | + | t.Fatalf("round %d frame %d differs:\nwant %v %v %v\ngot %v %v %v", round, i, len(wi), wb, ws, len(gi), gb, gs) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // A result stays valid until the next List; a later smaller frame must not | |
| 70 | + | // leave stale instances, batches, image references or skip counts visible. | |
| 71 | + | func TestListBufferReuseLifetime(t *testing.T) { | |
| 72 | + | a := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |
| 73 | + | var buf ListBuffer | |
| 74 | + | i1, b1, s1 := buf.List(Frame{Quads: []Quad{{Op: "rect"}, {Op: "image", Image: a}, {Op: "nope"}}}) | |
| 75 | + | if len(i1) != 2 || len(b1) != 2 || s1.Skipped["nope"] != 1 { | |
| 76 | + | t.Fatalf("first: %v %v %v", i1, b1, s1) | |
| 77 | + | } | |
| 78 | + | i2, b2, s2 := buf.List(Frame{Quads: []Quad{{Op: "glyph"}}}) | |
| 79 | + | if len(i2) != 1 || len(b2) != 1 || b2[0].Source != nil || len(s2.Skipped) != 0 || i2[0].KindLayer[0] != 1 { | |
| 80 | + | t.Fatalf("second: %v %v %v", i2, b2, s2) | |
| 81 | + | } | |
| 82 | + | buf.Release() | |
| 83 | + | for _, batch := range b2[:cap(b2)] { | |
| 84 | + | if batch.Source != nil { | |
| 85 | + | t.Fatal("Release left an image reference") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | // Results from the non-buffer API never alias the buffer. | |
| 89 | + | fi, fb, _ := ListBatches(Frame{Quads: []Quad{{Op: "rect"}}}) | |
| 90 | + | buf.List(listFixture(50)) | |
| 91 | + | if fi[0].KindLayer != [4]float32{} || len(fb) != 1 || fb[0].Count != 1 { | |
| 92 | + | t.Fatalf("ListBatches result aliased: %v %v", fi, fb) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestListBufferDropsOversizedStorage(t *testing.T) { | |
| 97 | + | var buf ListBuffer | |
| 98 | + | buf.List(listFixture(20000)) | |
| 99 | + | if _, _, _ = buf.List(listFixture(10)); cap(buf.instances) > 4*10+64 { | |
| 100 | + | t.Fatalf("retained %d instances for 10 quads", cap(buf.instances)) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestListBatchesAllocations(t *testing.T) { | |
| 105 | + | frame := listFixture(2000) | |
| 106 | + | if n := testing.AllocsPerRun(20, func() { ListBatches(frame) }); n > 4 { | |
| 107 | + | t.Fatalf("ListBatches allocs=%v, want <= 4 (instances, batches, skipped map)", n) | |
| 108 | + | } | |
| 109 | + | var buf ListBuffer | |
| 110 | + | buf.List(frame) | |
| 111 | + | if n := testing.AllocsPerRun(20, func() { buf.List(frame) }); n != 0 { | |
| 112 | + | t.Fatalf("ListBuffer.List allocs=%v, want 0 after warmup", n) | |
| 113 | + | } | |
| 114 | + | } | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "github.com/bnema/nefergui/internal/layout" | 5 | "github.com/bnema/nefergui/internal/layout" | ||
| 6 | "github.com/bnema/nefergui/internal/text" | 6 | "github.com/bnema/nefergui/internal/text" | ||
| 7 | "image" | 7 | "image" | ||
| 8 | + | "reflect" | |||
| 8 | "testing" | 9 | "testing" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -44,3 +45,70 @@ func TestListInstances(t *testing.T) { | |||||
| 44 | t.Fatalf("glyph=%+v", got[2]) | 45 | t.Fatalf("glyph=%+v", got[2]) | ||
| 45 | } | 46 | } | ||
| 46 | } | 47 | } | ||
| 48 | + | ||||
| 49 | + | func TestListBufferMatchesListBatches(t *testing.T) { | |||
| 50 | + | a := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |||
| 51 | + | frames := []Frame{ | |||
| 52 | + | listFixture(200), | |||
| 53 | + | {Quads: []Quad{{Op: "rect"}, {Op: "mystery"}, {Op: "image", Image: a}, {Op: "mystery"}, {Op: "glyph"}, {Op: "glyph"}}}, | |||
| 54 | + | {}, | |||
| 55 | + | listFixture(7), | |||
| 56 | + | } | |||
| 57 | + | var buf ListBuffer | |||
| 58 | + | for round := 0; round < 2; round++ { | |||
| 59 | + | for i, f := range frames { | |||
| 60 | + | wi, wb, ws := ListBatches(f) | |||
| 61 | + | gi, gb, gs := buf.List(f) | |||
| 62 | + | if !reflect.DeepEqual(wi, gi) || !reflect.DeepEqual(wb, gb) || !reflect.DeepEqual(ws, gs) { | |||
| 63 | + | t.Fatalf("round %d frame %d differs:\nwant %v %v %v\ngot %v %v %v", round, i, len(wi), wb, ws, len(gi), gb, gs) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // A result stays valid until the next List; a later smaller frame must not | |||
| 70 | + | // leave stale instances, batches, image references or skip counts visible. | |||
| 71 | + | func TestListBufferReuseLifetime(t *testing.T) { | |||
| 72 | + | a := image.NewRGBA(image.Rect(0, 0, 2, 2)) | |||
| 73 | + | var buf ListBuffer | |||
| 74 | + | i1, b1, s1 := buf.List(Frame{Quads: []Quad{{Op: "rect"}, {Op: "image", Image: a}, {Op: "nope"}}}) | |||
| 75 | + | if len(i1) != 2 || len(b1) != 2 || s1.Skipped["nope"] != 1 { | |||
| 76 | + | t.Fatalf("first: %v %v %v", i1, b1, s1) | |||
| 77 | + | } | |||
| 78 | + | i2, b2, s2 := buf.List(Frame{Quads: []Quad{{Op: "glyph"}}}) | |||
| 79 | + | if len(i2) != 1 || len(b2) != 1 || b2[0].Source != nil || len(s2.Skipped) != 0 || i2[0].KindLayer[0] != 1 { | |||
| 80 | + | t.Fatalf("second: %v %v %v", i2, b2, s2) | |||
| 81 | + | } | |||
| 82 | + | buf.Release() | |||
| 83 | + | for _, batch := range b2[:cap(b2)] { | |||
| 84 | + | if batch.Source != nil { | |||
| 85 | + | t.Fatal("Release left an image reference") | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | // Results from the non-buffer API never alias the buffer. | |||
| 89 | + | fi, fb, _ := ListBatches(Frame{Quads: []Quad{{Op: "rect"}}}) | |||
| 90 | + | buf.List(listFixture(50)) | |||
| 91 | + | if fi[0].KindLayer != [4]float32{} || len(fb) != 1 || fb[0].Count != 1 { | |||
| 92 | + | t.Fatalf("ListBatches result aliased: %v %v", fi, fb) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestListBufferDropsOversizedStorage(t *testing.T) { | |||
| 97 | + | var buf ListBuffer | |||
| 98 | + | buf.List(listFixture(20000)) | |||
| 99 | + | if _, _, _ = buf.List(listFixture(10)); cap(buf.instances) > 4*10+64 { | |||
| 100 | + | t.Fatalf("retained %d instances for 10 quads", cap(buf.instances)) | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestListBatchesAllocations(t *testing.T) { | |||
| 105 | + | frame := listFixture(2000) | |||
| 106 | + | if n := testing.AllocsPerRun(20, func() { ListBatches(frame) }); n > 4 { | |||
| 107 | + | t.Fatalf("ListBatches allocs=%v, want <= 4 (instances, batches, skipped map)", n) | |||
| 108 | + | } | |||
| 109 | + | var buf ListBuffer | |||
| 110 | + | buf.List(frame) | |||
| 111 | + | if n := testing.AllocsPerRun(20, func() { buf.List(frame) }); n != 0 { | |||
| 112 | + | t.Fatalf("ListBuffer.List allocs=%v, want 0 after warmup", n) | |||
| 113 | + | } | |||
| 114 | + | } | |||
M internal/render/prepare.go +24 -0
| @@ -74,6 +74,25 @@ func scaled(e layout.Edges, scale float64) layout.Edges { | |||
| 74 | 74 | return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale} | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | + | // quadCapacity is an upper bound on the quads Prepare can emit: one per glyph | |
| 78 | + | // and one per drawable command. Clipped-out primitives make it a slight | |
| 79 | + | // over-estimate, never an under-estimate, so appends do not reallocate. | |
| 80 | + | func quadCapacity(commands []layout.Command) int { | |
| 81 | + | n := 0 | |
| 82 | + | for i := range commands { | |
| 83 | + | switch cmd := &commands[i]; cmd.Op { | |
| 84 | + | case "clip-push", "clip-pop": | |
| 85 | + | case "text", "preedit-text": | |
| 86 | + | for j := range cmd.Runs { | |
| 87 | + | n += len(cmd.Runs[j].Glyphs) | |
| 88 | + | } | |
| 89 | + | default: | |
| 90 | + | n++ | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | return n | |
| 94 | + | } | |
| 95 | + | ||
| 77 | 96 | // Prepare converts logical rectangles once, using layout.Physical as the sole | |
| 78 | 97 | // logical-to-physical edge-rounding boundary. A clip-push intersects its parent | |
| 79 | 98 | // clip; rounded clips intentionally use only their axis-aligned bounds in V1. | |
| @@ -86,6 +105,11 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig | |||
| 86 | 105 | } | |
| 87 | 106 | p.Atlas.BeginFrame() | |
| 88 | 107 | frame := Frame{} | |
| 108 | + | if n := quadCapacity(commands); n > 0 { | |
| 109 | + | // Frames are returned detached and may outlive later Prepare calls, so | |
| 110 | + | // the backing array is never reused; size it once instead of regrowing. | |
| 111 | + | frame.Quads = make([]Quad, 0, n) | |
| 112 | + | } | |
| 89 | 113 | clip := layout.Rect{W: float64(width), H: float64(height)} | |
| 90 | 114 | stack := []layout.Rect{} | |
| 91 | 115 | for _, cmd := range commands { | |
| @@ -74,6 +74,25 @@ func scaled(e layout.Edges, scale float64) layout.Edges { | |||||
| 74 | return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale} | 74 | return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale} | ||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | + | // quadCapacity is an upper bound on the quads Prepare can emit: one per glyph | |||
| 78 | + | // and one per drawable command. Clipped-out primitives make it a slight | |||
| 79 | + | // over-estimate, never an under-estimate, so appends do not reallocate. | |||
| 80 | + | func quadCapacity(commands []layout.Command) int { | |||
| 81 | + | n := 0 | |||
| 82 | + | for i := range commands { | |||
| 83 | + | switch cmd := &commands[i]; cmd.Op { | |||
| 84 | + | case "clip-push", "clip-pop": | |||
| 85 | + | case "text", "preedit-text": | |||
| 86 | + | for j := range cmd.Runs { | |||
| 87 | + | n += len(cmd.Runs[j].Glyphs) | |||
| 88 | + | } | |||
| 89 | + | default: | |||
| 90 | + | n++ | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | return n | |||
| 94 | + | } | |||
| 95 | + | ||||
| 77 | // Prepare converts logical rectangles once, using layout.Physical as the sole | 96 | // Prepare converts logical rectangles once, using layout.Physical as the sole | ||
| 78 | // logical-to-physical edge-rounding boundary. A clip-push intersects its parent | 97 | // logical-to-physical edge-rounding boundary. A clip-push intersects its parent | ||
| 79 | // clip; rounded clips intentionally use only their axis-aligned bounds in V1. | 98 | // clip; rounded clips intentionally use only their axis-aligned bounds in V1. | ||
| @@ -86,6 +105,11 @@ func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, heig | |||||
| 86 | } | 105 | } | ||
| 87 | p.Atlas.BeginFrame() | 106 | p.Atlas.BeginFrame() | ||
| 88 | frame := Frame{} | 107 | frame := Frame{} | ||
| 108 | + | if n := quadCapacity(commands); n > 0 { | |||
| 109 | + | // Frames are returned detached and may outlive later Prepare calls, so | |||
| 110 | + | // the backing array is never reused; size it once instead of regrowing. | |||
| 111 | + | frame.Quads = make([]Quad, 0, n) | |||
| 112 | + | } | |||
| 89 | clip := layout.Rect{W: float64(width), H: float64(height)} | 113 | clip := layout.Rect{W: float64(width), H: float64(height)} | ||
| 90 | stack := []layout.Rect{} | 114 | stack := []layout.Rect{} | ||
| 91 | for _, cmd := range commands { | 115 | for _, cmd := range commands { | ||
A internal/render/prepare_alloc_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package render | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |
| 7 | + | "github.com/bnema/nefergui/internal/layout" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func rectCommands(n int) []layout.Command { | |
| 11 | + | cmds := make([]layout.Command, n) | |
| 12 | + | for i := range cmds { | |
| 13 | + | cmds[i] = layout.Command{Op: "rect", Rect: layout.Rect{X: 1, Y: float64(i), W: 10, H: 1}, Color: css.Color{A: 1}, Opacity: 1} | |
| 14 | + | } | |
| 15 | + | return cmds | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestQuadCapacityIsSufficientAndExactWithoutCulling(t *testing.T) { | |
| 19 | + | glyphs := glyphCommand(t, "office") | |
| 20 | + | n := 0 | |
| 21 | + | for _, r := range glyphs.Runs { | |
| 22 | + | n += len(r.Glyphs) | |
| 23 | + | } | |
| 24 | + | cmds := []layout.Command{{Op: "clip-push", Rect: layout.Rect{W: 200, H: 100}}, rectCommands(1)[0], glyphs, {Op: "clip-pop"}} | |
| 25 | + | if got := quadCapacity(cmds); got != n+1 { | |
| 26 | + | t.Fatalf("capacity %d, want %d", got, n+1) | |
| 27 | + | } | |
| 28 | + | p, err := NewPreparer(256, 2) | |
| 29 | + | if err != nil { | |
| 30 | + | t.Fatal(err) | |
| 31 | + | } | |
| 32 | + | f, err := p.Prepare(cmds, 1, 200, 100) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | if len(f.Quads) == 0 || len(f.Quads) > n+1 || cap(f.Quads) != n+1 { | |
| 37 | + | t.Fatalf("len=%d cap=%d, want cap %d", len(f.Quads), cap(f.Quads), n+1) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Frames are detached: a later Prepare must not alias or overwrite an earlier | |
| 42 | + | // frame that is still queued, blocked or superseded. | |
| 43 | + | func TestSuccessiveFramesAreDetached(t *testing.T) { | |
| 44 | + | p, err := NewPreparer(256, 2) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | a, err := p.Prepare(rectCommands(4), 1, 100, 100) | |
| 49 | + | if err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | want := append([]Quad(nil), a.Quads...) | |
| 53 | + | b, err := p.Prepare(rectCommands(3), 2, 100, 100) | |
| 54 | + | if err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | if len(a.Quads) != 4 || len(b.Quads) != 3 || &a.Quads[0] == &b.Quads[0] { | |
| 58 | + | t.Fatalf("frames alias: %d %d", len(a.Quads), len(b.Quads)) | |
| 59 | + | } | |
| 60 | + | for i := range want { | |
| 61 | + | if a.Quads[i] != want[i] { | |
| 62 | + | t.Fatalf("earlier frame quad %d overwritten", i) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | // Appending to a returned frame must not write into another frame. | |
| 66 | + | b.Quads = append(b.Quads[:len(b.Quads):len(b.Quads)], Quad{Op: "x"}) | |
| 67 | + | if a.Quads[0] != want[0] { | |
| 68 | + | t.Fatal("append leaked into earlier frame") | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestFailedPrepareReturnsNoPartialFrame(t *testing.T) { | |
| 73 | + | p, err := NewPreparer(256, 2) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | cmds := append(rectCommands(8), layout.Command{Op: "bogus"}) | |
| 78 | + | f, err := p.Prepare(cmds, 1, 100, 100) | |
| 79 | + | if err == nil || f.Quads != nil || f.Uploads != nil { | |
| 80 | + | t.Fatalf("partial frame on failure: %v %+v", err, f) | |
| 81 | + | } | |
| 82 | + | if f, err = p.Prepare(nil, 1, 100, 100); err != nil || f.Quads != nil { | |
| 83 | + | t.Fatalf("empty frame: %v %+v", err, f) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Preallocation must not disturb owed-upload accounting across frames whose | |
| 88 | + | // quads were sized differently. | |
| 89 | + | func TestPreallocatedFramesKeepUploadDebt(t *testing.T) { | |
| 90 | + | cmd := glyphCommand(t, "office") | |
| 91 | + | p, err := NewPreparer(256, 2) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | first, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100) | |
| 96 | + | if err != nil || len(first.Uploads) == 0 { | |
| 97 | + | t.Fatalf("first: %v %d", err, len(first.Uploads)) | |
| 98 | + | } | |
| 99 | + | // Superseding frame with no glyphs still resends the owed pages. | |
| 100 | + | second, err := p.Prepare(rectCommands(2), 1, 200, 100) | |
| 101 | + | if err != nil || len(second.Uploads) == 0 { | |
| 102 | + | t.Fatalf("debt lost: %v %d", err, len(second.Uploads)) | |
| 103 | + | } | |
| 104 | + | p.Submitted(first.Uploads) | |
| 105 | + | if f, err := p.Prepare(rectCommands(2), 1, 200, 100); err != nil || len(f.Uploads) == 0 { | |
| 106 | + | t.Fatalf("stale ack cleared debt: %v %d", err, len(f.Uploads)) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Box-only frames must cost one Quads allocation regardless of command count. | |
| 111 | + | func TestPrepareQuadAllocationsDoNotScaleWithCommands(t *testing.T) { | |
| 112 | + | p, err := NewPreparer(256, 2) | |
| 113 | + | if err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | cmds := rectCommands(512) | |
| 117 | + | allocs := testing.AllocsPerRun(20, func() { | |
| 118 | + | if _, err := p.Prepare(cmds, 1, 100, 1000); err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | }) | |
| 122 | + | if allocs > 3 { | |
| 123 | + | t.Fatalf("%v allocs for 512 rects, want a small constant", allocs) | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func BenchmarkPrepareBoxes(b *testing.B) { | |
| 128 | + | p, err := NewPreparer(256, 2) | |
| 129 | + | if err != nil { | |
| 130 | + | b.Fatal(err) | |
| 131 | + | } | |
| 132 | + | cmds := rectCommands(512) | |
| 133 | + | b.ReportAllocs() | |
| 134 | + | b.ResetTimer() | |
| 135 | + | for i := 0; i < b.N; i++ { | |
| 136 | + | if f, err := p.Prepare(cmds, 1, 100, 1000); err != nil || len(f.Quads) != 512 { | |
| 137 | + | b.Fatal(err) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | package render | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/nefergui/internal/css" | |||
| 7 | + | "github.com/bnema/nefergui/internal/layout" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func rectCommands(n int) []layout.Command { | |||
| 11 | + | cmds := make([]layout.Command, n) | |||
| 12 | + | for i := range cmds { | |||
| 13 | + | cmds[i] = layout.Command{Op: "rect", Rect: layout.Rect{X: 1, Y: float64(i), W: 10, H: 1}, Color: css.Color{A: 1}, Opacity: 1} | |||
| 14 | + | } | |||
| 15 | + | return cmds | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | func TestQuadCapacityIsSufficientAndExactWithoutCulling(t *testing.T) { | |||
| 19 | + | glyphs := glyphCommand(t, "office") | |||
| 20 | + | n := 0 | |||
| 21 | + | for _, r := range glyphs.Runs { | |||
| 22 | + | n += len(r.Glyphs) | |||
| 23 | + | } | |||
| 24 | + | cmds := []layout.Command{{Op: "clip-push", Rect: layout.Rect{W: 200, H: 100}}, rectCommands(1)[0], glyphs, {Op: "clip-pop"}} | |||
| 25 | + | if got := quadCapacity(cmds); got != n+1 { | |||
| 26 | + | t.Fatalf("capacity %d, want %d", got, n+1) | |||
| 27 | + | } | |||
| 28 | + | p, err := NewPreparer(256, 2) | |||
| 29 | + | if err != nil { | |||
| 30 | + | t.Fatal(err) | |||
| 31 | + | } | |||
| 32 | + | f, err := p.Prepare(cmds, 1, 200, 100) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | if len(f.Quads) == 0 || len(f.Quads) > n+1 || cap(f.Quads) != n+1 { | |||
| 37 | + | t.Fatalf("len=%d cap=%d, want cap %d", len(f.Quads), cap(f.Quads), n+1) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | // Frames are detached: a later Prepare must not alias or overwrite an earlier | |||
| 42 | + | // frame that is still queued, blocked or superseded. | |||
| 43 | + | func TestSuccessiveFramesAreDetached(t *testing.T) { | |||
| 44 | + | p, err := NewPreparer(256, 2) | |||
| 45 | + | if err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | a, err := p.Prepare(rectCommands(4), 1, 100, 100) | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | want := append([]Quad(nil), a.Quads...) | |||
| 53 | + | b, err := p.Prepare(rectCommands(3), 2, 100, 100) | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | if len(a.Quads) != 4 || len(b.Quads) != 3 || &a.Quads[0] == &b.Quads[0] { | |||
| 58 | + | t.Fatalf("frames alias: %d %d", len(a.Quads), len(b.Quads)) | |||
| 59 | + | } | |||
| 60 | + | for i := range want { | |||
| 61 | + | if a.Quads[i] != want[i] { | |||
| 62 | + | t.Fatalf("earlier frame quad %d overwritten", i) | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | // Appending to a returned frame must not write into another frame. | |||
| 66 | + | b.Quads = append(b.Quads[:len(b.Quads):len(b.Quads)], Quad{Op: "x"}) | |||
| 67 | + | if a.Quads[0] != want[0] { | |||
| 68 | + | t.Fatal("append leaked into earlier frame") | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func TestFailedPrepareReturnsNoPartialFrame(t *testing.T) { | |||
| 73 | + | p, err := NewPreparer(256, 2) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | cmds := append(rectCommands(8), layout.Command{Op: "bogus"}) | |||
| 78 | + | f, err := p.Prepare(cmds, 1, 100, 100) | |||
| 79 | + | if err == nil || f.Quads != nil || f.Uploads != nil { | |||
| 80 | + | t.Fatalf("partial frame on failure: %v %+v", err, f) | |||
| 81 | + | } | |||
| 82 | + | if f, err = p.Prepare(nil, 1, 100, 100); err != nil || f.Quads != nil { | |||
| 83 | + | t.Fatalf("empty frame: %v %+v", err, f) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // Preallocation must not disturb owed-upload accounting across frames whose | |||
| 88 | + | // quads were sized differently. | |||
| 89 | + | func TestPreallocatedFramesKeepUploadDebt(t *testing.T) { | |||
| 90 | + | cmd := glyphCommand(t, "office") | |||
| 91 | + | p, err := NewPreparer(256, 2) | |||
| 92 | + | if err != nil { | |||
| 93 | + | t.Fatal(err) | |||
| 94 | + | } | |||
| 95 | + | first, err := p.Prepare([]layout.Command{cmd}, 1, 200, 100) | |||
| 96 | + | if err != nil || len(first.Uploads) == 0 { | |||
| 97 | + | t.Fatalf("first: %v %d", err, len(first.Uploads)) | |||
| 98 | + | } | |||
| 99 | + | // Superseding frame with no glyphs still resends the owed pages. | |||
| 100 | + | second, err := p.Prepare(rectCommands(2), 1, 200, 100) | |||
| 101 | + | if err != nil || len(second.Uploads) == 0 { | |||
| 102 | + | t.Fatalf("debt lost: %v %d", err, len(second.Uploads)) | |||
| 103 | + | } | |||
| 104 | + | p.Submitted(first.Uploads) | |||
| 105 | + | if f, err := p.Prepare(rectCommands(2), 1, 200, 100); err != nil || len(f.Uploads) == 0 { | |||
| 106 | + | t.Fatalf("stale ack cleared debt: %v %d", err, len(f.Uploads)) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // Box-only frames must cost one Quads allocation regardless of command count. | |||
| 111 | + | func TestPrepareQuadAllocationsDoNotScaleWithCommands(t *testing.T) { | |||
| 112 | + | p, err := NewPreparer(256, 2) | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | cmds := rectCommands(512) | |||
| 117 | + | allocs := testing.AllocsPerRun(20, func() { | |||
| 118 | + | if _, err := p.Prepare(cmds, 1, 100, 1000); err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | }) | |||
| 122 | + | if allocs > 3 { | |||
| 123 | + | t.Fatalf("%v allocs for 512 rects, want a small constant", allocs) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func BenchmarkPrepareBoxes(b *testing.B) { | |||
| 128 | + | p, err := NewPreparer(256, 2) | |||
| 129 | + | if err != nil { | |||
| 130 | + | b.Fatal(err) | |||
| 131 | + | } | |||
| 132 | + | cmds := rectCommands(512) | |||
| 133 | + | b.ReportAllocs() | |||
| 134 | + | b.ResetTimer() | |||
| 135 | + | for i := 0; i < b.N; i++ { | |||
| 136 | + | if f, err := p.Prepare(cmds, 1, 100, 1000); err != nil || len(f.Quads) != 512 { | |||
| 137 | + | b.Fatal(err) | |||
| 138 | + | } | |||
| 139 | + | } | |||
| 140 | + | } | |||