// SPDX-License-Identifier: Unlicense OR MIT // Tessellation cache and worker for the Gio GPU pipeline. // Phase 7: separates the tessellation step (compute vertex bytes from path // scene commands) from the GPU-upload step (create immutable buffer from bytes). // // Three fallback tiers: // 1. SAB + COOP/COEP headers: full spawn, zero-copy channels (not yet wired). // 2. Workers without SAB: spawn + postMessage (not yet wired). // 3. Sync fallback (//go:build moxie_sync_tess, or WASM without SAB): inline. // // The tessGenCache keyed by path hash enables cache hits across frames even // when the path appears at a different position in the ops list. package gio import ( "encoding/binary" "io" "math" "moxie" ) // PathKey identifies a tessellation entry. // Hash covers raw scene-command bytes + affine transform + stroke params. type PathKey struct { Hash uint64 // FNV-1a of (pathData bytes || transform floats || stroke params) Outline bool Stroke bool Width float32 } func (k PathKey) EncodeTo(w io.Writer) error { var buf [18]byte binary.LittleEndian().PutUint64(buf[0:8], k.Hash) b := uint8(0) if k.Outline { b |= 1 } if k.Stroke { b |= 2 } buf[8] = b binary.LittleEndian().PutUint32(buf[9:13], math.Float32bits(k.Width)) _, err := w.Write(buf[:13]) return err } func (k *PathKey) DecodeFrom(r io.Reader) error { var buf [13]byte if _, err := io.ReadFull(r, buf[:]); err != nil { return err } k.Hash = binary.LittleEndian().Uint64(buf[0:8]) b := buf[8] k.Outline = b&1 != 0 k.Stroke = b&2 != 0 k.Width = math.Float32frombits(binary.LittleEndian().Uint32(buf[9:13])) return nil } // TessRequest asks the tessellation worker to tessellate a path. type TessRequest struct { Key PathKey Gen uint32 PathData moxie.Bytes // raw scene commands } func (r TessRequest) EncodeTo(w io.Writer) error { if err := r.Key.EncodeTo(w); err != nil { return err } if err := moxie.Uint32(r.Gen).EncodeTo(w); err != nil { return err } return r.PathData.EncodeTo(w) } func (r *TessRequest) DecodeFrom(rd io.Reader) error { if err := r.Key.DecodeFrom(rd); err != nil { return err } var gen moxie.Uint32 if err := gen.DecodeFrom(rd); err != nil { return err } r.Gen = uint32(gen) return r.PathData.DecodeFrom(rd) } // TessResult carries tessellated vertex data back to the main domain. type TessResult struct { Key PathKey Gen uint32 Verts moxie.Bytes // raw vertex bytes (vertStride * N) BoundsMinX float32 BoundsMinY float32 BoundsMaxX float32 BoundsMaxY float32 } func (r TessResult) EncodeTo(w io.Writer) error { if err := r.Key.EncodeTo(w); err != nil { return err } if err := moxie.Uint32(r.Gen).EncodeTo(w); err != nil { return err } if err := r.Verts.EncodeTo(w); err != nil { return err } var bounds [16]byte binary.LittleEndian().PutUint32(bounds[0:4], math.Float32bits(r.BoundsMinX)) binary.LittleEndian().PutUint32(bounds[4:8], math.Float32bits(r.BoundsMinY)) binary.LittleEndian().PutUint32(bounds[8:12], math.Float32bits(r.BoundsMaxX)) binary.LittleEndian().PutUint32(bounds[12:16], math.Float32bits(r.BoundsMaxY)) _, err := w.Write(bounds[:]) return err } func (r *TessResult) DecodeFrom(rd io.Reader) error { if err := r.Key.DecodeFrom(rd); err != nil { return err } var gen moxie.Uint32 if err := gen.DecodeFrom(rd); err != nil { return err } r.Gen = uint32(gen) if err := r.Verts.DecodeFrom(rd); err != nil { return err } var bounds [16]byte if _, err := io.ReadFull(rd, bounds[:]); err != nil { return err } r.BoundsMinX = math.Float32frombits(binary.LittleEndian().Uint32(bounds[0:4])) r.BoundsMinY = math.Float32frombits(binary.LittleEndian().Uint32(bounds[4:8])) r.BoundsMaxX = math.Float32frombits(binary.LittleEndian().Uint32(bounds[8:12])) r.BoundsMaxY = math.Float32frombits(binary.LittleEndian().Uint32(bounds[12:16])) return nil } // tessEntry holds cached tessellation output for one path. type tessEntry struct { key PathKey verts []byte bounds Rectangle lastGen uint32 } // tessGenCache maps path-hash to tessEntry. Evicts entries unused for // more than tessEvictAge frames (120 ≈ 2s at 60fps). type tessGenCache struct { entries map[uint64]*tessEntry } const tessEvictAge = 120 func newTessGenCache() tessGenCache { return tessGenCache{entries: map[uint64]*tessEntry{}} } func (c *tessGenCache) get(key PathKey, gen uint32) (*tessEntry, bool) { e, ok := c.entries[key.Hash] if ok { e.lastGen = gen // mark as used this frame } return e, ok } func (c *tessGenCache) put(e *tessEntry) { c.entries[e.key.Hash] = e } func (c *tessGenCache) evict(beforeGen uint32) { for h, e := range c.entries { if e.lastGen < beforeGen { delete(c.entries, h) } } } // hashPathData computes a FNV-1a hash of path bytes + transform + stroke params. func hashPathData(data []byte, tr Affine2D, outline bool, strWidth float32) uint64 { const ( offset64 = 14695981039346656037 prime64 = 1099511628211 ) h := uint64(offset64) for _, b := range data { h ^= uint64(b) h *= prime64 } // mix transform sx, hx, _, hy, sy, _ := tr.Elems() for _, f := range []float32{sx, hx, hy, sy} { bits := math.Float32bits(f) h ^= uint64(bits) h *= prime64 } // mix stroke if outline { h ^= 0xdead h *= prime64 } bits := math.Float32bits(strWidth) h ^= uint64(bits) h *= prime64 return h } // isSimple reports whether a path is simple enough to tessellate inline. // Simple: ≤8 line/gap commands, no curves (no Quad/Cubic), no stroke. func isSimple(pathData []byte, strWidth float32) bool { if strWidth > 0 { return false // stroked paths always go to worker } const cmdSize = sceneElemSize + 4 if len(pathData)%cmdSize != 0 { return false } n := len(pathData) / cmdSize if n > 8 { return false } for i := 0; i < n; i++ { cmd := DecodeCommand(pathData[i*cmdSize+4:]) switch cmd.Op() { case OpLine, OpGap: // ok default: return false // any curve → not simple } } return true } // tessellateSync performs synchronous tessellation without spawning a worker. // Returns raw vertex bytes and path-local bounds. func tessellateSync(pathData []byte, tr Affine2D, outline bool, strWidth float32) (verts []byte, bounds Rectangle) { var d drawOps return d.buildVerts(pathData, tr, outline, strWidth) } // tessWorker is the spawn worker function. // It receives TessRequests, tessellates each path, and sends TessResults. // The transform is embedded in PathKey.Hash (included when hashing). // NOTE: the worker receives pre-encoded path bytes; it does NOT have access // to the Affine2D. For the untransformed approach, pathData would be stored // without transform. For the current MVP, transform is pre-applied before // sending the request, so pathVerts already contains transformed geometry. func tessWorker(reqCh chan TessRequest, resCh chan TessResult) { for { req := <-reqCh verts, bnd := tessWorkerProcess(req) resCh <- TessResult{ Key: req.Key, Gen: req.Gen, Verts: moxie.Bytes(verts), BoundsMinX: bnd.Min.X, BoundsMinY: bnd.Min.Y, BoundsMaxX: bnd.Max.X, BoundsMaxY: bnd.Max.Y, } } } // tessWorkerProcess tessellates one request. Called in the worker domain. // Since transform is pre-applied (MVP), we pass identity transform. func tessWorkerProcess(req TessRequest) (verts []byte, bounds Rectangle) { return tessellateSync([]byte(req.PathData), AffineId(), req.Key.Outline, req.Key.Width) }