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