tess.mx raw
1 // SPDX-License-Identifier: Unlicense OR MIT
2
3 // Tessellation cache and worker for the Gio GPU pipeline.
4 // Phase 7: separates the tessellation step (compute vertex bytes from path
5 // scene commands) from the GPU-upload step (create immutable buffer from bytes).
6 //
7 // Three fallback tiers:
8 // 1. SAB + COOP/COEP headers: full spawn, zero-copy channels (not yet wired).
9 // 2. Workers without SAB: spawn + postMessage (not yet wired).
10 // 3. Sync fallback (//go:build moxie_sync_tess, or WASM without SAB): inline.
11 //
12 // The tessGenCache keyed by path hash enables cache hits across frames even
13 // when the path appears at a different position in the ops list.
14
15 package gio
16
17 import (
18 "encoding/binary"
19 "io"
20 "math"
21 "moxie"
22 )
23
24 // PathKey identifies a tessellation entry.
25 // Hash covers raw scene-command bytes + affine transform + stroke params.
26 type PathKey struct {
27 Hash uint64 // FNV-1a of (pathData bytes || transform floats || stroke params)
28 Outline bool
29 Stroke bool
30 Width float32
31 }
32
33 func (k PathKey) EncodeTo(w io.Writer) error {
34 var buf [18]byte
35 binary.LittleEndian().PutUint64(buf[0:8], k.Hash)
36 b := uint8(0)
37 if k.Outline {
38 b |= 1
39 }
40 if k.Stroke {
41 b |= 2
42 }
43 buf[8] = b
44 binary.LittleEndian().PutUint32(buf[9:13], math.Float32bits(k.Width))
45 _, err := w.Write(buf[:13])
46 return err
47 }
48
49 func (k *PathKey) DecodeFrom(r io.Reader) error {
50 var buf [13]byte
51 if _, err := io.ReadFull(r, buf[:]); err != nil {
52 return err
53 }
54 k.Hash = binary.LittleEndian().Uint64(buf[0:8])
55 b := buf[8]
56 k.Outline = b&1 != 0
57 k.Stroke = b&2 != 0
58 k.Width = math.Float32frombits(binary.LittleEndian().Uint32(buf[9:13]))
59 return nil
60 }
61
62 // TessRequest asks the tessellation worker to tessellate a path.
63 type TessRequest struct {
64 Key PathKey
65 Gen uint32
66 PathData moxie.Bytes // raw scene commands
67 }
68
69 func (r TessRequest) EncodeTo(w io.Writer) error {
70 if err := r.Key.EncodeTo(w); err != nil {
71 return err
72 }
73 if err := moxie.Uint32(r.Gen).EncodeTo(w); err != nil {
74 return err
75 }
76 return r.PathData.EncodeTo(w)
77 }
78
79 func (r *TessRequest) DecodeFrom(rd io.Reader) error {
80 if err := r.Key.DecodeFrom(rd); err != nil {
81 return err
82 }
83 var gen moxie.Uint32
84 if err := gen.DecodeFrom(rd); err != nil {
85 return err
86 }
87 r.Gen = uint32(gen)
88 return r.PathData.DecodeFrom(rd)
89 }
90
91 // TessResult carries tessellated vertex data back to the main domain.
92 type TessResult struct {
93 Key PathKey
94 Gen uint32
95 Verts moxie.Bytes // raw vertex bytes (vertStride * N)
96 BoundsMinX float32
97 BoundsMinY float32
98 BoundsMaxX float32
99 BoundsMaxY float32
100 }
101
102 func (r TessResult) EncodeTo(w io.Writer) error {
103 if err := r.Key.EncodeTo(w); err != nil {
104 return err
105 }
106 if err := moxie.Uint32(r.Gen).EncodeTo(w); err != nil {
107 return err
108 }
109 if err := r.Verts.EncodeTo(w); err != nil {
110 return err
111 }
112 var bounds [16]byte
113 binary.LittleEndian().PutUint32(bounds[0:4], math.Float32bits(r.BoundsMinX))
114 binary.LittleEndian().PutUint32(bounds[4:8], math.Float32bits(r.BoundsMinY))
115 binary.LittleEndian().PutUint32(bounds[8:12], math.Float32bits(r.BoundsMaxX))
116 binary.LittleEndian().PutUint32(bounds[12:16], math.Float32bits(r.BoundsMaxY))
117 _, err := w.Write(bounds[:])
118 return err
119 }
120
121 func (r *TessResult) DecodeFrom(rd io.Reader) error {
122 if err := r.Key.DecodeFrom(rd); err != nil {
123 return err
124 }
125 var gen moxie.Uint32
126 if err := gen.DecodeFrom(rd); err != nil {
127 return err
128 }
129 r.Gen = uint32(gen)
130 if err := r.Verts.DecodeFrom(rd); err != nil {
131 return err
132 }
133 var bounds [16]byte
134 if _, err := io.ReadFull(rd, bounds[:]); err != nil {
135 return err
136 }
137 r.BoundsMinX = math.Float32frombits(binary.LittleEndian().Uint32(bounds[0:4]))
138 r.BoundsMinY = math.Float32frombits(binary.LittleEndian().Uint32(bounds[4:8]))
139 r.BoundsMaxX = math.Float32frombits(binary.LittleEndian().Uint32(bounds[8:12]))
140 r.BoundsMaxY = math.Float32frombits(binary.LittleEndian().Uint32(bounds[12:16]))
141 return nil
142 }
143
144 // tessEntry holds cached tessellation output for one path.
145 type tessEntry struct {
146 key PathKey
147 verts []byte
148 bounds Rectangle
149 lastGen uint32
150 }
151
152 // tessGenCache maps path-hash to tessEntry. Evicts entries unused for
153 // more than tessEvictAge frames (120 ≈ 2s at 60fps).
154 type tessGenCache struct {
155 entries map[uint64]*tessEntry
156 }
157
158 const tessEvictAge = 120
159
160 func newTessGenCache() tessGenCache {
161 return tessGenCache{entries: map[uint64]*tessEntry{}}
162 }
163
164 func (c *tessGenCache) get(key PathKey, gen uint32) (*tessEntry, bool) {
165 e, ok := c.entries[key.Hash]
166 if ok {
167 e.lastGen = gen // mark as used this frame
168 }
169 return e, ok
170 }
171
172 func (c *tessGenCache) put(e *tessEntry) {
173 c.entries[e.key.Hash] = e
174 }
175
176 func (c *tessGenCache) evict(beforeGen uint32) {
177 for h, e := range c.entries {
178 if e.lastGen < beforeGen {
179 delete(c.entries, h)
180 }
181 }
182 }
183
184 // hashPathData computes a FNV-1a hash of path bytes + transform + stroke params.
185 func hashPathData(data []byte, tr Affine2D, outline bool, strWidth float32) uint64 {
186 const (
187 offset64 = 14695981039346656037
188 prime64 = 1099511628211
189 )
190 h := uint64(offset64)
191 for _, b := range data {
192 h ^= uint64(b)
193 h *= prime64
194 }
195 // mix transform
196 sx, hx, _, hy, sy, _ := tr.Elems()
197 for _, f := range []float32{sx, hx, hy, sy} {
198 bits := math.Float32bits(f)
199 h ^= uint64(bits)
200 h *= prime64
201 }
202 // mix stroke
203 if outline {
204 h ^= 0xdead
205 h *= prime64
206 }
207 bits := math.Float32bits(strWidth)
208 h ^= uint64(bits)
209 h *= prime64
210 return h
211 }
212
213 // isSimple reports whether a path is simple enough to tessellate inline.
214 // Simple: ≤8 line/gap commands, no curves (no Quad/Cubic), no stroke.
215 func isSimple(pathData []byte, strWidth float32) bool {
216 if strWidth > 0 {
217 return false // stroked paths always go to worker
218 }
219 const cmdSize = sceneElemSize + 4
220 if len(pathData)%cmdSize != 0 {
221 return false
222 }
223 n := len(pathData) / cmdSize
224 if n > 8 {
225 return false
226 }
227 for i := 0; i < n; i++ {
228 cmd := DecodeCommand(pathData[i*cmdSize+4:])
229 switch cmd.Op() {
230 case OpLine, OpGap:
231 // ok
232 default:
233 return false // any curve → not simple
234 }
235 }
236 return true
237 }
238
239 // tessellateSync performs synchronous tessellation without spawning a worker.
240 // Returns raw vertex bytes and path-local bounds.
241 func tessellateSync(pathData []byte, tr Affine2D, outline bool, strWidth float32) (verts []byte, bounds Rectangle) {
242 var d drawOps
243 return d.buildVerts(pathData, tr, outline, strWidth)
244 }
245
246 // tessWorker is the spawn worker function.
247 // It receives TessRequests, tessellates each path, and sends TessResults.
248 // The transform is embedded in PathKey.Hash (included when hashing).
249 // NOTE: the worker receives pre-encoded path bytes; it does NOT have access
250 // to the Affine2D. For the untransformed approach, pathData would be stored
251 // without transform. For the current MVP, transform is pre-applied before
252 // sending the request, so pathVerts already contains transformed geometry.
253 func tessWorker(reqCh chan TessRequest, resCh chan TessResult) {
254 for {
255 req := <-reqCh
256 verts, bnd := tessWorkerProcess(req)
257 resCh <- TessResult{
258 Key: req.Key,
259 Gen: req.Gen,
260 Verts: moxie.Bytes(verts),
261 BoundsMinX: bnd.Min.X,
262 BoundsMinY: bnd.Min.Y,
263 BoundsMaxX: bnd.Max.X,
264 BoundsMaxY: bnd.Max.Y,
265 }
266 }
267 }
268
269 // tessWorkerProcess tessellates one request. Called in the worker domain.
270 // Since transform is pre-applied (MVP), we pass identity transform.
271 func tessWorkerProcess(req TessRequest) (verts []byte, bounds Rectangle) {
272 return tessellateSync([]byte(req.PathData), AffineId(), req.Key.Outline, req.Key.Width)
273 }
274