@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.
- package/CHANGELOG.md +72 -0
- package/dist/esm/audio/AudioAnalyser.d.ts +36 -0
- package/dist/esm/audio/AudioAnalyser.js +148 -0
- package/dist/esm/audio/AudioAnalyser.js.map +1 -1
- package/dist/esm/audio/BeatDetector.d.ts +62 -0
- package/dist/esm/audio/BeatDetector.js +77 -0
- package/dist/esm/audio/BeatDetector.js.map +1 -1
- package/dist/esm/audio/dsp/mel.js +70 -0
- package/dist/esm/audio/dsp/mel.js.map +1 -0
- package/dist/esm/debug/RenderPassInspectorLayer.d.ts +71 -0
- package/dist/esm/debug/RenderPassInspectorLayer.js +201 -0
- package/dist/esm/debug/RenderPassInspectorLayer.js.map +1 -0
- package/dist/esm/debug/index.d.ts +1 -0
- package/dist/esm/debug/index.js +1 -0
- package/dist/esm/debug/index.js.map +1 -1
- package/dist/esm/index.js +2 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/rendering/filters/WebGpuShaderFilter.js +5 -1
- package/dist/esm/rendering/filters/WebGpuShaderFilter.js.map +1 -1
- package/dist/esm/rendering/index.d.ts +2 -0
- package/dist/esm/rendering/mesh/Mesh.d.ts +4 -47
- package/dist/esm/rendering/mesh/Mesh.js.map +1 -1
- package/dist/esm/rendering/mesh/MeshShader.d.ts +183 -0
- package/dist/esm/rendering/mesh/MeshShader.js +231 -0
- package/dist/esm/rendering/mesh/MeshShader.js.map +1 -0
- package/dist/esm/rendering/texture/DataTexture.d.ts +115 -0
- package/dist/esm/rendering/texture/DataTexture.js +173 -0
- package/dist/esm/rendering/texture/DataTexture.js.map +1 -0
- package/dist/esm/rendering/webgl2/WebGl2Backend.js +42 -1
- package/dist/esm/rendering/webgl2/WebGl2Backend.js.map +1 -1
- package/dist/esm/rendering/webgl2/WebGl2MeshRenderer.js +12 -1
- package/dist/esm/rendering/webgl2/WebGl2MeshRenderer.js.map +1 -1
- package/dist/esm/rendering/webgpu/WebGpuBackend.d.ts +1 -0
- package/dist/esm/rendering/webgpu/WebGpuBackend.js +60 -7
- package/dist/esm/rendering/webgpu/WebGpuBackend.js.map +1 -1
- package/dist/esm/rendering/webgpu/WebGpuMaskCompositor.js +2 -1
- package/dist/esm/rendering/webgpu/WebGpuMaskCompositor.js.map +1 -1
- package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.d.ts +13 -0
- package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.js +636 -83
- package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.js.map +1 -1
- package/dist/exo.esm.js +1452 -102
- package/dist/exo.esm.js.map +1 -1
- 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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
19374
|
-
|
|
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
|
-
//
|
|
19384
|
-
//
|
|
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
|
-
|
|
19388
|
-
|
|
19389
|
-
|
|
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.
|
|
19963
|
+
this._resetFrame();
|
|
19428
19964
|
return;
|
|
19429
19965
|
}
|
|
19430
|
-
// Phase 1: compute layout offsets
|
|
19431
|
-
|
|
19432
|
-
let
|
|
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.
|
|
19436
|
-
|
|
19437
|
-
|
|
19438
|
-
|
|
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
|
|
19456
|
-
|
|
19457
|
-
|
|
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
|
-
|
|
19461
|
-
|
|
19462
|
-
|
|
19463
|
-
|
|
19464
|
-
|
|
19465
|
-
|
|
19466
|
-
|
|
19467
|
-
|
|
19468
|
-
|
|
19469
|
-
|
|
19470
|
-
|
|
19471
|
-
|
|
19472
|
-
|
|
19473
|
-
|
|
19474
|
-
|
|
19475
|
-
|
|
19476
|
-
|
|
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
|
-
|
|
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.
|
|
19491
|
-
|
|
19492
|
-
|
|
19493
|
-
|
|
19494
|
-
|
|
19495
|
-
|
|
19496
|
-
|
|
19497
|
-
|
|
19498
|
-
|
|
19499
|
-
|
|
19500
|
-
|
|
19501
|
-
|
|
19502
|
-
|
|
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.
|
|
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
|
-
|
|
19592
|
-
|
|
19593
|
-
|
|
19594
|
-
|
|
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
|
-
|
|
19606
|
-
|
|
19607
|
-
|
|
19608
|
-
const
|
|
19609
|
-
const
|
|
19610
|
-
|
|
19611
|
-
|
|
19612
|
-
|
|
19613
|
-
|
|
19614
|
-
|
|
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:
|
|
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:
|
|
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 (
|
|
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:
|
|
21553
|
-
minFilter:
|
|
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
|