@codexo/exojs 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/esm/audio/AudioAnalyser.d.ts +36 -0
  3. package/dist/esm/audio/AudioAnalyser.js +148 -0
  4. package/dist/esm/audio/AudioAnalyser.js.map +1 -1
  5. package/dist/esm/audio/BeatDetector.d.ts +62 -0
  6. package/dist/esm/audio/BeatDetector.js +77 -0
  7. package/dist/esm/audio/BeatDetector.js.map +1 -1
  8. package/dist/esm/audio/dsp/mel.js +70 -0
  9. package/dist/esm/audio/dsp/mel.js.map +1 -0
  10. package/dist/esm/debug/RenderPassInspectorLayer.d.ts +71 -0
  11. package/dist/esm/debug/RenderPassInspectorLayer.js +201 -0
  12. package/dist/esm/debug/RenderPassInspectorLayer.js.map +1 -0
  13. package/dist/esm/debug/index.d.ts +1 -0
  14. package/dist/esm/debug/index.js +1 -0
  15. package/dist/esm/debug/index.js.map +1 -1
  16. package/dist/esm/index.js +2 -0
  17. package/dist/esm/index.js.map +1 -1
  18. package/dist/esm/rendering/filters/WebGpuShaderFilter.js +5 -1
  19. package/dist/esm/rendering/filters/WebGpuShaderFilter.js.map +1 -1
  20. package/dist/esm/rendering/index.d.ts +2 -0
  21. package/dist/esm/rendering/mesh/Mesh.d.ts +4 -47
  22. package/dist/esm/rendering/mesh/Mesh.js.map +1 -1
  23. package/dist/esm/rendering/mesh/MeshShader.d.ts +183 -0
  24. package/dist/esm/rendering/mesh/MeshShader.js +231 -0
  25. package/dist/esm/rendering/mesh/MeshShader.js.map +1 -0
  26. package/dist/esm/rendering/texture/DataTexture.d.ts +115 -0
  27. package/dist/esm/rendering/texture/DataTexture.js +173 -0
  28. package/dist/esm/rendering/texture/DataTexture.js.map +1 -0
  29. package/dist/esm/rendering/webgl2/WebGl2Backend.js +42 -1
  30. package/dist/esm/rendering/webgl2/WebGl2Backend.js.map +1 -1
  31. package/dist/esm/rendering/webgl2/WebGl2MeshRenderer.js +12 -1
  32. package/dist/esm/rendering/webgl2/WebGl2MeshRenderer.js.map +1 -1
  33. package/dist/esm/rendering/webgpu/WebGpuBackend.d.ts +1 -0
  34. package/dist/esm/rendering/webgpu/WebGpuBackend.js +60 -7
  35. package/dist/esm/rendering/webgpu/WebGpuBackend.js.map +1 -1
  36. package/dist/esm/rendering/webgpu/WebGpuMaskCompositor.js +2 -1
  37. package/dist/esm/rendering/webgpu/WebGpuMaskCompositor.js.map +1 -1
  38. package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.d.ts +13 -0
  39. package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.js +636 -83
  40. package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.js.map +1 -1
  41. package/dist/exo.esm.js +1452 -102
  42. package/dist/exo.esm.js.map +1 -1
  43. package/package.json +1 -1
package/dist/exo.esm.js CHANGED
@@ -1415,6 +1415,74 @@ const getOfflineAudioContext = () => getOrCreateOfflineAudioContext();
1415
1415
  */
1416
1416
  const decodeAudioData = async (arrayBuffer) => getOrCreateOfflineAudioContext().decodeAudioData(arrayBuffer);
1417
1417
 
1418
+ /**
1419
+ * Mel-scale filterbank generator.
1420
+ *
1421
+ * Generates `numBands` triangular filters log-spaced from `fMin` to `fMax` Hz
1422
+ * on the mel scale. Each filter is a typed array of weights for the FFT
1423
+ * magnitude bins.
1424
+ *
1425
+ * These helpers are also inlined inside the beat-detector worklet source
1426
+ * string (worklets cannot import modules).
1427
+ */
1428
+ /** Convert Hz to mel. */
1429
+ function hzToMel(hz) {
1430
+ return 2595 * Math.log10(1 + hz / 700);
1431
+ }
1432
+ /** Convert mel to Hz. */
1433
+ function melToHz(mel) {
1434
+ return 700 * (Math.pow(10, mel / 2595) - 1);
1435
+ }
1436
+ /**
1437
+ * Build a mel filterbank.
1438
+ *
1439
+ * @param numBands Number of mel bands (default 24).
1440
+ * @param fMin Lowest frequency in Hz (default 80).
1441
+ * @param fMax Highest frequency in Hz (default 8000).
1442
+ * @param fftSize Number of FFT points (e.g., 2048).
1443
+ * @param sampleRate Audio sample rate in Hz (e.g., 48000).
1444
+ */
1445
+ function buildMelFilterbank(bandCount, fMin, fMax, fftSize, sampleRate) {
1446
+ const binCount = fftSize >> 1; // positive frequencies
1447
+ const nyquist = sampleRate / 2;
1448
+ // Equally-spaced mel points: bandCount+2 points spanning fMin..fMax
1449
+ const melMin = hzToMel(fMin);
1450
+ const melMax = hzToMel(fMax);
1451
+ const melPoints = new Float32Array(bandCount + 2);
1452
+ for (let i = 0; i < bandCount + 2; i++) {
1453
+ melPoints[i] = melMin + ((melMax - melMin) * i) / (bandCount + 1);
1454
+ }
1455
+ // Convert mel points to FFT bin indices
1456
+ const binPoints = new Float32Array(bandCount + 2);
1457
+ for (let i = 0; i < bandCount + 2; i++) {
1458
+ const hz = melToHz(melPoints[i]);
1459
+ binPoints[i] = Math.round((hz / nyquist) * (binCount - 1));
1460
+ }
1461
+ const bands = [];
1462
+ for (let b = 0; b < bandCount; b++) {
1463
+ const startBin = Math.max(0, Math.min(binCount - 1, binPoints[b]));
1464
+ const peakBin = Math.max(0, Math.min(binCount - 1, binPoints[b + 1]));
1465
+ const endBin = Math.max(0, Math.min(binCount - 1, binPoints[b + 2]));
1466
+ const len = endBin - startBin + 1;
1467
+ const weights = new Float32Array(len);
1468
+ for (let i = 0; i < len; i++) {
1469
+ const bin = startBin + i;
1470
+ if (bin <= peakBin && peakBin > startBin) {
1471
+ weights[i] = (bin - startBin) / (peakBin - startBin);
1472
+ }
1473
+ else if (bin > peakBin && endBin > peakBin) {
1474
+ weights[i] = (endBin - bin) / (endBin - peakBin);
1475
+ }
1476
+ else {
1477
+ // Degenerate: startBin === peakBin === endBin
1478
+ weights[i] = 1;
1479
+ }
1480
+ }
1481
+ bands.push({ startBin, peakBin, endBin, weights });
1482
+ }
1483
+ return bands;
1484
+ }
1485
+
1418
1486
  /**
1419
1487
  * Lightweight visualisation analyser backed by a Web Audio AnalyserNode.
1420
1488
  *
@@ -1435,6 +1503,10 @@ class AudioAnalyser {
1435
1503
  _floatSpectrum;
1436
1504
  _byteWaveform;
1437
1505
  _floatWaveform;
1506
+ // Cached mel/log filterbanks. Key: `${bands}|${fMin}|${fMax}|${fftSize}`.
1507
+ // Filterbank construction is non-trivial; caching avoids per-frame allocation.
1508
+ _melCache = new Map();
1509
+ _logCache = new Map();
1438
1510
  /**
1439
1511
  * Create an `AudioAnalyser` with the given options. The underlying
1440
1512
  * `AnalyserNode` is created immediately if the `AudioContext` is already
@@ -1446,6 +1518,7 @@ class AudioAnalyser {
1446
1518
  smoothingTimeConstant: options?.smoothingTimeConstant ?? 0.8,
1447
1519
  minDecibels: options?.minDecibels ?? -100,
1448
1520
  maxDecibels: options?.maxDecibels ?? -30,
1521
+ source: options?.source ?? null,
1449
1522
  };
1450
1523
  const binCount = this._options.fftSize >> 1;
1451
1524
  this._byteSpectrum = new Uint8Array(binCount);
@@ -1458,6 +1531,9 @@ class AudioAnalyser {
1458
1531
  else {
1459
1532
  onAudioContextReady.once(this._setupAnalyser, this);
1460
1533
  }
1534
+ if (options?.source !== undefined && options.source !== null) {
1535
+ this.source = options.source;
1536
+ }
1461
1537
  }
1462
1538
  // -----------------------------------------------------------------------
1463
1539
  // Source setter
@@ -1613,6 +1689,81 @@ class AudioAnalyser {
1613
1689
  return this.getBandEnergy(0, nyquist);
1614
1690
  }
1615
1691
  // -----------------------------------------------------------------------
1692
+ // Spectrum mapping (mel / log scaling)
1693
+ //
1694
+ // The raw FFT bins from AnalyserNode are linearly distributed across
1695
+ // 0..nyquist, which gives bass a few bins and treble hundreds. For
1696
+ // visualisations you typically want perceptually-weighted spacing.
1697
+ //
1698
+ // Both `getSpectrumMel` and `getSpectrumLog` operate on the byte-domain
1699
+ // spectrum (0..255) and produce byte output by default. The float
1700
+ // variants operate on dBFS values and produce float output.
1701
+ //
1702
+ // Filterbanks are cached on the analyser; they only rebuild when fftSize
1703
+ // or the (bands, fMin, fMax) parameters change.
1704
+ // -----------------------------------------------------------------------
1705
+ /**
1706
+ * Mel-scaled spectrum, byte domain (0..255). Output length = `bands`.
1707
+ * Each output bin is a triangular-weighted sum of the linear FFT bins
1708
+ * underneath it, mel-spaced from `fMin` to `fMax`.
1709
+ */
1710
+ getSpectrumMel(into, options) {
1711
+ const bandCount = options?.bands ?? 32;
1712
+ const out = into ?? new Uint8Array(bandCount);
1713
+ const filterbank = this._getMelFilterbank(bandCount, options?.fMin ?? 20, options?.fMax ?? 20000);
1714
+ if (!this._analyser || filterbank === null) {
1715
+ out.fill(0);
1716
+ return out;
1717
+ }
1718
+ this._analyser.getByteFrequencyData(this._byteSpectrum);
1719
+ applyFilterbank(this._byteSpectrum, filterbank, out);
1720
+ return out;
1721
+ }
1722
+ /** Mel-scaled spectrum, float domain (dBFS values). Output length = `bands`. */
1723
+ getSpectrumMelFloat(into, options) {
1724
+ const bandCount = options?.bands ?? 32;
1725
+ const out = into ?? new Float32Array(bandCount);
1726
+ const filterbank = this._getMelFilterbank(bandCount, options?.fMin ?? 20, options?.fMax ?? 20000);
1727
+ if (!this._analyser || filterbank === null) {
1728
+ out.fill(0);
1729
+ return out;
1730
+ }
1731
+ this._analyser.getFloatFrequencyData(this._floatSpectrum);
1732
+ applyFilterbank(this._floatSpectrum, filterbank, out);
1733
+ return out;
1734
+ }
1735
+ /**
1736
+ * Log-scaled spectrum, byte domain (0..255). Output length = `bands`.
1737
+ * Bins are spaced so each output covers an equal fraction of the
1738
+ * `log2(fMax / fMin)` octave range — useful when you want a
1739
+ * frequency-axis visualization where each octave gets the same width.
1740
+ */
1741
+ getSpectrumLog(into, options) {
1742
+ const bandCount = options?.bands ?? 32;
1743
+ const out = into ?? new Uint8Array(bandCount);
1744
+ const ranges = this._getLogRanges(bandCount, options?.fMin ?? 20, options?.fMax ?? 20000);
1745
+ if (!this._analyser || ranges === null) {
1746
+ out.fill(0);
1747
+ return out;
1748
+ }
1749
+ this._analyser.getByteFrequencyData(this._byteSpectrum);
1750
+ applyLogRanges(this._byteSpectrum, ranges, out);
1751
+ return out;
1752
+ }
1753
+ /** Log-scaled spectrum, float domain (dBFS values). Output length = `bands`. */
1754
+ getSpectrumLogFloat(into, options) {
1755
+ const bandCount = options?.bands ?? 32;
1756
+ const out = into ?? new Float32Array(bandCount);
1757
+ const ranges = this._getLogRanges(bandCount, options?.fMin ?? 20, options?.fMax ?? 20000);
1758
+ if (!this._analyser || ranges === null) {
1759
+ out.fill(0);
1760
+ return out;
1761
+ }
1762
+ this._analyser.getFloatFrequencyData(this._floatSpectrum);
1763
+ applyLogRanges(this._floatSpectrum, ranges, out);
1764
+ return out;
1765
+ }
1766
+ // -----------------------------------------------------------------------
1616
1767
  // Lifecycle
1617
1768
  // -----------------------------------------------------------------------
1618
1769
  /**
@@ -1630,6 +1781,34 @@ class AudioAnalyser {
1630
1781
  // -----------------------------------------------------------------------
1631
1782
  // Private helpers
1632
1783
  // -----------------------------------------------------------------------
1784
+ _getMelFilterbank(bands, fMin, fMax) {
1785
+ if (!this._analyser)
1786
+ return null;
1787
+ const ctx = getAudioContext();
1788
+ const fftSize = this._options.fftSize;
1789
+ const clampedFmax = Math.min(fMax, ctx.sampleRate / 2);
1790
+ const key = `${bands}|${fMin}|${clampedFmax}|${fftSize}`;
1791
+ let cached = this._melCache.get(key);
1792
+ if (cached === undefined) {
1793
+ cached = buildMelFilterbank(bands, fMin, clampedFmax, fftSize, ctx.sampleRate);
1794
+ this._melCache.set(key, cached);
1795
+ }
1796
+ return cached;
1797
+ }
1798
+ _getLogRanges(bands, fMin, fMax) {
1799
+ if (!this._analyser)
1800
+ return null;
1801
+ const ctx = getAudioContext();
1802
+ const fftSize = this._options.fftSize;
1803
+ const clampedFmax = Math.min(fMax, ctx.sampleRate / 2);
1804
+ const key = `${bands}|${fMin}|${clampedFmax}|${fftSize}`;
1805
+ let cached = this._logCache.get(key);
1806
+ if (cached === undefined) {
1807
+ cached = buildLogRanges(bands, fMin, clampedFmax, fftSize, ctx.sampleRate);
1808
+ this._logCache.set(key, cached);
1809
+ }
1810
+ return cached;
1811
+ }
1633
1812
  _setupAnalyser(audioContext) {
1634
1813
  const node = audioContext.createAnalyser();
1635
1814
  node.fftSize = this._options.fftSize;
@@ -1720,6 +1899,42 @@ class AudioAnalyser {
1720
1899
  }
1721
1900
  }
1722
1901
  }
1902
+ function buildLogRanges(bandCount, fMin, fMax, fftSize, sampleRate) {
1903
+ const binCount = fftSize >> 1;
1904
+ const nyquist = sampleRate / 2;
1905
+ const logMin = Math.log(fMin);
1906
+ const logMax = Math.log(fMax);
1907
+ const ranges = [];
1908
+ for (let b = 0; b < bandCount; b++) {
1909
+ const lowHz = Math.exp(logMin + ((logMax - logMin) * b) / bandCount);
1910
+ const highHz = Math.exp(logMin + ((logMax - logMin) * (b + 1)) / bandCount);
1911
+ const startBin = Math.max(0, Math.min(binCount - 1, Math.floor((lowHz / nyquist) * binCount)));
1912
+ const endBin = Math.max(startBin, Math.min(binCount - 1, Math.ceil((highHz / nyquist) * binCount) - 1));
1913
+ ranges.push({ startBin, endBin });
1914
+ }
1915
+ return ranges;
1916
+ }
1917
+ function applyFilterbank(spectrum, bands, out) {
1918
+ for (let b = 0; b < bands.length; b++) {
1919
+ const { startBin, weights } = bands[b];
1920
+ let sum = 0;
1921
+ for (let i = 0; i < weights.length; i++) {
1922
+ sum += spectrum[startBin + i] * weights[i];
1923
+ }
1924
+ out[b] = sum;
1925
+ }
1926
+ }
1927
+ function applyLogRanges(spectrum, ranges, out) {
1928
+ for (let b = 0; b < ranges.length; b++) {
1929
+ const { startBin, endBin } = ranges[b];
1930
+ let sum = 0;
1931
+ const count = endBin - startBin + 1;
1932
+ for (let i = startBin; i <= endBin; i++) {
1933
+ sum += spectrum[i];
1934
+ }
1935
+ out[b] = sum / count;
1936
+ }
1937
+ }
1723
1938
 
1724
1939
  /** τ = 2π, the full-circle radian constant. */
1725
1940
  const tau$2 = Math.PI * 2;
@@ -4504,6 +4719,15 @@ class BeatDetector {
4504
4719
  _timeSignature = { numerator: 4, denominator: 4 };
4505
4720
  _nextDownbeatTime = 0;
4506
4721
  _lookahead = Object.freeze([]);
4722
+ /**
4723
+ * Half-life in seconds for the {@link pulse} envelope. Mutable; default 0.15.
4724
+ * Smaller values give a snappier pulse, larger values a longer afterglow.
4725
+ */
4726
+ pulseHalfLife;
4727
+ /** Half-life for the {@link barPulse} envelope. Mutable; default 0.3. */
4728
+ barPulseHalfLife;
4729
+ /** Time window for {@link justBeat}. Mutable; default 0.03 (30ms). */
4730
+ justBeatWindow;
4507
4731
  constructor(options) {
4508
4732
  this._options = {
4509
4733
  minBpm: options?.minBpm ?? 50,
@@ -4514,13 +4738,25 @@ class BeatDetector {
4514
4738
  settlingMs: options?.settlingMs ?? 1500,
4515
4739
  melBands: options?.melBands ?? 24,
4516
4740
  enableTimeSignatureDetection: options?.enableTimeSignatureDetection ?? true,
4741
+ // Visual-state options aren't part of worklet config; cached in the public
4742
+ // fields below but kept in the Required<> shape for type completeness.
4743
+ source: options?.source ?? null,
4744
+ pulseHalfLife: options?.pulseHalfLife ?? 0.15,
4745
+ barPulseHalfLife: options?.barPulseHalfLife ?? 0.3,
4746
+ justBeatWindow: options?.justBeatWindow ?? 0.03,
4517
4747
  };
4748
+ this.pulseHalfLife = this._options.pulseHalfLife;
4749
+ this.barPulseHalfLife = this._options.barPulseHalfLife;
4750
+ this.justBeatWindow = this._options.justBeatWindow;
4518
4751
  if (isAudioContextReady()) {
4519
4752
  this._setup(getAudioContext());
4520
4753
  }
4521
4754
  else {
4522
4755
  onAudioContextReady.once(this._setup, this);
4523
4756
  }
4757
+ if (options?.source !== undefined && options.source !== null) {
4758
+ this.source = options.source;
4759
+ }
4524
4760
  }
4525
4761
  // -----------------------------------------------------------------------
4526
4762
  // Source setter (polymorphic tap)
@@ -4601,6 +4837,62 @@ class BeatDetector {
4601
4837
  return this._lookahead;
4602
4838
  }
4603
4839
  // -----------------------------------------------------------------------
4840
+ // Visual derived state — pure getters for per-frame polling
4841
+ // -----------------------------------------------------------------------
4842
+ /**
4843
+ * Seconds elapsed since the most recent beat, derived from {@link beatPhase}
4844
+ * and {@link tempo}. Returns 0 when the detector hasn't locked yet.
4845
+ */
4846
+ get secondsSinceLastBeat() {
4847
+ if (this._tempo === 0)
4848
+ return 0;
4849
+ return this._beatPhase * (60 / this._tempo);
4850
+ }
4851
+ /**
4852
+ * 0..1 envelope, peaks at 1.0 the moment a beat fires and halves every
4853
+ * {@link pulseHalfLife} seconds. Drives "pulse on the beat" visuals
4854
+ * with a single multiplication: `sprite.scale = 1 + clock.pulse * 0.3`.
4855
+ */
4856
+ get pulse() {
4857
+ if (this._tempo === 0)
4858
+ return 0;
4859
+ return Math.pow(0.5, this.secondsSinceLastBeat / this.pulseHalfLife);
4860
+ }
4861
+ /**
4862
+ * Like {@link pulse} but resets on downbeats and decays per
4863
+ * {@link barPulseHalfLife}. Useful for emphasizing the first beat of
4864
+ * each bar (e.g. brighter flash on "1" vs "2,3,4").
4865
+ */
4866
+ get barPulse() {
4867
+ if (this._tempo === 0 || this._barLength === 0)
4868
+ return 0;
4869
+ const secondsPerBeat = 60 / this._tempo;
4870
+ const lastDownbeat = this._nextDownbeatTime - this._barLength * secondsPerBeat;
4871
+ const elapsed = Math.max(0, getAudioContext().currentTime - lastDownbeat);
4872
+ return Math.pow(0.5, elapsed / this.barPulseHalfLife);
4873
+ }
4874
+ /**
4875
+ * True for the visual frame(s) within {@link justBeatWindow} seconds of
4876
+ * a beat onset. Use for one-shot triggers (strobe flash, particle burst,
4877
+ * sample retrigger). Default window 30ms covers a typical 60fps frame.
4878
+ */
4879
+ get justBeat() {
4880
+ return this._tempo > 0 && this.secondsSinceLastBeat < this.justBeatWindow;
4881
+ }
4882
+ /**
4883
+ * Phase 0..1 within a subdivision of the current beat. `division` is the
4884
+ * number of subdivisions per beat: 2 for 8th notes, 4 for 16th notes,
4885
+ * 3 for triplets. Use to drive sub-beat-resolution effects:
4886
+ *
4887
+ * const sixteenth = clock.subdivisionPhase(4);
4888
+ * if (sixteenth < 0.05) flash();
4889
+ */
4890
+ subdivisionPhase(division) {
4891
+ if (!Number.isFinite(division) || division <= 0)
4892
+ return 0;
4893
+ return (this._beatPhase * division) % 1;
4894
+ }
4895
+ // -----------------------------------------------------------------------
4604
4896
  // Lifecycle
4605
4897
  // -----------------------------------------------------------------------
4606
4898
  destroy() {
@@ -16526,6 +16818,174 @@ const resetRenderStats = (stats) => {
16526
16818
  return stats;
16527
16819
  };
16528
16820
 
16821
+ const channelsForFormat = {
16822
+ r8: 1,
16823
+ r32f: 1,
16824
+ rgba8: 4,
16825
+ rgba32f: 4,
16826
+ };
16827
+ const bytesPerChannelForFormat = {
16828
+ r8: 1,
16829
+ r32f: 4,
16830
+ rgba8: 1,
16831
+ rgba32f: 4,
16832
+ };
16833
+ /**
16834
+ * A 2D texture whose pixels live in a CPU-side typed array. Mutate the
16835
+ * `buffer` directly and call {@link commit} to upload the whole array, or
16836
+ * {@link commitRect} to upload a sub-region (cheaper for ring-buffer
16837
+ * patterns like spectrograms).
16838
+ *
16839
+ * `DataTexture` extends {@link Texture}, so any API that accepts a
16840
+ * `Texture` (filter uniforms, mesh textures, custom shader uniforms)
16841
+ * accepts a `DataTexture` unchanged.
16842
+ *
16843
+ * # Default sampler
16844
+ *
16845
+ * `DataTexture` defaults to nearest filtering, clamp-to-edge wrap, no mip
16846
+ * generation, and no premultiply. These match the typical "raw data
16847
+ * lookup" use case where bilinear filtering would corrupt sampled values
16848
+ * (e.g. spectrum bins sampled by index).
16849
+ *
16850
+ * # Bring-your-own buffer
16851
+ *
16852
+ * Pass `data` in options to share an external buffer. Useful for:
16853
+ * - SharedArrayBuffer + Worker pipelines (audio DSP off the main thread)
16854
+ * - Buffer pooling across many small textures
16855
+ * - Interop with WebAssembly memory or other APIs that produce typed-array views
16856
+ *
16857
+ * The buffer reference is fixed for the lifetime of the texture (the
16858
+ * `buffer` property is `readonly`); only its contents may be mutated.
16859
+ *
16860
+ * # Format / buffer correspondence
16861
+ *
16862
+ * The TypeScript type system narrows `buffer` based on `format`:
16863
+ *
16864
+ * const r8 = new DataTexture({ width: 256, height: 1, format: 'r8' });
16865
+ * r8.buffer // Uint8Array
16866
+ *
16867
+ * const r32f = new DataTexture({ width: 256, height: 1, format: 'r32f' });
16868
+ * r32f.buffer // Float32Array
16869
+ */
16870
+ class DataTexture extends Texture {
16871
+ static defaultSamplerOptions = {
16872
+ scaleMode: ScaleModes.Nearest,
16873
+ wrapMode: WrapModes.ClampToEdge,
16874
+ premultiplyAlpha: false,
16875
+ generateMipMap: false,
16876
+ flipY: false,
16877
+ };
16878
+ format;
16879
+ buffer;
16880
+ _dirty = null;
16881
+ constructor(options) {
16882
+ super(null, { ...DataTexture.defaultSamplerOptions, ...options.samplerOptions });
16883
+ const { width, height, format, data } = options;
16884
+ if (!Number.isInteger(width) || width <= 0) {
16885
+ throw new Error(`DataTexture width must be a positive integer (got ${width}).`);
16886
+ }
16887
+ if (!Number.isInteger(height) || height <= 0) {
16888
+ throw new Error(`DataTexture height must be a positive integer (got ${height}).`);
16889
+ }
16890
+ const channels = channelsForFormat[format];
16891
+ const bpc = bytesPerChannelForFormat[format];
16892
+ const expectedBytes = width * height * channels * bpc;
16893
+ let buffer;
16894
+ if (data === undefined) {
16895
+ buffer = isFloatFormat(format) ? new Float32Array(expectedBytes / 4) : new Uint8Array(expectedBytes);
16896
+ }
16897
+ else if (data instanceof ArrayBuffer) {
16898
+ if (data.byteLength !== expectedBytes) {
16899
+ throw new Error(`DataTexture data byteLength ${data.byteLength} does not match ${width}x${height} ${format} (${expectedBytes} bytes expected).`);
16900
+ }
16901
+ buffer = isFloatFormat(format) ? new Float32Array(data) : new Uint8Array(data);
16902
+ }
16903
+ else if (data instanceof Uint8Array) {
16904
+ if (isFloatFormat(format)) {
16905
+ throw new Error(`DataTexture format '${format}' requires a Float32Array, got Uint8Array.`);
16906
+ }
16907
+ if (data.byteLength !== expectedBytes) {
16908
+ throw new Error(`DataTexture Uint8Array length ${data.length} does not match ${width}x${height} ${format} (${expectedBytes} expected).`);
16909
+ }
16910
+ buffer = data;
16911
+ }
16912
+ else {
16913
+ // Float32Array
16914
+ if (!isFloatFormat(format)) {
16915
+ throw new Error(`DataTexture format '${format}' requires a Uint8Array, got Float32Array.`);
16916
+ }
16917
+ if (data.byteLength !== expectedBytes) {
16918
+ throw new Error(`DataTexture Float32Array byteLength ${data.byteLength} does not match ${width}x${height} ${format} (${expectedBytes} expected).`);
16919
+ }
16920
+ buffer = data;
16921
+ }
16922
+ this.format = format;
16923
+ this.buffer = buffer;
16924
+ this.setSize(width, height);
16925
+ // Mark fully dirty so the first sync uploads the whole buffer.
16926
+ this._dirty = { full: true, x: 0, y: 0, width, height };
16927
+ }
16928
+ /**
16929
+ * Mark the entire buffer for re-upload on next backend sync. Call after
16930
+ * mutating `buffer` contents to flush changes to the GPU.
16931
+ */
16932
+ commit() {
16933
+ this._dirty = { full: true, x: 0, y: 0, width: this.width, height: this.height };
16934
+ this.setSize(this.width, this.height);
16935
+ // setSize is a no-op when dimensions don't change; force version bump for sync detection.
16936
+ this._version++;
16937
+ return this;
16938
+ }
16939
+ /**
16940
+ * Mark a sub-region of the buffer dirty for partial upload. More efficient
16941
+ * than {@link commit} for ring-buffer patterns where only one row or column
16942
+ * changes per frame. If a region was already pending, the union is uploaded.
16943
+ *
16944
+ * Coordinates are pixel-space with origin at the top-left. Bounds are clamped
16945
+ * to the texture dimensions; out-of-range rectangles throw.
16946
+ */
16947
+ commitRect(x, y, width, height) {
16948
+ if (!Number.isInteger(x) || !Number.isInteger(y) || !Number.isInteger(width) || !Number.isInteger(height)) {
16949
+ throw new Error(`DataTexture commitRect requires integer coordinates (got ${x}, ${y}, ${width}, ${height}).`);
16950
+ }
16951
+ if (width <= 0 || height <= 0) {
16952
+ throw new Error(`DataTexture commitRect requires positive width and height (got ${width}, ${height}).`);
16953
+ }
16954
+ if (x < 0 || y < 0 || x + width > this.width || y + height > this.height) {
16955
+ throw new Error(`DataTexture commitRect (${x}, ${y}, ${width}, ${height}) is out of bounds for ${this.width}x${this.height}.`);
16956
+ }
16957
+ if (this._dirty === null) {
16958
+ this._dirty = { full: false, x, y, width, height };
16959
+ }
16960
+ else if (this._dirty.full) ;
16961
+ else {
16962
+ // Union with the existing pending region.
16963
+ const minX = Math.min(this._dirty.x, x);
16964
+ const minY = Math.min(this._dirty.y, y);
16965
+ const maxX = Math.max(this._dirty.x + this._dirty.width, x + width);
16966
+ const maxY = Math.max(this._dirty.y + this._dirty.height, y + height);
16967
+ this._dirty = { full: false, x: minX, y: minY, width: maxX - minX, height: maxY - minY };
16968
+ }
16969
+ this._version++;
16970
+ return this;
16971
+ }
16972
+ /**
16973
+ * Internal: backend reads the pending dirty region and clears it. Returns
16974
+ * `null` when there is nothing pending. Backends call this once per sync
16975
+ * pass to plan their texSubImage2D / writeTexture operations.
16976
+ *
16977
+ * @internal
16978
+ */
16979
+ _consumeDirtyRegion() {
16980
+ const region = this._dirty;
16981
+ this._dirty = null;
16982
+ return region;
16983
+ }
16984
+ }
16985
+ function isFloatFormat(format) {
16986
+ return format === 'r32f' || format === 'rgba32f';
16987
+ }
16988
+
16529
16989
  /**
16530
16990
  * Backend-agnostic shader program descriptor.
16531
16991
  *
@@ -17426,7 +17886,7 @@ const vertexStrideWords = vertexStrideBytes$3 / 4;
17426
17886
  const initialVertexCapacity = 64;
17427
17887
  const initialIndexCapacity = 192;
17428
17888
  const defaultVertexColor = 0xffffffff; // white, full alpha
17429
- const maxCustomTextureSlots = 8;
17889
+ const maxCustomTextureSlots$1 = 8;
17430
17890
  class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
17431
17891
  _defaultShader = new Shader(vertexSource$2, fragmentSource$2);
17432
17892
  _customShaders = new Map();
@@ -17434,7 +17894,7 @@ class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
17434
17894
  _textureUnitScratch = new Int32Array([0]);
17435
17895
  // Pre-built texture-unit indices used for custom-shader sampler bindings;
17436
17896
  // pre-allocated so the per-frame uniform path stays allocation-free.
17437
- _slotScratches = Array.from({ length: maxCustomTextureSlots }, (_, i) => new Int32Array([i]));
17897
+ _slotScratches = Array.from({ length: maxCustomTextureSlots$1 }, (_, i) => new Int32Array([i]));
17438
17898
  _vertexCapacity = initialVertexCapacity;
17439
17899
  _indexCapacity = initialIndexCapacity;
17440
17900
  _vertexData = new ArrayBuffer(initialVertexCapacity * vertexStrideBytes$3);
@@ -17671,11 +18131,22 @@ class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
17671
18131
  if (cached !== undefined) {
17672
18132
  return cached;
17673
18133
  }
17674
- const shader = new Shader(config.vertexSource, config.fragmentSource);
18134
+ if (config.glsl === null) {
18135
+ throw new Error('MeshShader has no `glsl` source; cannot render through the WebGL2 backend.');
18136
+ }
18137
+ const shader = new Shader(config.glsl.vertex, config.glsl.fragment);
17675
18138
  shader.connect(createWebGl2ShaderProgram(gl));
17676
18139
  // Force first finalize so getUniform()/uniforms.has() are usable below.
17677
18140
  shader.sync();
17678
18141
  this._customShaders.set(config, shader);
18142
+ // Wire shader.destroy() through to evict + dispose the cached program.
18143
+ config._onDispose(() => {
18144
+ const stored = this._customShaders.get(config);
18145
+ if (stored !== undefined) {
18146
+ stored.destroy();
18147
+ this._customShaders.delete(config);
18148
+ }
18149
+ });
17679
18150
  return shader;
17680
18151
  }
17681
18152
  _bindCustomUniforms(shader, uniforms, backend) {
@@ -17689,8 +18160,8 @@ class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
17689
18160
  const value = uniforms[name];
17690
18161
  const uniform = shader.getUniform(name);
17691
18162
  if (value instanceof Texture || value instanceof RenderTexture) {
17692
- if (textureSlot >= maxCustomTextureSlots) {
17693
- throw new Error(`Mesh custom shader requested more than ${maxCustomTextureSlots - 1} texture uniforms.`);
18163
+ if (textureSlot >= maxCustomTextureSlots$1) {
18164
+ throw new Error(`Mesh custom shader requested more than ${maxCustomTextureSlots$1 - 1} texture uniforms.`);
17694
18165
  }
17695
18166
  backend.bindTexture(value, textureSlot);
17696
18167
  uniform.setValue(this._slotScratches[textureSlot]);
@@ -18830,7 +19301,31 @@ class WebGl2Backend {
18830
19301
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, texture.wrapMode);
18831
19302
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, texture.wrapMode);
18832
19303
  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, texture.premultiplyAlpha);
18833
- if (texture instanceof RenderTexture) {
19304
+ if (texture instanceof DataTexture) {
19305
+ const formatInfo = webgl2DataTextureFormat(texture.format);
19306
+ const region = texture._consumeDirtyRegion();
19307
+ const needsAlloc = state.version === -1 || state.width !== texture.width || state.height !== texture.height;
19308
+ if (needsAlloc || region === null || region.full) {
19309
+ gl.texImage2D(gl.TEXTURE_2D, 0, formatInfo.internalFormat, texture.width, texture.height, 0, formatInfo.format, formatInfo.type, texture.buffer);
19310
+ }
19311
+ else {
19312
+ // Partial upload: pack a contiguous sub-region from the row-major
19313
+ // buffer into a temporary view that gl.texSubImage2D can read.
19314
+ const channels = formatInfo.channels;
19315
+ const rowFloats = texture.width * channels;
19316
+ const subFloats = region.width * channels;
19317
+ const subView = texture.buffer instanceof Float32Array
19318
+ ? new Float32Array(region.width * region.height * channels)
19319
+ : new Uint8Array(region.width * region.height * channels);
19320
+ for (let row = 0; row < region.height; row++) {
19321
+ const sourceStart = (region.y + row) * rowFloats + region.x * channels;
19322
+ const targetStart = row * subFloats;
19323
+ subView.set(texture.buffer.subarray(sourceStart, sourceStart + subFloats), targetStart);
19324
+ }
19325
+ gl.texSubImage2D(gl.TEXTURE_2D, 0, region.x, region.y, region.width, region.height, formatInfo.format, formatInfo.type, subView);
19326
+ }
19327
+ }
19328
+ else if (texture instanceof RenderTexture) {
18834
19329
  if (state.version === -1 || state.width !== texture.width || state.height !== texture.height || texture.source === null) {
18835
19330
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, texture.width, texture.height, 0, gl.RGBA, gl.UNSIGNED_BYTE, texture.source);
18836
19331
  }
@@ -18901,6 +19396,22 @@ class WebGl2Backend {
18901
19396
  gl.scissor(clip.x, clip.y, clip.width, clip.height);
18902
19397
  }
18903
19398
  }
19399
+ // WebGL2RenderingContext is not defined in jsdom; resolve constants from
19400
+ // the live gl context instead of the global class so test environments
19401
+ // without WebGL2 still load this module.
19402
+ function webgl2DataTextureFormat(format) {
19403
+ const gl = WebGL2RenderingContext;
19404
+ switch (format) {
19405
+ case 'r8':
19406
+ return { internalFormat: gl.R8, format: gl.RED, type: gl.UNSIGNED_BYTE, channels: 1 };
19407
+ case 'r32f':
19408
+ return { internalFormat: gl.R32F, format: gl.RED, type: gl.FLOAT, channels: 1 };
19409
+ case 'rgba8':
19410
+ return { internalFormat: gl.RGBA8, format: gl.RGBA, type: gl.UNSIGNED_BYTE, channels: 4 };
19411
+ case 'rgba32f':
19412
+ return { internalFormat: gl.RGBA32F, format: gl.RGBA, type: gl.FLOAT, channels: 4 };
19413
+ }
19414
+ }
18904
19415
 
18905
19416
  /// <reference types="@webgpu/types" />
18906
19417
  /**
@@ -19140,9 +19651,10 @@ class WebGpuMaskCompositor {
19140
19651
  });
19141
19652
  const targetFormat = manager.renderTargetFormat;
19142
19653
  const pipeline = this._getOrCreatePipeline(targetFormat, blendMode);
19143
- const encoder = device.createCommandEncoder();
19654
+ const encoder = device.createCommandEncoder({ label: 'WebGpuMaskCompositor' });
19144
19655
  const pass = encoder.beginRenderPass({
19145
19656
  colorAttachments: [manager.createColorAttachment()],
19657
+ label: 'WebGpuMaskCompositor pass',
19146
19658
  });
19147
19659
  manager.stats.renderPasses++;
19148
19660
  const scissor = manager.getScissorRect();
@@ -19337,16 +19849,27 @@ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
19337
19849
  }
19338
19850
  `;
19339
19851
  // Per-vertex layout (20 bytes): pos f32x2 + uv f32x2 + color u8x4-norm.
19340
- // CPU bakes the (view * globalTransform) into position so the vertex
19341
- // shader stays branchless and uniform-free except for the per-mesh tint.
19852
+ // Default-shader path bakes the (view * globalTransform) into position so the
19853
+ // vertex shader stays branchless and uniform-free except for the per-mesh tint.
19854
+ // Custom-shader path keeps positions in LOCAL space — the user's vertex
19855
+ // shader receives mesh transforms via the auto-bound u_mesh uniform block.
19342
19856
  const vertexStrideBytes$1 = 20;
19343
19857
  const wordsPerVertex = vertexStrideBytes$1 / 4;
19344
19858
  const tintByteLength = 32; // vec4 tint + vec4 flags (only flags.x used)
19859
+ // Custom-shader uniform layout:
19860
+ // mat3x3<f32> projection — 48 bytes (3 vec3 columns padded to vec4 in WGSL)
19861
+ // mat3x3<f32> translation — 48 bytes
19862
+ // vec4<f32> tint — 16 bytes
19863
+ // Total: 112 bytes; aligned up to 256 for dynamic offset.
19864
+ const customMeshUniformBytes = 112;
19865
+ const meshUniformAlignment = 256;
19866
+ const maxCustomTextureSlots = 7; // user texture uniforms; group 2 binding 1..N
19345
19867
  class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
19346
19868
  _combinedTransform = new Matrix();
19347
19869
  _drawCalls = [];
19348
19870
  _pipelines = new Map();
19349
19871
  _textureBindGroups = new Map();
19872
+ _customShaders = new Map();
19350
19873
  _device = null;
19351
19874
  _shaderModule = null;
19352
19875
  _uniformBindGroupLayout = null;
@@ -19370,8 +19893,9 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
19370
19893
  if (backend === null) {
19371
19894
  throw new Error('WebGpuMeshRenderer is not connected to a backend.');
19372
19895
  }
19373
- if (mesh.shader !== null) {
19374
- throw new Error('Mesh custom shaders are currently WebGL2-only. WebGPU support is planned for a future release; in the meantime use the WebGL2 backend or omit `shader` from the Mesh options.');
19896
+ const customShader = mesh.shader;
19897
+ if (customShader !== null && customShader.wgsl === null) {
19898
+ throw new Error('MeshShader has no `wgsl` source; cannot render through the WebGPU backend.');
19375
19899
  }
19376
19900
  const vertexCount = mesh.vertexCount;
19377
19901
  if (vertexCount === 0) {
@@ -19380,15 +19904,26 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
19380
19904
  const blendMode = mesh.blendMode;
19381
19905
  backend.setBlendMode(blendMode);
19382
19906
  const meshTexture = mesh.texture ?? Texture.white;
19383
- // Texture.white is a 1x1 canvas-backed Texture; backend.shouldPremultiplyTextureSample
19384
- // expects RenderTexture-or-Texture. Both branches are valid here.
19907
+ // backend.shouldPremultiplyTextureSample expects RenderTexture-or-Texture.
19908
+ // Both branches are valid here. Premultiply flag is ignored by custom
19909
+ // shaders (they handle premultiplication themselves), but we still record
19910
+ // it so the default path uses the right value.
19385
19911
  const premultiplySample = backend.shouldPremultiplyTextureSample(meshTexture);
19386
19912
  const indexCount = mesh.indexCount;
19387
- // Plan offsets within the shared per-frame buffers; actual data
19388
- // packing happens in flush() after all drawcalls are known so a
19389
- // single writeBuffer per resource covers the whole frame.
19913
+ let customDrawIndex = -1;
19914
+ if (customShader !== null) {
19915
+ const resources = this._getOrCreateCustomShaderResources(customShader);
19916
+ customDrawIndex = resources.drawCount;
19917
+ resources.drawCount++;
19918
+ resources.totalVertices += vertexCount;
19919
+ resources.totalIndices += indexCount;
19920
+ }
19921
+ // Plan offsets within the shared (default) or per-shader (custom) buffers;
19922
+ // actual data packing happens in flush() after all drawcalls are known so
19923
+ // a single writeBuffer per resource covers the whole frame.
19390
19924
  const drawCall = {
19391
19925
  mesh,
19926
+ customShader,
19392
19927
  blendMode,
19393
19928
  texture: meshTexture,
19394
19929
  premultiplySample,
@@ -19396,6 +19931,7 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
19396
19931
  vertexCount,
19397
19932
  indexByteOffset: 0,
19398
19933
  indexCount,
19934
+ customDrawIndex,
19399
19935
  };
19400
19936
  // Use mutable record (interface readonly is for type safety against
19401
19937
  // callers; the renderer fills these slots in flush()).
@@ -19424,88 +19960,194 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
19424
19960
  pass.end();
19425
19961
  backend.submit(encoder.finish());
19426
19962
  }
19427
- this._drawCallCount = 0;
19963
+ this._resetFrame();
19428
19964
  return;
19429
19965
  }
19430
- // Phase 1: compute layout offsets for the whole frame.
19431
- let totalVertices = 0;
19432
- let totalIndices = 0;
19966
+ // Phase 1: compute layout offsets (default vs. custom paths use separate
19967
+ // buffers, so default offsets are independent of custom offsets).
19968
+ let defaultVertices = 0;
19969
+ let defaultIndices = 0;
19970
+ const customVertexCursors = new Map(); // running vertex count per shader
19971
+ const customIndexCursors = new Map();
19433
19972
  for (let i = 0; i < this._drawCallCount; i++) {
19434
19973
  const dc = this._drawCalls[i];
19435
- dc.vertexByteOffset = totalVertices * vertexStrideBytes$1;
19436
- dc.indexByteOffset = totalIndices * Uint16Array.BYTES_PER_ELEMENT;
19437
- totalVertices += dc.vertexCount;
19438
- totalIndices += dc.indexCount;
19439
- }
19440
- // Phase 2: ensure capacities for the totals.
19441
- this._ensureVertexCapacity(totalVertices);
19442
- this._ensureIndexCapacity(totalIndices);
19443
- this._ensureUniformCapacity(this._drawCallCount);
19444
- // Phase 3: pack vertex + index + uniform CPU-side data.
19445
- const uniformBytes = this._drawCallCount * this._uniformAlignment;
19446
- const uniformData = new ArrayBuffer(uniformBytes);
19447
- const uniformF32 = new Float32Array(uniformData);
19448
- for (let i = 0; i < this._drawCallCount; i++) {
19449
- const dc = this._drawCalls[i];
19450
- this._writeMeshVertices(backend, dc.mesh, dc.vertexByteOffset / vertexStrideBytes$1);
19451
- if (dc.mesh.indices !== null) {
19452
- this._packedIndexData.set(dc.mesh.indices, dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT);
19974
+ if (dc.customShader === null) {
19975
+ dc.vertexByteOffset = defaultVertices * vertexStrideBytes$1;
19976
+ dc.indexByteOffset = defaultIndices * Uint16Array.BYTES_PER_ELEMENT;
19977
+ defaultVertices += dc.vertexCount;
19978
+ defaultIndices += dc.indexCount;
19453
19979
  }
19454
19980
  else {
19455
- const start = dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT;
19456
- for (let j = 0; j < dc.indexCount; j++) {
19457
- this._packedIndexData[start + j] = j;
19981
+ const vCursor = customVertexCursors.get(dc.customShader) ?? 0;
19982
+ const iCursor = customIndexCursors.get(dc.customShader) ?? 0;
19983
+ dc.vertexByteOffset = vCursor * vertexStrideBytes$1;
19984
+ dc.indexByteOffset = iCursor * Uint16Array.BYTES_PER_ELEMENT;
19985
+ customVertexCursors.set(dc.customShader, vCursor + dc.vertexCount);
19986
+ customIndexCursors.set(dc.customShader, iCursor + dc.indexCount);
19987
+ }
19988
+ }
19989
+ // Phase 2: ensure capacities for the totals (default path).
19990
+ this._ensureVertexCapacity(defaultVertices);
19991
+ this._ensureIndexCapacity(defaultIndices);
19992
+ // Default-path uniform buffer holds (tint vec4 + flags vec4) per draw call;
19993
+ // each custom-shader resource manages its own.
19994
+ const defaultDrawCalls = this._drawCallCount - this._totalCustomDraws();
19995
+ this._ensureUniformCapacity(defaultDrawCalls);
19996
+ // Phase 3: pack default-path vertex/index/uniform data.
19997
+ const defaultUniformBytes = defaultDrawCalls * this._uniformAlignment;
19998
+ const defaultUniformData = defaultUniformBytes > 0 ? new ArrayBuffer(defaultUniformBytes) : null;
19999
+ const defaultUniformF32 = defaultUniformData !== null ? new Float32Array(defaultUniformData) : null;
20000
+ let defaultUniformIndex = 0;
20001
+ for (let i = 0; i < this._drawCallCount; i++) {
20002
+ const dc = this._drawCalls[i];
20003
+ if (dc.customShader === null) {
20004
+ // Default path: CPU-bake transform into vertex positions.
20005
+ this._writeMeshVertices(backend, dc.mesh, dc.vertexByteOffset / vertexStrideBytes$1, /* bake */ true);
20006
+ if (dc.mesh.indices !== null) {
20007
+ this._packedIndexData.set(dc.mesh.indices, dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT);
20008
+ }
20009
+ else {
20010
+ const start = dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT;
20011
+ for (let j = 0; j < dc.indexCount; j++) {
20012
+ this._packedIndexData[start + j] = j;
20013
+ }
20014
+ }
20015
+ // Pack tint+flags for default path.
20016
+ if (defaultUniformF32 !== null) {
20017
+ const offsetWords = (defaultUniformIndex * this._uniformAlignment) / Float32Array.BYTES_PER_ELEMENT;
20018
+ const tint = dc.mesh.tint;
20019
+ defaultUniformF32[offsetWords + 0] = tint.red;
20020
+ defaultUniformF32[offsetWords + 1] = tint.green;
20021
+ defaultUniformF32[offsetWords + 2] = tint.blue;
20022
+ defaultUniformF32[offsetWords + 3] = tint.alpha;
20023
+ defaultUniformF32[offsetWords + 4] = dc.premultiplySample ? 1 : 0;
20024
+ defaultUniformF32[offsetWords + 5] = 0;
20025
+ defaultUniformF32[offsetWords + 6] = 0;
20026
+ defaultUniformF32[offsetWords + 7] = 0;
19458
20027
  }
20028
+ defaultUniformIndex++;
19459
20029
  }
19460
- const uniformOffsetWords = (i * this._uniformAlignment) / Float32Array.BYTES_PER_ELEMENT;
19461
- const tint = dc.mesh.tint;
19462
- uniformF32[uniformOffsetWords + 0] = tint.red;
19463
- uniformF32[uniformOffsetWords + 1] = tint.green;
19464
- uniformF32[uniformOffsetWords + 2] = tint.blue;
19465
- uniformF32[uniformOffsetWords + 3] = tint.alpha;
19466
- uniformF32[uniformOffsetWords + 4] = dc.premultiplySample ? 1 : 0;
19467
- uniformF32[uniformOffsetWords + 5] = 0;
19468
- uniformF32[uniformOffsetWords + 6] = 0;
19469
- uniformF32[uniformOffsetWords + 7] = 0;
19470
- }
19471
- // Phase 4: single writeBuffer per resource for the whole frame.
19472
- device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, totalVertices * vertexStrideBytes$1);
19473
- device.queue.writeBuffer(this._indexBuffer, 0, this._packedIndexData.buffer, this._packedIndexData.byteOffset, totalIndices * Uint16Array.BYTES_PER_ELEMENT);
19474
- device.queue.writeBuffer(this._uniformBuffer, 0, uniformData, 0, uniformBytes);
19475
- // Phase 5: single render pass with one drawIndexed per mesh.
19476
- const encoder = device.createCommandEncoder();
20030
+ }
20031
+ // Phase 3b: pack custom-path vertex/index/uniform data per shader.
20032
+ for (const [shader, resources] of this._customShaders) {
20033
+ if (resources.drawCount === 0) {
20034
+ continue;
20035
+ }
20036
+ this._ensureCustomCapacities(resources);
20037
+ // Pack vertices/indices in local space (no CPU bake).
20038
+ let vWritten = 0;
20039
+ let iWritten = 0;
20040
+ let drawCursor = 0;
20041
+ for (let i = 0; i < this._drawCallCount; i++) {
20042
+ const dc = this._drawCalls[i];
20043
+ if (dc.customShader !== shader)
20044
+ continue;
20045
+ this._writeMeshVerticesIntoBuffer(dc.mesh, vWritten, resources.vertexFloatView, resources.vertexUintView);
20046
+ if (dc.mesh.indices !== null) {
20047
+ resources.indexData.set(dc.mesh.indices, iWritten);
20048
+ }
20049
+ else {
20050
+ for (let j = 0; j < dc.indexCount; j++) {
20051
+ resources.indexData[iWritten + j] = j;
20052
+ }
20053
+ }
20054
+ // Write mesh-uniform slot (proj/trans/tint) with dynamic offset.
20055
+ this._writeCustomMeshUniform(shader, resources, drawCursor, dc.mesh, backend);
20056
+ vWritten += dc.vertexCount;
20057
+ iWritten += dc.indexCount;
20058
+ drawCursor++;
20059
+ }
20060
+ device.queue.writeBuffer(resources.vertexBuffer, 0, resources.vertexData, 0, resources.totalVertices * vertexStrideBytes$1);
20061
+ device.queue.writeBuffer(resources.indexBuffer, 0, resources.indexData.buffer, resources.indexData.byteOffset, resources.totalIndices * Uint16Array.BYTES_PER_ELEMENT);
20062
+ // Build/refresh user uniform UBO from shader.uniforms (re-built every
20063
+ // frame so mutations to shader.uniforms.X are picked up).
20064
+ this._uploadUserUniforms(shader, resources);
20065
+ }
20066
+ // Phase 4: single writeBuffer per resource for the default path.
20067
+ if (defaultVertices > 0) {
20068
+ device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, defaultVertices * vertexStrideBytes$1);
20069
+ device.queue.writeBuffer(this._indexBuffer, 0, this._packedIndexData.buffer, this._packedIndexData.byteOffset, defaultIndices * Uint16Array.BYTES_PER_ELEMENT);
20070
+ }
20071
+ if (defaultUniformData !== null) {
20072
+ device.queue.writeBuffer(this._uniformBuffer, 0, defaultUniformData, 0, defaultUniformBytes);
20073
+ }
20074
+ // Phase 5: single render pass with one drawIndexed per mesh, switching
20075
+ // pipeline+bind groups between default and custom paths as needed.
20076
+ const encoder = device.createCommandEncoder({ label: 'WebGpuMeshRenderer' });
19477
20077
  const pass = encoder.beginRenderPass({
19478
20078
  colorAttachments: [backend.createColorAttachment()],
20079
+ label: 'WebGpuMeshRenderer pass',
19479
20080
  });
19480
20081
  backend.stats.renderPasses++;
19481
20082
  if (scissor !== null) {
19482
20083
  pass.setScissorRect(scissor.x, scissor.y, scissor.width, scissor.height);
19483
20084
  }
20085
+ const renderTargetFormat = backend.renderTargetFormat;
20086
+ let lastShader = null;
19484
20087
  let lastBlendMode = null;
19485
20088
  let lastFormat = null;
19486
20089
  let lastTexture = null;
19487
- const renderTargetFormat = backend.renderTargetFormat;
20090
+ let defaultDrawCursor = 0;
20091
+ const customDrawCursors = new Map();
19488
20092
  for (let i = 0; i < this._drawCallCount; i++) {
19489
20093
  const dc = this._drawCalls[i];
19490
- if (dc.blendMode !== lastBlendMode || renderTargetFormat !== lastFormat) {
19491
- lastBlendMode = dc.blendMode;
19492
- lastFormat = renderTargetFormat;
19493
- pass.setPipeline(this._getPipeline({ blendMode: dc.blendMode, format: renderTargetFormat }));
19494
- }
19495
- pass.setBindGroup(0, this._uniformBindGroup, [i * this._uniformAlignment]);
19496
- if (dc.texture !== lastTexture) {
19497
- lastTexture = dc.texture;
19498
- pass.setBindGroup(1, this._getTextureBindGroup(backend, dc.texture));
19499
- }
19500
- pass.setVertexBuffer(0, this._vertexBuffer, dc.vertexByteOffset);
19501
- pass.setIndexBuffer(this._indexBuffer, 'uint16', dc.indexByteOffset);
19502
- pass.drawIndexed(dc.indexCount);
20094
+ if (dc.customShader === null) {
20095
+ // ----- Default path -----
20096
+ const needsPipeline = lastShader !== 'default' || dc.blendMode !== lastBlendMode || renderTargetFormat !== lastFormat;
20097
+ if (needsPipeline) {
20098
+ pass.setPipeline(this._getPipeline({ blendMode: dc.blendMode, format: renderTargetFormat }));
20099
+ lastShader = 'default';
20100
+ lastBlendMode = dc.blendMode;
20101
+ lastFormat = renderTargetFormat;
20102
+ // Pipeline switch invalidates bind group state assumptions.
20103
+ lastTexture = null;
20104
+ }
20105
+ pass.setBindGroup(0, this._uniformBindGroup, [defaultDrawCursor * this._uniformAlignment]);
20106
+ if (dc.texture !== lastTexture) {
20107
+ lastTexture = dc.texture;
20108
+ pass.setBindGroup(1, this._getTextureBindGroup(backend, dc.texture));
20109
+ }
20110
+ pass.setVertexBuffer(0, this._vertexBuffer, dc.vertexByteOffset);
20111
+ pass.setIndexBuffer(this._indexBuffer, 'uint16', dc.indexByteOffset);
20112
+ pass.drawIndexed(dc.indexCount);
20113
+ defaultDrawCursor++;
20114
+ }
20115
+ else {
20116
+ // ----- Custom path -----
20117
+ const resources = this._customShaders.get(dc.customShader);
20118
+ const needsPipeline = lastShader !== dc.customShader || dc.blendMode !== lastBlendMode || renderTargetFormat !== lastFormat;
20119
+ // Wrap each custom-shader draw in a debug group so capture tools
20120
+ // (Spector.js, Chrome DevTools' WebGPU panel) show meaningful
20121
+ // labels for the otherwise-anonymous mesh draws inside the
20122
+ // batched render pass.
20123
+ pass.pushDebugGroup('MeshShader (custom)');
20124
+ if (needsPipeline) {
20125
+ pass.setPipeline(this._getOrCreateCustomPipeline(resources, dc.blendMode, renderTargetFormat));
20126
+ lastShader = dc.customShader;
20127
+ lastBlendMode = dc.blendMode;
20128
+ lastFormat = renderTargetFormat;
20129
+ lastTexture = null;
20130
+ // User bind group is shader-scoped; rebind once per shader switch.
20131
+ pass.setBindGroup(2, this._buildUserBindGroup(backend, dc.customShader, resources));
20132
+ }
20133
+ const cursor = customDrawCursors.get(dc.customShader) ?? 0;
20134
+ pass.setBindGroup(0, resources.meshUniformBindGroup, [cursor * meshUniformAlignment]);
20135
+ if (dc.texture !== lastTexture) {
20136
+ lastTexture = dc.texture;
20137
+ pass.setBindGroup(1, this._getOrCreateCustomMeshTextureBindGroup(resources, backend, dc.texture));
20138
+ }
20139
+ pass.setVertexBuffer(0, resources.vertexBuffer, dc.vertexByteOffset);
20140
+ pass.setIndexBuffer(resources.indexBuffer, 'uint16', dc.indexByteOffset);
20141
+ pass.drawIndexed(dc.indexCount);
20142
+ pass.popDebugGroup();
20143
+ customDrawCursors.set(dc.customShader, cursor + 1);
20144
+ }
19503
20145
  backend.stats.batches++;
19504
20146
  backend.stats.drawCalls++;
19505
20147
  }
19506
20148
  pass.end();
19507
20149
  backend.submit(encoder.finish());
19508
- this._drawCallCount = 0;
20150
+ this._resetFrame();
19509
20151
  }
19510
20152
  destroy() {
19511
20153
  this.disconnect();
@@ -19581,6 +20223,15 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
19581
20223
  this._textureBindGroupLayout = null;
19582
20224
  this._uniformBindGroupLayout = null;
19583
20225
  this._shaderModule = null;
20226
+ // Custom shaders are owned by user code (one MeshShader can be shared
20227
+ // across multiple Mesh instances). Their resources are released when the
20228
+ // user calls shader.destroy(), which fires our _onDispose callback. On
20229
+ // backend disconnect we eagerly release everything to avoid GPU leaks
20230
+ // even if the user keeps the shader reference around.
20231
+ for (const resources of this._customShaders.values()) {
20232
+ this._releaseCustomShaderResources(resources);
20233
+ }
20234
+ this._customShaders.clear();
19584
20235
  this._device = null;
19585
20236
  this._backend = null;
19586
20237
  this._drawCallCount = 0;
@@ -19588,30 +20239,47 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
19588
20239
  this._indexBufferCapacity = 0;
19589
20240
  this._uniformBufferCapacity = 0;
19590
20241
  }
19591
- _writeMeshVertices(backend, mesh, vertexStart) {
19592
- // Bake (view * globalTransform) into vertex positions on the CPU,
19593
- // matching the primitive renderer's no-uniforms approach.
19594
- const matrix = this._combinedTransform.copy(mesh.getGlobalTransform()).combine(backend.view.getTransform());
19595
- const a = matrix.a;
19596
- const b = matrix.b;
19597
- const c = matrix.c;
19598
- const d = matrix.d;
19599
- const tx = matrix.x;
19600
- const ty = matrix.y;
20242
+ // ---------------------------------------------------------------------------
20243
+ // Default-path helpers
20244
+ // ---------------------------------------------------------------------------
20245
+ _writeMeshVertices(backend, mesh, vertexStart, bake) {
19601
20246
  const vertices = mesh.vertices;
19602
20247
  const uvs = mesh.uvs;
19603
20248
  const colors = mesh.colors;
19604
20249
  const vertexCount = mesh.vertexCount;
19605
- for (let i = 0; i < vertexCount; i++) {
19606
- const sourceIndex = i * 2;
19607
- const targetIndex = (vertexStart + i) * wordsPerVertex;
19608
- const px = vertices[sourceIndex];
19609
- const py = vertices[sourceIndex + 1];
19610
- this._float32View[targetIndex + 0] = a * px + b * py + tx;
19611
- this._float32View[targetIndex + 1] = c * px + d * py + ty;
19612
- this._float32View[targetIndex + 2] = uvs !== null ? uvs[sourceIndex] : 0;
19613
- this._float32View[targetIndex + 3] = uvs !== null ? uvs[sourceIndex + 1] : 0;
19614
- this._uint32View[targetIndex + 4] = colors !== null ? colors[i] : 0xffffffff;
20250
+ if (bake) {
20251
+ // Bake (view * globalTransform) into vertex positions on the CPU,
20252
+ // matching the primitive renderer's no-uniforms approach.
20253
+ const matrix = this._combinedTransform.copy(mesh.getGlobalTransform()).combine(backend.view.getTransform());
20254
+ const a = matrix.a;
20255
+ const b = matrix.b;
20256
+ const c = matrix.c;
20257
+ const d = matrix.d;
20258
+ const tx = matrix.x;
20259
+ const ty = matrix.y;
20260
+ for (let i = 0; i < vertexCount; i++) {
20261
+ const sourceIndex = i * 2;
20262
+ const targetIndex = (vertexStart + i) * wordsPerVertex;
20263
+ const px = vertices[sourceIndex];
20264
+ const py = vertices[sourceIndex + 1];
20265
+ this._float32View[targetIndex + 0] = a * px + b * py + tx;
20266
+ this._float32View[targetIndex + 1] = c * px + d * py + ty;
20267
+ this._float32View[targetIndex + 2] = uvs !== null ? uvs[sourceIndex] : 0;
20268
+ this._float32View[targetIndex + 3] = uvs !== null ? uvs[sourceIndex + 1] : 0;
20269
+ this._uint32View[targetIndex + 4] = colors !== null ? colors[i] : 0xffffffff;
20270
+ }
20271
+ }
20272
+ else {
20273
+ // Should not happen — default path always bakes. Defensive no-op.
20274
+ for (let i = 0; i < vertexCount; i++) {
20275
+ const sourceIndex = i * 2;
20276
+ const targetIndex = (vertexStart + i) * wordsPerVertex;
20277
+ this._float32View[targetIndex + 0] = vertices[sourceIndex];
20278
+ this._float32View[targetIndex + 1] = vertices[sourceIndex + 1];
20279
+ this._float32View[targetIndex + 2] = uvs !== null ? uvs[sourceIndex] : 0;
20280
+ this._float32View[targetIndex + 3] = uvs !== null ? uvs[sourceIndex + 1] : 0;
20281
+ this._uint32View[targetIndex + 4] = colors !== null ? colors[i] : 0xffffffff;
20282
+ }
19615
20283
  }
19616
20284
  }
19617
20285
  _getPipeline(key) {
@@ -19705,6 +20373,9 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
19705
20373
  }
19706
20374
  }
19707
20375
  _ensureUniformCapacity(drawCallCount) {
20376
+ if (drawCallCount === 0) {
20377
+ return;
20378
+ }
19708
20379
  const requiredBytes = drawCallCount * this._uniformAlignment;
19709
20380
  if (requiredBytes > this._uniformBufferCapacity) {
19710
20381
  this._uniformBuffer?.destroy();
@@ -19724,6 +20395,400 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
19724
20395
  });
19725
20396
  }
19726
20397
  }
20398
+ // ---------------------------------------------------------------------------
20399
+ // Custom-path helpers
20400
+ // ---------------------------------------------------------------------------
20401
+ _totalCustomDraws() {
20402
+ let total = 0;
20403
+ for (const resources of this._customShaders.values()) {
20404
+ total += resources.drawCount;
20405
+ }
20406
+ return total;
20407
+ }
20408
+ _resetFrame() {
20409
+ this._drawCallCount = 0;
20410
+ for (const resources of this._customShaders.values()) {
20411
+ resources.drawCount = 0;
20412
+ resources.totalVertices = 0;
20413
+ resources.totalIndices = 0;
20414
+ }
20415
+ }
20416
+ _getOrCreateCustomShaderResources(shader) {
20417
+ let resources = this._customShaders.get(shader);
20418
+ if (resources !== undefined) {
20419
+ return resources;
20420
+ }
20421
+ if (this._device === null) {
20422
+ throw new Error('WebGpuMeshRenderer is not connected to a backend.');
20423
+ }
20424
+ if (shader.wgsl === null) {
20425
+ throw new Error('MeshShader has no `wgsl` source; cannot render through the WebGPU backend.');
20426
+ }
20427
+ const device = this._device;
20428
+ const shaderModule = device.createShaderModule({ code: shader.wgsl });
20429
+ const meshUniformLayout = device.createBindGroupLayout({
20430
+ entries: [
20431
+ {
20432
+ binding: 0,
20433
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
20434
+ buffer: { type: 'uniform', hasDynamicOffset: true },
20435
+ },
20436
+ ],
20437
+ });
20438
+ const meshTextureLayout = device.createBindGroupLayout({
20439
+ entries: [
20440
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
20441
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
20442
+ ],
20443
+ });
20444
+ const userLayout = this._buildUserBindGroupLayout(device, shader);
20445
+ const pipelineLayout = device.createPipelineLayout({
20446
+ bindGroupLayouts: [meshUniformLayout, meshTextureLayout, userLayout],
20447
+ });
20448
+ const sampler = device.createSampler({
20449
+ magFilter: 'linear',
20450
+ minFilter: 'linear',
20451
+ addressModeU: 'clamp-to-edge',
20452
+ addressModeV: 'clamp-to-edge',
20453
+ });
20454
+ const initialVertexCount = 64;
20455
+ const initialIndexCount = 192;
20456
+ const vertexData = new ArrayBuffer(initialVertexCount * vertexStrideBytes$1);
20457
+ resources = {
20458
+ shaderModule,
20459
+ meshUniformLayout,
20460
+ meshTextureLayout,
20461
+ userLayout,
20462
+ pipelineLayout,
20463
+ pipelines: new Map(),
20464
+ vertexBuffer: null,
20465
+ indexBuffer: null,
20466
+ vertexBufferCapacity: 0,
20467
+ indexBufferCapacity: 0,
20468
+ vertexData,
20469
+ vertexFloatView: new Float32Array(vertexData),
20470
+ vertexUintView: new Uint32Array(vertexData),
20471
+ indexData: new Uint16Array(initialIndexCount),
20472
+ meshUniformBuffer: null,
20473
+ meshUniformBufferCapacity: 0,
20474
+ meshUniformBindGroup: null,
20475
+ userUniformBuffer: null,
20476
+ userUniformBufferCapacity: 0,
20477
+ meshTextureBindGroups: new Map(),
20478
+ sampler,
20479
+ drawCount: 0,
20480
+ totalVertices: 0,
20481
+ totalIndices: 0,
20482
+ };
20483
+ this._customShaders.set(shader, resources);
20484
+ // When the user calls shader.destroy(), evict and release.
20485
+ shader._onDispose(() => {
20486
+ const r = this._customShaders.get(shader);
20487
+ if (r !== undefined) {
20488
+ this._releaseCustomShaderResources(r);
20489
+ this._customShaders.delete(shader);
20490
+ }
20491
+ });
20492
+ return resources;
20493
+ }
20494
+ _ensureCustomCapacities(resources) {
20495
+ const device = this._device;
20496
+ // Vertex buffer
20497
+ const vertexBytes = resources.totalVertices * vertexStrideBytes$1;
20498
+ if (vertexBytes > resources.vertexData.byteLength) {
20499
+ const newSize = Math.max(vertexBytes, resources.vertexData.byteLength * 2);
20500
+ resources.vertexData = new ArrayBuffer(newSize);
20501
+ resources.vertexFloatView = new Float32Array(resources.vertexData);
20502
+ resources.vertexUintView = new Uint32Array(resources.vertexData);
20503
+ }
20504
+ if (vertexBytes > resources.vertexBufferCapacity) {
20505
+ resources.vertexBuffer?.destroy();
20506
+ resources.vertexBufferCapacity = Math.max(vertexBytes, resources.vertexBufferCapacity * 2 || vertexStrideBytes$1);
20507
+ resources.vertexBuffer = device.createBuffer({
20508
+ size: resources.vertexBufferCapacity,
20509
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
20510
+ });
20511
+ }
20512
+ // Index buffer
20513
+ const indexBytes = resources.totalIndices * Uint16Array.BYTES_PER_ELEMENT;
20514
+ if (resources.indexData.length < resources.totalIndices) {
20515
+ resources.indexData = new Uint16Array(Math.max(resources.totalIndices, resources.indexData.length * 2));
20516
+ }
20517
+ if (indexBytes > resources.indexBufferCapacity) {
20518
+ resources.indexBuffer?.destroy();
20519
+ resources.indexBufferCapacity = Math.max(indexBytes, resources.indexBufferCapacity * 2 || Uint16Array.BYTES_PER_ELEMENT);
20520
+ resources.indexBuffer = device.createBuffer({
20521
+ size: resources.indexBufferCapacity,
20522
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
20523
+ });
20524
+ }
20525
+ // Mesh-uniform UBO (proj/trans/tint per draw, 256-byte aligned).
20526
+ const meshUniformBytes = resources.drawCount * meshUniformAlignment;
20527
+ if (meshUniformBytes > resources.meshUniformBufferCapacity) {
20528
+ resources.meshUniformBuffer?.destroy();
20529
+ resources.meshUniformBufferCapacity = Math.max(meshUniformBytes, resources.meshUniformBufferCapacity * 2 || meshUniformAlignment);
20530
+ resources.meshUniformBuffer = device.createBuffer({
20531
+ size: resources.meshUniformBufferCapacity,
20532
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
20533
+ });
20534
+ resources.meshUniformBindGroup = device.createBindGroup({
20535
+ layout: resources.meshUniformLayout,
20536
+ entries: [
20537
+ {
20538
+ binding: 0,
20539
+ resource: { buffer: resources.meshUniformBuffer, size: customMeshUniformBytes },
20540
+ },
20541
+ ],
20542
+ });
20543
+ }
20544
+ }
20545
+ _writeMeshVerticesIntoBuffer(mesh, vertexStart, floatView, uintView) {
20546
+ const vertices = mesh.vertices;
20547
+ const uvs = mesh.uvs;
20548
+ const colors = mesh.colors;
20549
+ const vertexCount = mesh.vertexCount;
20550
+ for (let i = 0; i < vertexCount; i++) {
20551
+ const sourceIndex = i * 2;
20552
+ const targetIndex = (vertexStart + i) * wordsPerVertex;
20553
+ floatView[targetIndex + 0] = vertices[sourceIndex];
20554
+ floatView[targetIndex + 1] = vertices[sourceIndex + 1];
20555
+ floatView[targetIndex + 2] = uvs !== null ? uvs[sourceIndex] : 0;
20556
+ floatView[targetIndex + 3] = uvs !== null ? uvs[sourceIndex + 1] : 0;
20557
+ uintView[targetIndex + 4] = colors !== null ? colors[i] : 0xffffffff;
20558
+ }
20559
+ }
20560
+ _writeCustomMeshUniform(_shader, resources, drawCursor, mesh, backend) {
20561
+ // Layout: mat3x3 projection (48B) + mat3x3 translation (48B) + vec4 tint (16B) = 112B.
20562
+ // WGSL mat3x3 stores 3 vec3 columns padded to vec4 alignment.
20563
+ const slotBytes = meshUniformAlignment;
20564
+ const slotFloats = slotBytes / Float32Array.BYTES_PER_ELEMENT;
20565
+ const data = new Float32Array(slotFloats);
20566
+ const proj = backend.view.getTransform();
20567
+ const trans = mesh.getGlobalTransform();
20568
+ // mat3 (column-major): [a, c, tx | b, d, ty | 0, 0, 1] in 2D.
20569
+ // WGSL mat3x3 has each column padded to vec4. Store as:
20570
+ // col0 = [a, b, 0, 0] / [c, d, 0, 0] / ...
20571
+ // ExoJS Matrix stores: a, b, c, d, x, y. Standard 2D affine is:
20572
+ // [a c tx]
20573
+ // [b d ty]
20574
+ // [0 0 1 ]
20575
+ // Column-major mat3: col0 = (a, b, 0), col1 = (c, d, 0), col2 = (tx, ty, 1).
20576
+ let off = 0;
20577
+ // projection
20578
+ data[off + 0] = proj.a;
20579
+ data[off + 1] = proj.b;
20580
+ data[off + 2] = 0;
20581
+ data[off + 3] = 0; // pad
20582
+ data[off + 4] = proj.c;
20583
+ data[off + 5] = proj.d;
20584
+ data[off + 6] = 0;
20585
+ data[off + 7] = 0; // pad
20586
+ data[off + 8] = proj.x;
20587
+ data[off + 9] = proj.y;
20588
+ data[off + 10] = 1;
20589
+ data[off + 11] = 0; // pad
20590
+ off += 12;
20591
+ // translation
20592
+ data[off + 0] = trans.a;
20593
+ data[off + 1] = trans.b;
20594
+ data[off + 2] = 0;
20595
+ data[off + 3] = 0;
20596
+ data[off + 4] = trans.c;
20597
+ data[off + 5] = trans.d;
20598
+ data[off + 6] = 0;
20599
+ data[off + 7] = 0;
20600
+ data[off + 8] = trans.x;
20601
+ data[off + 9] = trans.y;
20602
+ data[off + 10] = 1;
20603
+ data[off + 11] = 0;
20604
+ off += 12;
20605
+ // tint (vec4)
20606
+ const tint = mesh.tint;
20607
+ data[off + 0] = tint.red;
20608
+ data[off + 1] = tint.green;
20609
+ data[off + 2] = tint.blue;
20610
+ data[off + 3] = tint.alpha;
20611
+ this._device.queue.writeBuffer(resources.meshUniformBuffer, drawCursor * slotBytes, data);
20612
+ }
20613
+ _getOrCreateCustomPipeline(resources, blendMode, format) {
20614
+ const cacheKey = `${blendMode}:${format}`;
20615
+ let pipeline = resources.pipelines.get(cacheKey);
20616
+ if (pipeline === undefined) {
20617
+ pipeline = this._device.createRenderPipeline({
20618
+ layout: resources.pipelineLayout,
20619
+ vertex: {
20620
+ module: resources.shaderModule,
20621
+ entryPoint: 'vertexMain',
20622
+ buffers: [
20623
+ {
20624
+ arrayStride: vertexStrideBytes$1,
20625
+ stepMode: 'vertex',
20626
+ attributes: [
20627
+ { shaderLocation: 0, offset: 0, format: 'float32x2' },
20628
+ { shaderLocation: 1, offset: 8, format: 'float32x2' },
20629
+ { shaderLocation: 2, offset: 16, format: 'unorm8x4' },
20630
+ ],
20631
+ },
20632
+ ],
20633
+ },
20634
+ fragment: {
20635
+ module: resources.shaderModule,
20636
+ entryPoint: 'fragmentMain',
20637
+ targets: [
20638
+ {
20639
+ format,
20640
+ blend: getWebGpuBlendState(blendMode),
20641
+ writeMask: GPUColorWrite.ALL,
20642
+ },
20643
+ ],
20644
+ },
20645
+ primitive: {
20646
+ topology: 'triangle-list',
20647
+ cullMode: 'none',
20648
+ },
20649
+ });
20650
+ resources.pipelines.set(cacheKey, pipeline);
20651
+ }
20652
+ return pipeline;
20653
+ }
20654
+ _getOrCreateCustomMeshTextureBindGroup(resources, backend, texture) {
20655
+ let group = resources.meshTextureBindGroups.get(texture);
20656
+ if (group === undefined) {
20657
+ const binding = backend.getTextureBinding(texture);
20658
+ group = this._device.createBindGroup({
20659
+ layout: resources.meshTextureLayout,
20660
+ entries: [
20661
+ { binding: 0, resource: binding.view },
20662
+ { binding: 1, resource: binding.sampler },
20663
+ ],
20664
+ });
20665
+ resources.meshTextureBindGroups.set(texture, group);
20666
+ }
20667
+ return group;
20668
+ }
20669
+ _buildUserBindGroupLayout(device, shader) {
20670
+ const entries = [];
20671
+ const userUniforms = shader.uniforms;
20672
+ Object.values(userUniforms).some(v => !isTextureUniform(v));
20673
+ // Binding 0 always reserved for the user UBO (even if empty), so the
20674
+ // bind-group layout is stable across user-uniform mutations.
20675
+ entries.push({
20676
+ binding: 0,
20677
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
20678
+ buffer: { type: 'uniform' },
20679
+ });
20680
+ let bindingIndex = 1;
20681
+ let textureCount = 0;
20682
+ for (const value of Object.values(userUniforms)) {
20683
+ if (!isTextureUniform(value)) {
20684
+ continue;
20685
+ }
20686
+ if (textureCount >= maxCustomTextureSlots) {
20687
+ throw new Error(`MeshShader requested more than ${maxCustomTextureSlots} user texture uniforms.`);
20688
+ }
20689
+ entries.push({
20690
+ binding: bindingIndex,
20691
+ visibility: GPUShaderStage.FRAGMENT,
20692
+ texture: { sampleType: 'float' },
20693
+ });
20694
+ bindingIndex++;
20695
+ entries.push({
20696
+ binding: bindingIndex,
20697
+ visibility: GPUShaderStage.FRAGMENT,
20698
+ sampler: { type: 'filtering' },
20699
+ });
20700
+ bindingIndex++;
20701
+ textureCount++;
20702
+ }
20703
+ return device.createBindGroupLayout({ entries });
20704
+ }
20705
+ _uploadUserUniforms(_shader, resources) {
20706
+ const device = this._device;
20707
+ const uniforms = _shader.uniforms;
20708
+ const scalarValues = Object.values(uniforms).filter(v => !isTextureUniform(v));
20709
+ // Always create a UBO (even if empty) since binding 0 of the user layout
20710
+ // is fixed. Min size 16 bytes to satisfy WebGPU's minimum buffer size.
20711
+ const slotCount = Math.max(scalarValues.length, 1);
20712
+ const bufferBytes = slotCount * 16;
20713
+ if (resources.userUniformBuffer === null || resources.userUniformBufferCapacity < bufferBytes) {
20714
+ resources.userUniformBuffer?.destroy();
20715
+ resources.userUniformBufferCapacity = bufferBytes;
20716
+ resources.userUniformBuffer = device.createBuffer({
20717
+ size: bufferBytes,
20718
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
20719
+ });
20720
+ }
20721
+ const data = new Float32Array(bufferBytes / 4);
20722
+ let slot = 0;
20723
+ for (const value of scalarValues) {
20724
+ const baseFloatIndex = slot * 4;
20725
+ if (typeof value === 'number') {
20726
+ data[baseFloatIndex] = value;
20727
+ }
20728
+ else if (value instanceof Float32Array) {
20729
+ data.set(value, baseFloatIndex);
20730
+ }
20731
+ else if (value instanceof Int32Array) {
20732
+ for (let i = 0; i < value.length; i++) {
20733
+ data[baseFloatIndex + i] = value[i];
20734
+ }
20735
+ }
20736
+ else {
20737
+ const arr = value;
20738
+ for (let i = 0; i < arr.length; i++) {
20739
+ data[baseFloatIndex + i] = arr[i];
20740
+ }
20741
+ }
20742
+ slot++;
20743
+ }
20744
+ device.queue.writeBuffer(resources.userUniformBuffer, 0, data);
20745
+ }
20746
+ _buildUserBindGroup(backend, shader, resources) {
20747
+ const device = this._device;
20748
+ const entries = [];
20749
+ entries.push({ binding: 0, resource: { buffer: resources.userUniformBuffer } });
20750
+ let bindingIndex = 1;
20751
+ for (const value of Object.values(shader.uniforms)) {
20752
+ if (!isTextureUniform(value)) {
20753
+ continue;
20754
+ }
20755
+ const binding = backend.getTextureBinding(value);
20756
+ entries.push({ binding: bindingIndex, resource: binding.view });
20757
+ bindingIndex++;
20758
+ entries.push({ binding: bindingIndex, resource: binding.sampler });
20759
+ bindingIndex++;
20760
+ }
20761
+ return device.createBindGroup({
20762
+ layout: resources.userLayout,
20763
+ entries,
20764
+ });
20765
+ }
20766
+ _releaseCustomShaderResources(resources) {
20767
+ resources.vertexBuffer?.destroy();
20768
+ resources.indexBuffer?.destroy();
20769
+ resources.meshUniformBuffer?.destroy();
20770
+ resources.userUniformBuffer?.destroy();
20771
+ resources.pipelines.clear();
20772
+ resources.meshTextureBindGroups.clear();
20773
+ resources.vertexBuffer = null;
20774
+ resources.indexBuffer = null;
20775
+ resources.meshUniformBuffer = null;
20776
+ resources.userUniformBuffer = null;
20777
+ resources.meshUniformBindGroup = null;
20778
+ resources.vertexBufferCapacity = 0;
20779
+ resources.indexBufferCapacity = 0;
20780
+ resources.meshUniformBufferCapacity = 0;
20781
+ resources.userUniformBufferCapacity = 0;
20782
+ }
20783
+ }
20784
+ function isTextureUniform(value) {
20785
+ return (typeof value === 'object' &&
20786
+ value !== null &&
20787
+ 'width' in value &&
20788
+ 'height' in value &&
20789
+ !(value instanceof Float32Array) &&
20790
+ !(value instanceof Int32Array) &&
20791
+ !Array.isArray(value));
19727
20792
  }
19728
20793
 
19729
20794
  /// <reference types="@webgpu/types" />
@@ -21413,7 +22478,7 @@ class WebGpuBackend {
21413
22478
  width: Math.max(texture.width, 1),
21414
22479
  height: Math.max(texture.height, 1),
21415
22480
  },
21416
- format: managedTextureFormat,
22481
+ format: this._getGpuTextureFormat(texture),
21417
22482
  mipLevelCount: this._getMipLevelCount(texture),
21418
22483
  usage: this._getTextureUsage(texture),
21419
22484
  });
@@ -21437,7 +22502,7 @@ class WebGpuBackend {
21437
22502
  return state;
21438
22503
  }
21439
22504
  _syncTexture(texture) {
21440
- if (!(texture instanceof RenderTexture) && (texture.source === null || texture.width === 0 || texture.height === 0)) {
22505
+ if (!(texture instanceof RenderTexture) && !(texture instanceof DataTexture) && (texture.source === null || texture.width === 0 || texture.height === 0)) {
21441
22506
  throw new Error('WebGPU sprite rendering requires a texture with a valid source and non-zero dimensions.');
21442
22507
  }
21443
22508
  const state = this._getTextureState(texture);
@@ -21451,7 +22516,7 @@ class WebGpuBackend {
21451
22516
  width: texture.width,
21452
22517
  height: texture.height,
21453
22518
  },
21454
- format: managedTextureFormat,
22519
+ format: this._getGpuTextureFormat(texture),
21455
22520
  mipLevelCount,
21456
22521
  usage: this._getTextureUsage(texture),
21457
22522
  });
@@ -21463,7 +22528,34 @@ class WebGpuBackend {
21463
22528
  state.hasContent = false;
21464
22529
  }
21465
22530
  state.sampler = this._createSampler(texture);
21466
- if (!(texture instanceof RenderTexture)) {
22531
+ if (texture instanceof DataTexture) {
22532
+ const formatInfo = webgpuDataTextureFormat(texture.format);
22533
+ const region = texture._consumeDirtyRegion();
22534
+ const isFullUpload = region === null || region.full || !state.hasContent;
22535
+ if (isFullUpload) {
22536
+ this.device.queue.writeTexture({ texture: state.texture }, texture.buffer, {
22537
+ bytesPerRow: texture.width * formatInfo.bytesPerPixel,
22538
+ rowsPerImage: texture.height,
22539
+ }, { width: texture.width, height: texture.height });
22540
+ }
22541
+ else {
22542
+ // Partial upload: pack the dirty region into a contiguous buffer.
22543
+ const channels = formatInfo.channels;
22544
+ const bytesPerPixel = formatInfo.bytesPerPixel;
22545
+ const subBytes = region.width * region.height * bytesPerPixel;
22546
+ const subBuffer = texture.buffer instanceof Float32Array ? new Float32Array(subBytes / 4) : new Uint8Array(subBytes);
22547
+ const rowChannels = texture.width * channels;
22548
+ const subRowChannels = region.width * channels;
22549
+ for (let row = 0; row < region.height; row++) {
22550
+ const sourceStart = (region.y + row) * rowChannels + region.x * channels;
22551
+ const targetStart = row * subRowChannels;
22552
+ subBuffer.set(texture.buffer.subarray(sourceStart, sourceStart + subRowChannels), targetStart);
22553
+ }
22554
+ this.device.queue.writeTexture({ texture: state.texture, origin: { x: region.x, y: region.y } }, subBuffer, { bytesPerRow: region.width * bytesPerPixel, rowsPerImage: region.height }, { width: region.width, height: region.height });
22555
+ }
22556
+ state.hasContent = true;
22557
+ }
22558
+ else if (!(texture instanceof RenderTexture)) {
21467
22559
  const source = texture.source;
21468
22560
  this.device.queue.copyExternalImageToTexture({
21469
22561
  source,
@@ -21546,14 +22638,27 @@ class WebGpuBackend {
21546
22638
  };
21547
22639
  }
21548
22640
  _createSampler(texture) {
22641
+ // Float32 textures (r32float, rgba32float) are non-filterable by default
22642
+ // in WebGPU; force nearest filtering to avoid validation errors. Apps
22643
+ // that need linear filtering on floats can opt into the
22644
+ // 'float32-filterable' device feature and pass linear via samplerOptions
22645
+ // (not yet exposed).
22646
+ const isFloatData = texture instanceof DataTexture && (texture.format === 'r32f' || texture.format === 'rgba32f');
22647
+ const filter = isFloatData ? 'nearest' : this._getFilterMode(texture.scaleMode);
21549
22648
  return this.device.createSampler({
21550
22649
  addressModeU: this._getAddressMode(texture.wrapMode),
21551
22650
  addressModeV: this._getAddressMode(texture.wrapMode),
21552
- magFilter: this._getFilterMode(texture.scaleMode),
21553
- minFilter: this._getFilterMode(texture.scaleMode),
21554
- mipmapFilter: this._getMipmapFilterMode(texture.scaleMode),
22651
+ magFilter: filter,
22652
+ minFilter: filter,
22653
+ mipmapFilter: isFloatData ? 'nearest' : this._getMipmapFilterMode(texture.scaleMode),
21555
22654
  });
21556
22655
  }
22656
+ _getGpuTextureFormat(texture) {
22657
+ if (texture instanceof DataTexture) {
22658
+ return webgpuDataTextureFormat(texture.format).gpuFormat;
22659
+ }
22660
+ return managedTextureFormat;
22661
+ }
21557
22662
  _getTextureUsage(texture) {
21558
22663
  const mipmapUsage = this._getMipLevelCount(texture) > 1 ? GPUTextureUsage.RENDER_ATTACHMENT : 0;
21559
22664
  if (texture instanceof RenderTexture) {
@@ -21747,6 +22852,18 @@ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
21747
22852
  };
21748
22853
  }
21749
22854
  }
22855
+ function webgpuDataTextureFormat(format) {
22856
+ switch (format) {
22857
+ case 'r8':
22858
+ return { gpuFormat: 'r8unorm', bytesPerPixel: 1, channels: 1 };
22859
+ case 'r32f':
22860
+ return { gpuFormat: 'r32float', bytesPerPixel: 4, channels: 1 };
22861
+ case 'rgba8':
22862
+ return { gpuFormat: 'rgba8unorm', bytesPerPixel: 4, channels: 4 };
22863
+ case 'rgba32f':
22864
+ return { gpuFormat: 'rgba32float', bytesPerPixel: 16, channels: 4 };
22865
+ }
22866
+ }
21750
22867
 
21751
22868
  /**
21752
22869
  * Renders an `HTMLVideoElement` as a live texture on a {@link Sprite}.
@@ -28761,10 +29878,14 @@ class WebGpuShaderFilter extends Filter {
28761
29878
  // ---- Build user bind group (group 1) ----
28762
29879
  const userBindGroup = this._buildUserBindGroup(gpu, conn);
28763
29880
  // ---- Encode render pass ----
29881
+ // Labels are picked up by Spector.js and Chrome DevTools' WebGPU
29882
+ // panel and displayed as the pass name in capture views; ignored
29883
+ // by WebGPU at runtime so they cost nothing in release builds.
28764
29884
  const device2 = gpu.device;
28765
- const encoder = device2.createCommandEncoder();
29885
+ const encoder = device2.createCommandEncoder({ label: 'WebGpuShaderFilter' });
28766
29886
  const pass = encoder.beginRenderPass({
28767
29887
  colorAttachments: [gpu.createColorAttachment()],
29888
+ label: 'WebGpuShaderFilter pass',
28768
29889
  });
28769
29890
  gpu.stats.renderPasses++;
28770
29891
  pass.setPipeline(conn.pipeline);
@@ -29268,6 +30389,235 @@ class LutFilter extends Filter {
29268
30389
  }
29269
30390
  }
29270
30391
 
30392
+ /**
30393
+ * Custom shader pair attached to a {@link Mesh}.
30394
+ *
30395
+ * One `MeshShader` instance can be shared across many meshes; renderers
30396
+ * cache compiled programs/pipelines on the instance reference. Call
30397
+ * {@link destroy} when the shader is no longer needed to release the
30398
+ * cached GPU resources on every backend the shader was used on.
30399
+ *
30400
+ * # Vertex layout
30401
+ *
30402
+ * The vertex layout is fixed and shared with the default mesh shader,
30403
+ * so custom vertex shaders MUST pin the standard attribute locations:
30404
+ *
30405
+ * ## GLSL (location-qualified)
30406
+ *
30407
+ * ```glsl
30408
+ * layout(location = 0) in vec2 a_position;
30409
+ * layout(location = 1) in vec2 a_texcoord;
30410
+ * layout(location = 2) in vec4 a_color;
30411
+ * ```
30412
+ *
30413
+ * ## WGSL (location-qualified)
30414
+ *
30415
+ * ```wgsl
30416
+ * struct VertexInput {
30417
+ * @location(0) position: vec2<f32>,
30418
+ * @location(1) texcoord: vec2<f32>,
30419
+ * @location(2) color: vec4<f32>,
30420
+ * };
30421
+ * ```
30422
+ *
30423
+ * # Auto-bound uniforms
30424
+ *
30425
+ * The renderer auto-binds these when the shader declares them. Declared
30426
+ * but unused is fine; absent is fine too. Both backends carry the same
30427
+ * logical uniforms, only the binding scheme differs.
30428
+ *
30429
+ * ## GLSL
30430
+ *
30431
+ * ```glsl
30432
+ * uniform mat3 u_projection; // active view's projection
30433
+ * uniform mat3 u_translation; // mesh's global transform
30434
+ * uniform vec4 u_tint; // mesh.tint as RGBA in 0..1
30435
+ * uniform sampler2D u_texture; // bound to texture slot 0
30436
+ * ```
30437
+ *
30438
+ * ## WGSL
30439
+ *
30440
+ * ```wgsl
30441
+ * struct MeshUniforms {
30442
+ * projection: mat3x3<f32>,
30443
+ * translation: mat3x3<f32>,
30444
+ * tint: vec4<f32>,
30445
+ * };
30446
+ *
30447
+ * @group(0) @binding(0) var<uniform> u_mesh: MeshUniforms;
30448
+ *
30449
+ * @group(1) @binding(0) var u_texture: texture_2d<f32>;
30450
+ * @group(1) @binding(1) var u_sampler: sampler;
30451
+ * ```
30452
+ *
30453
+ * # User uniforms
30454
+ *
30455
+ * Anything in {@link uniforms} is set after the auto-binds. `Texture`/
30456
+ * `RenderTexture` values claim slots 1..N (slot 0 belongs to the mesh).
30457
+ *
30458
+ * ## WGSL user-uniform contract
30459
+ *
30460
+ * User uniforms live in `@group(2)`:
30461
+ *
30462
+ * - `@group(2) @binding(0) var<uniform> u_user: <UserUniformsStruct>;`
30463
+ * for the packed scalar/vector/matrix uniforms.
30464
+ * - `@group(2) @binding(N)` for each `Texture`/`RenderTexture` uniform,
30465
+ * in declaration order, alongside its sampler at `@binding(N+1)`.
30466
+ */
30467
+ class MeshShader {
30468
+ /**
30469
+ * Mutable user uniform values. Mutate between frames to drive animated
30470
+ * effects; the renderer reads from this map every draw.
30471
+ *
30472
+ * shader.uniforms.uTime = performance.now() / 1000;
30473
+ * shader.uniforms.uColor = [1, 0.5, 0, 1];
30474
+ */
30475
+ uniforms;
30476
+ /** GLSL source pair for the WebGL2 backend, or `null` if not provided. */
30477
+ glsl;
30478
+ /** WGSL source for the WebGPU backend, or `null` if not provided. */
30479
+ wgsl;
30480
+ _disposeCallbacks = new Set();
30481
+ constructor(options) {
30482
+ if (options.glsl === undefined && options.wgsl === undefined) {
30483
+ throw new Error('MeshShader requires at least one of `glsl` or `wgsl`.');
30484
+ }
30485
+ if (options.glsl !== undefined) {
30486
+ if (typeof options.glsl.vertex !== 'string' || options.glsl.vertex.length === 0) {
30487
+ throw new Error('MeshShader.glsl.vertex must be a non-empty string.');
30488
+ }
30489
+ if (typeof options.glsl.fragment !== 'string' || options.glsl.fragment.length === 0) {
30490
+ throw new Error('MeshShader.glsl.fragment must be a non-empty string.');
30491
+ }
30492
+ }
30493
+ if (options.wgsl !== undefined && (typeof options.wgsl !== 'string' || options.wgsl.length === 0)) {
30494
+ throw new Error('MeshShader.wgsl must be a non-empty string.');
30495
+ }
30496
+ this.glsl = options.glsl ?? null;
30497
+ this.wgsl = options.wgsl ?? null;
30498
+ this.uniforms = { ...(options.uniforms ?? {}) };
30499
+ }
30500
+ /**
30501
+ * Convenience setter equivalent to `shader.uniforms[name] = value`.
30502
+ * Provided for symmetry with engine APIs that prefer explicit methods
30503
+ * over property mutation.
30504
+ */
30505
+ setUniform(name, value) {
30506
+ this.uniforms[name] = value;
30507
+ }
30508
+ /**
30509
+ * Reflect declared uniforms from each language's source. Returns a per-
30510
+ * language map of uniform-name → declared type, parsed from the shader
30511
+ * sources via lightweight regex (not a full GLSL/WGSL grammar). Texture
30512
+ * uniforms (`sampler2D`/`texture_2d`) are included; sampler bindings
30513
+ * are not (they pair with textures by binding index).
30514
+ *
30515
+ * Reflection is best-effort and intended for CI drift-checks and editor
30516
+ * tooling, not for runtime uniform binding decisions. The renderers do
30517
+ * NOT consult this map; they bind uniforms by name from {@link uniforms}
30518
+ * and let the underlying API resolve declared-but-unused entries.
30519
+ */
30520
+ getDeclaredUniforms() {
30521
+ return {
30522
+ glsl: this.glsl !== null ? parseGlslUniforms(this.glsl.vertex, this.glsl.fragment) : {},
30523
+ wgsl: this.wgsl !== null ? parseWgslUniforms(this.wgsl) : {},
30524
+ };
30525
+ }
30526
+ /**
30527
+ * Compare declared uniform names between the GLSL and WGSL sources.
30528
+ * Returns lists of names declared in only one language. Use in CI to
30529
+ * catch drift when both languages should expose the same logical
30530
+ * uniforms. When only one language is provided, returns empty arrays.
30531
+ *
30532
+ * Auto-bound uniforms (`u_projection`, `u_translation`, `u_tint`,
30533
+ * `u_texture`) are excluded from the comparison since the GLSL source
30534
+ * declares them at the top-level uniform scope while the WGSL source
30535
+ * receives them via the `@group(0)` mesh-uniforms struct and the
30536
+ * `@group(1)` texture binding.
30537
+ */
30538
+ detectUniformDrift() {
30539
+ if (this.glsl === null || this.wgsl === null) {
30540
+ return { onlyInGlsl: [], onlyInWgsl: [] };
30541
+ }
30542
+ const declared = this.getDeclaredUniforms();
30543
+ const glslNames = new Set(Object.keys(declared.glsl).filter(n => !autoBoundUniformNames.has(n)));
30544
+ const wgslNames = new Set(Object.keys(declared.wgsl).filter(n => !autoBoundUniformNames.has(n)));
30545
+ const onlyInGlsl = [];
30546
+ const onlyInWgsl = [];
30547
+ for (const name of glslNames) {
30548
+ if (!wgslNames.has(name))
30549
+ onlyInGlsl.push(name);
30550
+ }
30551
+ for (const name of wgslNames) {
30552
+ if (!glslNames.has(name))
30553
+ onlyInWgsl.push(name);
30554
+ }
30555
+ return { onlyInGlsl, onlyInWgsl };
30556
+ }
30557
+ /**
30558
+ * Release GPU resources cached against this `MeshShader` on every
30559
+ * backend that has compiled it. Safe to call multiple times. After
30560
+ * destroy, the shader can still be re-used — renderers will recompile
30561
+ * on next draw — but typical usage is to drop the reference.
30562
+ */
30563
+ destroy() {
30564
+ for (const callback of this._disposeCallbacks) {
30565
+ callback();
30566
+ }
30567
+ this._disposeCallbacks.clear();
30568
+ }
30569
+ /**
30570
+ * Internal hook for renderers to register a per-shader-instance cleanup
30571
+ * callback (release compiled program, pipeline, or bind groups). The
30572
+ * callback fires on {@link destroy}; renderers MUST also tolerate the
30573
+ * shader being garbage-collected without destroy ever being called.
30574
+ *
30575
+ * @internal
30576
+ */
30577
+ _onDispose(callback) {
30578
+ this._disposeCallbacks.add(callback);
30579
+ }
30580
+ }
30581
+ const autoBoundUniformNames = new Set(['u_projection', 'u_translation', 'u_tint', 'u_texture', 'u_mesh']);
30582
+ const glslUniformPattern = /\buniform\s+(?:mediump\s+|highp\s+|lowp\s+|)(\w+)\s+(\w+)[^;]*;/g;
30583
+ const wgslUserUniformPattern = /@group\(\s*2\s*\)\s*@binding\(\s*\d+\s*\)\s*var(?:<[^>]+>|)\s+(\w+)\s*:\s*([^;]+);/g;
30584
+ /**
30585
+ * Strip line and block comments from a shader source so the uniform
30586
+ * regexes don't match commented-out declarations. Conservative: works
30587
+ * for both GLSL and WGSL syntax (both use `//` and `/* ... *\/`).
30588
+ */
30589
+ function stripComments(source) {
30590
+ return source.replaceAll(/\/\*[\s\S]*?\*\//g, '').replaceAll(/\/\/[^\n]*/g, '');
30591
+ }
30592
+ function parseGlslUniforms(vertex, fragment) {
30593
+ const result = {};
30594
+ for (const source of [vertex, fragment]) {
30595
+ const stripped = stripComments(source);
30596
+ glslUniformPattern.lastIndex = 0;
30597
+ let match;
30598
+ while ((match = glslUniformPattern.exec(stripped)) !== null) {
30599
+ const [, type, name] = match;
30600
+ result[name] = type;
30601
+ }
30602
+ }
30603
+ return result;
30604
+ }
30605
+ function parseWgslUniforms(source) {
30606
+ const result = {};
30607
+ const stripped = stripComments(source);
30608
+ // User uniforms in @group(2). Each user-uniform binding is either:
30609
+ // - var<uniform> u_user: SomeStruct;
30610
+ // - var u_extraTex: texture_2d<f32>;
30611
+ // We extract the name and the (trimmed) type expression.
30612
+ wgslUserUniformPattern.lastIndex = 0;
30613
+ let match;
30614
+ while ((match = wgslUserUniformPattern.exec(stripped)) !== null) {
30615
+ const [, name, type] = match;
30616
+ result[name] = type.trim();
30617
+ }
30618
+ return result;
30619
+ }
30620
+
29271
30621
  /**
29272
30622
  * Immediate-mode 2D shape API backed by {@link Mesh} children.
29273
30623
  *
@@ -30796,5 +32146,5 @@ class NetworkOnlyStrategy {
30796
32146
  }
30797
32147
  }
30798
32148
 
30799
- export { AbstractAssetFactory, AbstractMedia, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AlphaFadeOverLifetime, AnimatedSprite, Application, ApplicationStatus, ApplyForce, ArcadeStickGamepadMapping, AttractToPoint, AudioAnalyser, AudioBus, AudioFilter, AudioListener, AudioManager, BeatDetector, BinaryFactory, BlendModes, BlurFilter, Bounds, BoxArea, BufferTypes, BufferUsage, BundleLoadError, BurstSpawn, CacheFirstStrategy, CallbackRenderPass, Capabilities, ChannelOffset, ChannelSize, ChorusFilter, Circle, CircleArea, Clock, CollisionType, Color, ColorFilter, ColorOverLifetime, ColorOverSpeed, CompressorFilter, ConeDirection, Constant, Container, Curve, DeathModule, DelayFilter, Drag, Drawable, DuckingFilter, DynamicGlyphAtlas, Ease, Ellipse, Envelope, EqualizerFilter, FactoryRegistry, Filter, Flags, FontFactory, GameCubeGamepadMapping, Gamepad, GamepadAxis, GamepadButton, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, Gradient, GranularFilter, Graphics, HighpassFilter, ImageFactory, IndexedDbDatabase, IndexedDbStore, InputBinding, InputManager, InteractionEvent, InteractionManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, Keyboard, Line, LineSegment, Loader, LowpassFilter, LutFilter, Matrix, Mesh, Music, MusicFactory, NetworkOnlyStrategy, ObservableSize, ObservableVector, OrbitalForce, OscillatorSound, ParticleSystem, PitchShiftFilter, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, Random, Range, RateSpawn, Rectangle, RenderBackendType, RenderNode, RenderTarget, RenderTargetPass, RenderTexture, RendererRegistry, RenderingPrimitives, RepelFromPoint, ReverbFilter, RotateOverLifetime, Sampler, ScaleModes, ScaleOverLifetime, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderUniform, Signal, Size, Sound, SoundFactory, SoundPoolStrategy, SpawnModule, SpawnOnDeath, Sprite, SpriteFlags, Spritesheet, SteamControllerGamepadMapping, SteamDeckGamepadMapping, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, Time, Timer, Turbulence, Tween, TweenManager, TweenState, UpdateModule, Vector, VectorRange, VelocityOverLifetime, Video, VideoFactory, View, ViewFlags, VocoderFilter, VoronoiRegion, VttAsset, VttFactory, WasmFactory, WebGl2Backend, WebGl2MeshRenderer, WebGl2ParticleRenderer, WebGl2RenderBuffer, WebGl2ShaderBlock, WebGl2ShaderFilter, WebGl2SpriteRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuMeshRenderer, WebGpuParticleRenderer, WebGpuShaderFilter, WebGpuSpriteRenderer, WorkletFilter, WrapModes, XboxGamepadMapping, bezierCurveTo, buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRenderStats, createWebGl2ShaderProgram, crossFade, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, emptyArrayBuffer, getAudioContext, getAudioManager, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, getCollisionEllipseCircle, getCollisionEllipseRectangle, getCollisionPolygonCircle, getCollisionRectangleRectangle, getCollisionSat, getDistance, getOfflineAudioContext, getPreciseTime, getTextureSourceSize, getVoronoiRegion$1 as getVoronoiRegion, getWebGpuBlendState, hours, inRange, intersectionCircleCircle, intersectionCircleEllipse, intersectionCirclePoly, intersectionEllipseEllipse, intersectionEllipsePoly, intersectionLineCircle, intersectionLineEllipse, intersectionLineLine, intersectionLinePoly, intersectionLineRect, intersectionPointCircle, intersectionPointEllipse, intersectionPointLine, intersectionPointPoint, intersectionPointPoly, intersectionPointRect, intersectionPolyPoly, intersectionRectCircle, intersectionRectEllipse, intersectionRectPoly, intersectionRectRect, intersectionSat, isAudioContextReady, isPowerOfTwo, layoutText, lerp, matchesIds, maxPointers, milliseconds, minutes, noop$1 as noop, onAudioContextReady, parseGamepadDescriptor, pointerSlotSize, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, registerWorkletProcessor, removeArrayItems, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign, stopEvent, substepSweep, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, sweepCircleAgainst, sweepCircleVsCircle, sweepCircleVsRectangle, sweepRectangle, sweepRectangleAgainst, tau$2 as tau, trimRotation, upgradeFragmentShaderToGl300, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames, wgslFieldLayout, wgslUniformByteSize };
32149
+ export { AbstractAssetFactory, AbstractMedia, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AlphaFadeOverLifetime, AnimatedSprite, Application, ApplicationStatus, ApplyForce, ArcadeStickGamepadMapping, AttractToPoint, AudioAnalyser, AudioBus, AudioFilter, AudioListener, AudioManager, BeatDetector, BinaryFactory, BlendModes, BlurFilter, Bounds, BoxArea, BufferTypes, BufferUsage, BundleLoadError, BurstSpawn, CacheFirstStrategy, CallbackRenderPass, Capabilities, ChannelOffset, ChannelSize, ChorusFilter, Circle, CircleArea, Clock, CollisionType, Color, ColorFilter, ColorOverLifetime, ColorOverSpeed, CompressorFilter, ConeDirection, Constant, Container, Curve, DataTexture, DeathModule, DelayFilter, Drag, Drawable, DuckingFilter, DynamicGlyphAtlas, Ease, Ellipse, Envelope, EqualizerFilter, FactoryRegistry, Filter, Flags, FontFactory, GameCubeGamepadMapping, Gamepad, GamepadAxis, GamepadButton, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, Gradient, GranularFilter, Graphics, HighpassFilter, ImageFactory, IndexedDbDatabase, IndexedDbStore, InputBinding, InputManager, InteractionEvent, InteractionManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, Keyboard, Line, LineSegment, Loader, LowpassFilter, LutFilter, Matrix, Mesh, MeshShader, Music, MusicFactory, NetworkOnlyStrategy, ObservableSize, ObservableVector, OrbitalForce, OscillatorSound, ParticleSystem, PitchShiftFilter, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, Random, Range, RateSpawn, Rectangle, RenderBackendType, RenderNode, RenderTarget, RenderTargetPass, RenderTexture, RendererRegistry, RenderingPrimitives, RepelFromPoint, ReverbFilter, RotateOverLifetime, Sampler, ScaleModes, ScaleOverLifetime, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderUniform, Signal, Size, Sound, SoundFactory, SoundPoolStrategy, SpawnModule, SpawnOnDeath, Sprite, SpriteFlags, Spritesheet, SteamControllerGamepadMapping, SteamDeckGamepadMapping, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, Time, Timer, Turbulence, Tween, TweenManager, TweenState, UpdateModule, Vector, VectorRange, VelocityOverLifetime, Video, VideoFactory, View, ViewFlags, VocoderFilter, VoronoiRegion, VttAsset, VttFactory, WasmFactory, WebGl2Backend, WebGl2MeshRenderer, WebGl2ParticleRenderer, WebGl2RenderBuffer, WebGl2ShaderBlock, WebGl2ShaderFilter, WebGl2SpriteRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuMeshRenderer, WebGpuParticleRenderer, WebGpuShaderFilter, WebGpuSpriteRenderer, WorkletFilter, WrapModes, XboxGamepadMapping, bezierCurveTo, buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRenderStats, createWebGl2ShaderProgram, crossFade, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, emptyArrayBuffer, getAudioContext, getAudioManager, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, getCollisionEllipseCircle, getCollisionEllipseRectangle, getCollisionPolygonCircle, getCollisionRectangleRectangle, getCollisionSat, getDistance, getOfflineAudioContext, getPreciseTime, getTextureSourceSize, getVoronoiRegion$1 as getVoronoiRegion, getWebGpuBlendState, hours, inRange, intersectionCircleCircle, intersectionCircleEllipse, intersectionCirclePoly, intersectionEllipseEllipse, intersectionEllipsePoly, intersectionLineCircle, intersectionLineEllipse, intersectionLineLine, intersectionLinePoly, intersectionLineRect, intersectionPointCircle, intersectionPointEllipse, intersectionPointLine, intersectionPointPoint, intersectionPointPoly, intersectionPointRect, intersectionPolyPoly, intersectionRectCircle, intersectionRectEllipse, intersectionRectPoly, intersectionRectRect, intersectionSat, isAudioContextReady, isPowerOfTwo, layoutText, lerp, matchesIds, maxPointers, milliseconds, minutes, noop$1 as noop, onAudioContextReady, parseGamepadDescriptor, pointerSlotSize, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, registerWorkletProcessor, removeArrayItems, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign, stopEvent, substepSweep, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, sweepCircleAgainst, sweepCircleVsCircle, sweepCircleVsRectangle, sweepRectangle, sweepRectangleAgainst, tau$2 as tau, trimRotation, upgradeFragmentShaderToGl300, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames, wgslFieldLayout, wgslUniformByteSize };
30800
32150
  //# sourceMappingURL=exo.esm.js.map