1// Package render prepares ordered physical-pixel primitives for Vulkan submission.
2// It does not own the display list or its borrowed font faces.
3package render
4
5import (
6 "fmt"
7 "image"
8 "math"
9 "slices"
10
11 "github.com/bnema/nefergui/internal/css"
12 "github.com/bnema/nefergui/internal/layout"
13 "github.com/bnema/nefergui/internal/text"
14 "github.com/go-text/typesetting/font"
15)
16
17// Quad is a physical-pixel rectangle with a physical clip. Color is straight
18// sRGB; conversion to premultiplied linear color belongs at the GPU boundary.
19// Glyphs refer to an atlas placement valid only for this prepared frame.
20type Quad struct {
21 Op string
22 Rect layout.Rect
23 Clip layout.Rect
24 Color css.Color
25 Radii [4]float64
26 Widths layout.Edges
27 Colors css.ColorSides
28 Glyph text.Placement
29 Image image.Image
30 // Shape is the original box when the draw bounds extend past it.
31 Shape layout.Rect
32 // Shadow holds physical offset, blur and spread. Inset is carried separately.
33 Shadow [4]float64
34 Inset bool
35}
36
37type Frame struct {
38 Quads []Quad
39 Uploads []text.Upload
40}
41
42// Preparer owns the glyph atlas and the obligation to deliver its changes to the
43// GPU mirror. Prepare hands the caller every change not yet known to be
44// submitted; Submitted is the only acknowledgement. A frame that is retried,
45// abandoned, superseded or never reached submission therefore never loses
46// atlas changes: the next Prepare resends whole pages (placements are kept).
47type Preparer struct {
48 Atlas *text.Atlas
49 // owed is true while the latest issued uploads have not been submitted.
50 owed bool
51 issued []text.Upload
52}
53
54// NewPreparer bounds the grayscale glyph atlas. Call Prepare synchronously with
55// layout; the atlas and shaped faces are not safe for concurrent use.
56func NewPreparer(size, pages int) (*Preparer, error) {
57 a, err := text.NewAtlas(size, pages)
58 if err != nil {
59 return nil, err
60 }
61 return &Preparer{Atlas: a}, nil
62}
63
64func intersect(a, b layout.Rect) layout.Rect {
65 x, y := math.Max(a.X, b.X), math.Max(a.Y, b.Y)
66 return layout.Rect{X: x, Y: y, W: math.Max(0, math.Min(a.X+a.W, b.X+b.W)-x), H: math.Max(0, math.Min(a.Y+a.H, b.Y+b.H)-y)}
67}
68
69func physical(r layout.Rect, scale float64) layout.Rect {
70 x, y, w, h := layout.Physical(r, scale)
71 return layout.Rect{X: float64(x), Y: float64(y), W: float64(w), H: float64(h)}
72}
73
74func scaled(e layout.Edges, scale float64) layout.Edges {
75 return layout.Edges{Top: e.Top * scale, Right: e.Right * scale, Bottom: e.Bottom * scale, Left: e.Left * scale}
76}
77
78// maxQuadPrealloc bounds speculative storage for mostly clipped documents.
79// Visible frames larger than this grow normally as their quads are emitted.
80const maxQuadPrealloc = 512
81
82// quadCapacity estimates one quad per glyph and drawable command, capped so
83// offscreen content cannot force a large allocation before culling.
84func quadCapacity(commands []layout.Command) int {
85 n := 0
86 for i := range commands {
87 switch cmd := &commands[i]; cmd.Op {
88 case "clip-push", "clip-pop":
89 case "text", "preedit-text":
90 for j := range cmd.Runs {
91 n += len(cmd.Runs[j].Glyphs)
92 }
93 default:
94 n++
95 }
96 }
97 return min(n, maxQuadPrealloc)
98}
99
100// Prepare converts logical rectangles once, using layout.Physical as the sole
101// logical-to-physical edge-rounding boundary. A clip-push intersects its parent
102// clip; rounded clips intentionally use only their axis-aligned bounds in V1.
103// Clip-pop without a push and unknown operations are rejected, not dropped.
104// Image commands without a source are rejected. Failed frames do not produce
105// partial output.
106func (p *Preparer) Prepare(commands []layout.Command, scale float64, width, height int) (Frame, error) {
107 // Frames are returned detached and may outlive later Prepare calls, so
108 // the backing array is never reused.
109 return p.PrepareInto(nil, commands, scale, width, height)
110}
111
112// PrepareInto is Prepare reusing quads' backing array for the returned frame's
113// Quads. The frame aliases quads, so it is valid only until quads is reused:
114// callers that keep frames must use Prepare. Pass the last frame's Quads
115// unchanged: its length tells how much of a past peak buffer is still needed.
116func (p *Preparer) PrepareInto(quads []Quad, commands []layout.Command, scale float64, width, height int) (Frame, error) {
117 if p == nil || p.Atlas == nil || scale <= 0 || math.IsNaN(scale) || math.IsInf(scale, 0) || width <= 0 || height <= 0 {
118 return Frame{}, fmt.Errorf("render: invalid frame dimensions, scale, or atlas")
119 }
120 p.Atlas.BeginFrame()
121 // Bound the initial reservation; larger visible frames grow through
122 // append after culling.
123 n := quadCapacity(commands)
124 if cap(quads) > bufferSlack*max(n, len(quads))+64 {
125 quads = nil // do not pin a past peak frame's buffer
126 }
127 clear(quads[:cap(quads)]) // drop image and glyph references from the last frame
128 frame := Frame{Quads: slices.Grow(quads[:0], n)}
129 clip := layout.Rect{W: float64(width), H: float64(height)}
130 var stackBuf [8]layout.Rect // nested clips beyond this depth grow on the heap
131 stack := stackBuf[:0]
132 for _, cmd := range commands {
133 switch cmd.Op {
134 case "clip-push":
135 stack = append(stack, clip)
136 clip = intersect(clip, physical(cmd.Rect, scale))
137 continue
138 case "clip-pop":
139 if len(stack) == 0 {
140 return Frame{}, fmt.Errorf("render: unbalanced clip-pop")
141 }
142 clip = stack[len(stack)-1]
143 stack = stack[:len(stack)-1]
144 continue
145 case "text", "preedit-text":
146 for _, run := range cmd.Runs {
147 if run.Face == nil || run.FaceID != run.Face.ID || run.Size <= 0 {
148 return Frame{}, fmt.Errorf("render: missing or mismatched face for %q", cmd.ID)
149 }
150 for _, glyph := range run.Glyphs {
151 x, y := glyph.X*scale, glyph.Y*scale
152 key, err := text.GlyphKey(run.Face, font.GID(glyph.ID), run.Size*scale, x, run.Face.Variations)
153 if err != nil {
154 return Frame{}, err
155 }
156 place, ok := p.Atlas.Lookup(key)
157 if !ok {
158 mask, err := text.Rasterize(run.Face, key)
159 if err != nil {
160 return Frame{}, err
161 }
162 place, err = p.Atlas.Insert(key, mask)
163 if err != nil {
164 return Frame{}, err
165 }
166 }
167 if place.Page < 0 || place.Rect.Empty() {
168 continue
169 }
170 r := layout.Rect{X: float64(int(math.Floor(x)) + place.Origin.X), Y: float64(int(math.Floor(y)) + place.Origin.Y), W: float64(place.Rect.Dx()), H: float64(place.Rect.Dy())}
171 if intersect(clip, r).W > 0 && intersect(clip, r).H > 0 {
172 frame.Quads = append(frame.Quads, Quad{Op: "glyph", Rect: r, Clip: clip, Color: opacity(cmd.Color, cmd.Opacity), Glyph: place})
173 }
174 }
175 }
176 continue
177 case "image":
178 if cmd.Image == nil {
179 return Frame{}, fmt.Errorf("render: image %q has no pixel source", cmd.ID)
180 }
181 case "rect", "border", "outline", "shadow", "selection", "caret", "preedit-underline", "preedit-caret":
182 default:
183 return Frame{}, fmt.Errorf("render: unknown display operation %q", cmd.Op)
184 }
185 r := physical(cmd.Rect, scale)
186 shape := r
187 var shadow [4]float64
188 inset := false
189 if cmd.Op == "outline" {
190 // Layout already applies outline-offset to Rect; width grows outward.
191 w := cmd.Widths.Top * scale
192 r = layout.Rect{X: r.X - w, Y: r.Y - w, W: r.W + 2*w, H: r.H + 2*w}
193 }
194 if cmd.Op == "shadow" {
195 if cmd.Shadow == nil {
196 return Frame{}, fmt.Errorf("render: shadow %q has no parameters", cmd.ID)
197 }
198 sh := cmd.Shadow
199 shadow = [4]float64{sh.X.Value * scale, sh.Y.Value * scale, sh.Blur.Value * scale, sh.Spread.Value * scale}
200 inset = sh.Inset
201 if !inset {
202 // Three standard deviations cover the visible Gaussian tail.
203 pad := shadow[2]*1.5 + math.Max(0, shadow[3])
204 r = layout.Rect{X: r.X + shadow[0] - pad, Y: r.Y + shadow[1] - pad, W: r.W + 2*pad, H: r.H + 2*pad}
205 }
206 }
207 if intersect(clip, r).W == 0 || intersect(clip, r).H == 0 {
208 continue
209 }
210 color := cmd.Color
211 if cmd.Op == "shadow" {
212 color = cmd.Shadow.Color
213 }
214 q := Quad{Op: cmd.Op, Rect: r, Clip: clip, Color: opacity(color, cmd.Opacity), Widths: scaled(cmd.Widths, scale), Colors: cmd.Colors, Shape: shape, Shadow: shadow, Inset: inset, Image: cmd.Image}
215 if cmd.Op == "image" {
216 q.Color = css.Color{R: 1, G: 1, B: 1, A: opacity(css.Color{A: 1}, cmd.Opacity).A}
217 }
218 for i, v := range cmd.Radii {
219 q.Radii[i] = v * scale
220 }
221 frame.Quads = append(frame.Quads, q)
222 }
223 if len(stack) > 0 {
224 return Frame{}, fmt.Errorf("render: unbalanced clip-push")
225 }
226 if p.owed {
227 // The previous frame's uploads may never have reached the GPU. Full pages
228 // subsume them and any changes made since.
229 p.Atlas.MarkAllDirty()
230 }
231 frame.Uploads = p.Atlas.Uploads()
232 p.issued, p.owed = frame.Uploads, len(frame.Uploads) > 0
233 return frame, nil
234}
235
236// Submitted acknowledges that uploads, as returned in the most recent Prepare's
237// Frame.Uploads, were recorded in a successfully submitted GPU submission.
238// Any other slice (an older, superseded frame or foreign data) is ignored and
239// the latest frame's uploads stay owed. Call it on the Prepare owner's loop.
240func (p *Preparer) Submitted(uploads []text.Upload) {
241 if p == nil || len(uploads) == 0 || len(uploads) != len(p.issued) || &uploads[0] != &p.issued[0] {
242 return
243 }
244 p.owed, p.issued = false, nil
245}
246
247func opacity(c css.Color, a float64) css.Color {
248 c.A *= math.Max(0, math.Min(1, a))
249 return c
250}