@codexo/exojs 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/esm/audio/AudioAnalyser.d.ts +36 -0
  3. package/dist/esm/audio/AudioAnalyser.js +148 -0
  4. package/dist/esm/audio/AudioAnalyser.js.map +1 -1
  5. package/dist/esm/audio/BeatDetector.d.ts +62 -0
  6. package/dist/esm/audio/BeatDetector.js +77 -0
  7. package/dist/esm/audio/BeatDetector.js.map +1 -1
  8. package/dist/esm/audio/dsp/mel.js +70 -0
  9. package/dist/esm/audio/dsp/mel.js.map +1 -0
  10. package/dist/esm/debug/RenderPassInspectorLayer.d.ts +71 -0
  11. package/dist/esm/debug/RenderPassInspectorLayer.js +201 -0
  12. package/dist/esm/debug/RenderPassInspectorLayer.js.map +1 -0
  13. package/dist/esm/debug/index.d.ts +1 -0
  14. package/dist/esm/debug/index.js +1 -0
  15. package/dist/esm/debug/index.js.map +1 -1
  16. package/dist/esm/index.js +2 -0
  17. package/dist/esm/index.js.map +1 -1
  18. package/dist/esm/rendering/filters/WebGpuShaderFilter.js +5 -1
  19. package/dist/esm/rendering/filters/WebGpuShaderFilter.js.map +1 -1
  20. package/dist/esm/rendering/index.d.ts +2 -0
  21. package/dist/esm/rendering/mesh/Mesh.d.ts +4 -47
  22. package/dist/esm/rendering/mesh/Mesh.js.map +1 -1
  23. package/dist/esm/rendering/mesh/MeshShader.d.ts +183 -0
  24. package/dist/esm/rendering/mesh/MeshShader.js +231 -0
  25. package/dist/esm/rendering/mesh/MeshShader.js.map +1 -0
  26. package/dist/esm/rendering/texture/DataTexture.d.ts +115 -0
  27. package/dist/esm/rendering/texture/DataTexture.js +173 -0
  28. package/dist/esm/rendering/texture/DataTexture.js.map +1 -0
  29. package/dist/esm/rendering/webgl2/WebGl2Backend.js +42 -1
  30. package/dist/esm/rendering/webgl2/WebGl2Backend.js.map +1 -1
  31. package/dist/esm/rendering/webgl2/WebGl2MeshRenderer.js +12 -1
  32. package/dist/esm/rendering/webgl2/WebGl2MeshRenderer.js.map +1 -1
  33. package/dist/esm/rendering/webgpu/WebGpuBackend.d.ts +1 -0
  34. package/dist/esm/rendering/webgpu/WebGpuBackend.js +60 -7
  35. package/dist/esm/rendering/webgpu/WebGpuBackend.js.map +1 -1
  36. package/dist/esm/rendering/webgpu/WebGpuMaskCompositor.js +2 -1
  37. package/dist/esm/rendering/webgpu/WebGpuMaskCompositor.js.map +1 -1
  38. package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.d.ts +13 -0
  39. package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.js +636 -83
  40. package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.js.map +1 -1
  41. package/dist/exo.esm.js +1452 -102
  42. package/dist/exo.esm.js.map +1 -1
  43. package/package.json +1 -1
@@ -48,16 +48,27 @@ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
48
48
  }
49
49
  `;
50
50
  // Per-vertex layout (20 bytes): pos f32x2 + uv f32x2 + color u8x4-norm.
51
- // CPU bakes the (view * globalTransform) into position so the vertex
52
- // shader stays branchless and uniform-free except for the per-mesh tint.
51
+ // Default-shader path bakes the (view * globalTransform) into position so the
52
+ // vertex shader stays branchless and uniform-free except for the per-mesh tint.
53
+ // Custom-shader path keeps positions in LOCAL space — the user's vertex
54
+ // shader receives mesh transforms via the auto-bound u_mesh uniform block.
53
55
  const vertexStrideBytes = 20;
54
56
  const wordsPerVertex = vertexStrideBytes / 4;
55
57
  const tintByteLength = 32; // vec4 tint + vec4 flags (only flags.x used)
58
+ // Custom-shader uniform layout:
59
+ // mat3x3<f32> projection — 48 bytes (3 vec3 columns padded to vec4 in WGSL)
60
+ // mat3x3<f32> translation — 48 bytes
61
+ // vec4<f32> tint — 16 bytes
62
+ // Total: 112 bytes; aligned up to 256 for dynamic offset.
63
+ const customMeshUniformBytes = 112;
64
+ const meshUniformAlignment = 256;
65
+ const maxCustomTextureSlots = 7; // user texture uniforms; group 2 binding 1..N
56
66
  class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
57
67
  _combinedTransform = new Matrix();
58
68
  _drawCalls = [];
59
69
  _pipelines = new Map();
60
70
  _textureBindGroups = new Map();
71
+ _customShaders = new Map();
61
72
  _device = null;
62
73
  _shaderModule = null;
63
74
  _uniformBindGroupLayout = null;
@@ -81,8 +92,9 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
81
92
  if (backend === null) {
82
93
  throw new Error('WebGpuMeshRenderer is not connected to a backend.');
83
94
  }
84
- if (mesh.shader !== null) {
85
- throw new Error('Mesh custom shaders are currently WebGL2-only. WebGPU support is planned for a future release; in the meantime use the WebGL2 backend or omit `shader` from the Mesh options.');
95
+ const customShader = mesh.shader;
96
+ if (customShader !== null && customShader.wgsl === null) {
97
+ throw new Error('MeshShader has no `wgsl` source; cannot render through the WebGPU backend.');
86
98
  }
87
99
  const vertexCount = mesh.vertexCount;
88
100
  if (vertexCount === 0) {
@@ -91,15 +103,26 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
91
103
  const blendMode = mesh.blendMode;
92
104
  backend.setBlendMode(blendMode);
93
105
  const meshTexture = mesh.texture ?? Texture.white;
94
- // Texture.white is a 1x1 canvas-backed Texture; backend.shouldPremultiplyTextureSample
95
- // expects RenderTexture-or-Texture. Both branches are valid here.
106
+ // backend.shouldPremultiplyTextureSample expects RenderTexture-or-Texture.
107
+ // Both branches are valid here. Premultiply flag is ignored by custom
108
+ // shaders (they handle premultiplication themselves), but we still record
109
+ // it so the default path uses the right value.
96
110
  const premultiplySample = backend.shouldPremultiplyTextureSample(meshTexture);
97
111
  const indexCount = mesh.indexCount;
98
- // Plan offsets within the shared per-frame buffers; actual data
99
- // packing happens in flush() after all drawcalls are known so a
100
- // single writeBuffer per resource covers the whole frame.
112
+ let customDrawIndex = -1;
113
+ if (customShader !== null) {
114
+ const resources = this._getOrCreateCustomShaderResources(customShader);
115
+ customDrawIndex = resources.drawCount;
116
+ resources.drawCount++;
117
+ resources.totalVertices += vertexCount;
118
+ resources.totalIndices += indexCount;
119
+ }
120
+ // Plan offsets within the shared (default) or per-shader (custom) buffers;
121
+ // actual data packing happens in flush() after all drawcalls are known so
122
+ // a single writeBuffer per resource covers the whole frame.
101
123
  const drawCall = {
102
124
  mesh,
125
+ customShader,
103
126
  blendMode,
104
127
  texture: meshTexture,
105
128
  premultiplySample,
@@ -107,6 +130,7 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
107
130
  vertexCount,
108
131
  indexByteOffset: 0,
109
132
  indexCount,
133
+ customDrawIndex,
110
134
  };
111
135
  // Use mutable record (interface readonly is for type safety against
112
136
  // callers; the renderer fills these slots in flush()).
@@ -135,88 +159,194 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
135
159
  pass.end();
136
160
  backend.submit(encoder.finish());
137
161
  }
138
- this._drawCallCount = 0;
162
+ this._resetFrame();
139
163
  return;
140
164
  }
141
- // Phase 1: compute layout offsets for the whole frame.
142
- let totalVertices = 0;
143
- let totalIndices = 0;
165
+ // Phase 1: compute layout offsets (default vs. custom paths use separate
166
+ // buffers, so default offsets are independent of custom offsets).
167
+ let defaultVertices = 0;
168
+ let defaultIndices = 0;
169
+ const customVertexCursors = new Map(); // running vertex count per shader
170
+ const customIndexCursors = new Map();
144
171
  for (let i = 0; i < this._drawCallCount; i++) {
145
172
  const dc = this._drawCalls[i];
146
- dc.vertexByteOffset = totalVertices * vertexStrideBytes;
147
- dc.indexByteOffset = totalIndices * Uint16Array.BYTES_PER_ELEMENT;
148
- totalVertices += dc.vertexCount;
149
- totalIndices += dc.indexCount;
150
- }
151
- // Phase 2: ensure capacities for the totals.
152
- this._ensureVertexCapacity(totalVertices);
153
- this._ensureIndexCapacity(totalIndices);
154
- this._ensureUniformCapacity(this._drawCallCount);
155
- // Phase 3: pack vertex + index + uniform CPU-side data.
156
- const uniformBytes = this._drawCallCount * this._uniformAlignment;
157
- const uniformData = new ArrayBuffer(uniformBytes);
158
- const uniformF32 = new Float32Array(uniformData);
173
+ if (dc.customShader === null) {
174
+ dc.vertexByteOffset = defaultVertices * vertexStrideBytes;
175
+ dc.indexByteOffset = defaultIndices * Uint16Array.BYTES_PER_ELEMENT;
176
+ defaultVertices += dc.vertexCount;
177
+ defaultIndices += dc.indexCount;
178
+ }
179
+ else {
180
+ const vCursor = customVertexCursors.get(dc.customShader) ?? 0;
181
+ const iCursor = customIndexCursors.get(dc.customShader) ?? 0;
182
+ dc.vertexByteOffset = vCursor * vertexStrideBytes;
183
+ dc.indexByteOffset = iCursor * Uint16Array.BYTES_PER_ELEMENT;
184
+ customVertexCursors.set(dc.customShader, vCursor + dc.vertexCount);
185
+ customIndexCursors.set(dc.customShader, iCursor + dc.indexCount);
186
+ }
187
+ }
188
+ // Phase 2: ensure capacities for the totals (default path).
189
+ this._ensureVertexCapacity(defaultVertices);
190
+ this._ensureIndexCapacity(defaultIndices);
191
+ // Default-path uniform buffer holds (tint vec4 + flags vec4) per draw call;
192
+ // each custom-shader resource manages its own.
193
+ const defaultDrawCalls = this._drawCallCount - this._totalCustomDraws();
194
+ this._ensureUniformCapacity(defaultDrawCalls);
195
+ // Phase 3: pack default-path vertex/index/uniform data.
196
+ const defaultUniformBytes = defaultDrawCalls * this._uniformAlignment;
197
+ const defaultUniformData = defaultUniformBytes > 0 ? new ArrayBuffer(defaultUniformBytes) : null;
198
+ const defaultUniformF32 = defaultUniformData !== null ? new Float32Array(defaultUniformData) : null;
199
+ let defaultUniformIndex = 0;
159
200
  for (let i = 0; i < this._drawCallCount; i++) {
160
201
  const dc = this._drawCalls[i];
161
- this._writeMeshVertices(backend, dc.mesh, dc.vertexByteOffset / vertexStrideBytes);
162
- if (dc.mesh.indices !== null) {
163
- this._packedIndexData.set(dc.mesh.indices, dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT);
202
+ if (dc.customShader === null) {
203
+ // Default path: CPU-bake transform into vertex positions.
204
+ this._writeMeshVertices(backend, dc.mesh, dc.vertexByteOffset / vertexStrideBytes, /* bake */ true);
205
+ if (dc.mesh.indices !== null) {
206
+ this._packedIndexData.set(dc.mesh.indices, dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT);
207
+ }
208
+ else {
209
+ const start = dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT;
210
+ for (let j = 0; j < dc.indexCount; j++) {
211
+ this._packedIndexData[start + j] = j;
212
+ }
213
+ }
214
+ // Pack tint+flags for default path.
215
+ if (defaultUniformF32 !== null) {
216
+ const offsetWords = (defaultUniformIndex * this._uniformAlignment) / Float32Array.BYTES_PER_ELEMENT;
217
+ const tint = dc.mesh.tint;
218
+ defaultUniformF32[offsetWords + 0] = tint.red;
219
+ defaultUniformF32[offsetWords + 1] = tint.green;
220
+ defaultUniformF32[offsetWords + 2] = tint.blue;
221
+ defaultUniformF32[offsetWords + 3] = tint.alpha;
222
+ defaultUniformF32[offsetWords + 4] = dc.premultiplySample ? 1 : 0;
223
+ defaultUniformF32[offsetWords + 5] = 0;
224
+ defaultUniformF32[offsetWords + 6] = 0;
225
+ defaultUniformF32[offsetWords + 7] = 0;
226
+ }
227
+ defaultUniformIndex++;
164
228
  }
165
- else {
166
- const start = dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT;
167
- for (let j = 0; j < dc.indexCount; j++) {
168
- this._packedIndexData[start + j] = j;
229
+ }
230
+ // Phase 3b: pack custom-path vertex/index/uniform data per shader.
231
+ for (const [shader, resources] of this._customShaders) {
232
+ if (resources.drawCount === 0) {
233
+ continue;
234
+ }
235
+ this._ensureCustomCapacities(resources);
236
+ // Pack vertices/indices in local space (no CPU bake).
237
+ let vWritten = 0;
238
+ let iWritten = 0;
239
+ let drawCursor = 0;
240
+ for (let i = 0; i < this._drawCallCount; i++) {
241
+ const dc = this._drawCalls[i];
242
+ if (dc.customShader !== shader)
243
+ continue;
244
+ this._writeMeshVerticesIntoBuffer(dc.mesh, vWritten, resources.vertexFloatView, resources.vertexUintView);
245
+ if (dc.mesh.indices !== null) {
246
+ resources.indexData.set(dc.mesh.indices, iWritten);
247
+ }
248
+ else {
249
+ for (let j = 0; j < dc.indexCount; j++) {
250
+ resources.indexData[iWritten + j] = j;
251
+ }
169
252
  }
253
+ // Write mesh-uniform slot (proj/trans/tint) with dynamic offset.
254
+ this._writeCustomMeshUniform(shader, resources, drawCursor, dc.mesh, backend);
255
+ vWritten += dc.vertexCount;
256
+ iWritten += dc.indexCount;
257
+ drawCursor++;
170
258
  }
171
- const uniformOffsetWords = (i * this._uniformAlignment) / Float32Array.BYTES_PER_ELEMENT;
172
- const tint = dc.mesh.tint;
173
- uniformF32[uniformOffsetWords + 0] = tint.red;
174
- uniformF32[uniformOffsetWords + 1] = tint.green;
175
- uniformF32[uniformOffsetWords + 2] = tint.blue;
176
- uniformF32[uniformOffsetWords + 3] = tint.alpha;
177
- uniformF32[uniformOffsetWords + 4] = dc.premultiplySample ? 1 : 0;
178
- uniformF32[uniformOffsetWords + 5] = 0;
179
- uniformF32[uniformOffsetWords + 6] = 0;
180
- uniformF32[uniformOffsetWords + 7] = 0;
181
- }
182
- // Phase 4: single writeBuffer per resource for the whole frame.
183
- device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, totalVertices * vertexStrideBytes);
184
- device.queue.writeBuffer(this._indexBuffer, 0, this._packedIndexData.buffer, this._packedIndexData.byteOffset, totalIndices * Uint16Array.BYTES_PER_ELEMENT);
185
- device.queue.writeBuffer(this._uniformBuffer, 0, uniformData, 0, uniformBytes);
186
- // Phase 5: single render pass with one drawIndexed per mesh.
187
- const encoder = device.createCommandEncoder();
259
+ device.queue.writeBuffer(resources.vertexBuffer, 0, resources.vertexData, 0, resources.totalVertices * vertexStrideBytes);
260
+ device.queue.writeBuffer(resources.indexBuffer, 0, resources.indexData.buffer, resources.indexData.byteOffset, resources.totalIndices * Uint16Array.BYTES_PER_ELEMENT);
261
+ // Build/refresh user uniform UBO from shader.uniforms (re-built every
262
+ // frame so mutations to shader.uniforms.X are picked up).
263
+ this._uploadUserUniforms(shader, resources);
264
+ }
265
+ // Phase 4: single writeBuffer per resource for the default path.
266
+ if (defaultVertices > 0) {
267
+ device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, defaultVertices * vertexStrideBytes);
268
+ device.queue.writeBuffer(this._indexBuffer, 0, this._packedIndexData.buffer, this._packedIndexData.byteOffset, defaultIndices * Uint16Array.BYTES_PER_ELEMENT);
269
+ }
270
+ if (defaultUniformData !== null) {
271
+ device.queue.writeBuffer(this._uniformBuffer, 0, defaultUniformData, 0, defaultUniformBytes);
272
+ }
273
+ // Phase 5: single render pass with one drawIndexed per mesh, switching
274
+ // pipeline+bind groups between default and custom paths as needed.
275
+ const encoder = device.createCommandEncoder({ label: 'WebGpuMeshRenderer' });
188
276
  const pass = encoder.beginRenderPass({
189
277
  colorAttachments: [backend.createColorAttachment()],
278
+ label: 'WebGpuMeshRenderer pass',
190
279
  });
191
280
  backend.stats.renderPasses++;
192
281
  if (scissor !== null) {
193
282
  pass.setScissorRect(scissor.x, scissor.y, scissor.width, scissor.height);
194
283
  }
284
+ const renderTargetFormat = backend.renderTargetFormat;
285
+ let lastShader = null;
195
286
  let lastBlendMode = null;
196
287
  let lastFormat = null;
197
288
  let lastTexture = null;
198
- const renderTargetFormat = backend.renderTargetFormat;
289
+ let defaultDrawCursor = 0;
290
+ const customDrawCursors = new Map();
199
291
  for (let i = 0; i < this._drawCallCount; i++) {
200
292
  const dc = this._drawCalls[i];
201
- if (dc.blendMode !== lastBlendMode || renderTargetFormat !== lastFormat) {
202
- lastBlendMode = dc.blendMode;
203
- lastFormat = renderTargetFormat;
204
- pass.setPipeline(this._getPipeline({ blendMode: dc.blendMode, format: renderTargetFormat }));
293
+ if (dc.customShader === null) {
294
+ // ----- Default path -----
295
+ const needsPipeline = lastShader !== 'default' || dc.blendMode !== lastBlendMode || renderTargetFormat !== lastFormat;
296
+ if (needsPipeline) {
297
+ pass.setPipeline(this._getPipeline({ blendMode: dc.blendMode, format: renderTargetFormat }));
298
+ lastShader = 'default';
299
+ lastBlendMode = dc.blendMode;
300
+ lastFormat = renderTargetFormat;
301
+ // Pipeline switch invalidates bind group state assumptions.
302
+ lastTexture = null;
303
+ }
304
+ pass.setBindGroup(0, this._uniformBindGroup, [defaultDrawCursor * this._uniformAlignment]);
305
+ if (dc.texture !== lastTexture) {
306
+ lastTexture = dc.texture;
307
+ pass.setBindGroup(1, this._getTextureBindGroup(backend, dc.texture));
308
+ }
309
+ pass.setVertexBuffer(0, this._vertexBuffer, dc.vertexByteOffset);
310
+ pass.setIndexBuffer(this._indexBuffer, 'uint16', dc.indexByteOffset);
311
+ pass.drawIndexed(dc.indexCount);
312
+ defaultDrawCursor++;
205
313
  }
206
- pass.setBindGroup(0, this._uniformBindGroup, [i * this._uniformAlignment]);
207
- if (dc.texture !== lastTexture) {
208
- lastTexture = dc.texture;
209
- pass.setBindGroup(1, this._getTextureBindGroup(backend, dc.texture));
314
+ else {
315
+ // ----- Custom path -----
316
+ const resources = this._customShaders.get(dc.customShader);
317
+ const needsPipeline = lastShader !== dc.customShader || dc.blendMode !== lastBlendMode || renderTargetFormat !== lastFormat;
318
+ // Wrap each custom-shader draw in a debug group so capture tools
319
+ // (Spector.js, Chrome DevTools' WebGPU panel) show meaningful
320
+ // labels for the otherwise-anonymous mesh draws inside the
321
+ // batched render pass.
322
+ pass.pushDebugGroup('MeshShader (custom)');
323
+ if (needsPipeline) {
324
+ pass.setPipeline(this._getOrCreateCustomPipeline(resources, dc.blendMode, renderTargetFormat));
325
+ lastShader = dc.customShader;
326
+ lastBlendMode = dc.blendMode;
327
+ lastFormat = renderTargetFormat;
328
+ lastTexture = null;
329
+ // User bind group is shader-scoped; rebind once per shader switch.
330
+ pass.setBindGroup(2, this._buildUserBindGroup(backend, dc.customShader, resources));
331
+ }
332
+ const cursor = customDrawCursors.get(dc.customShader) ?? 0;
333
+ pass.setBindGroup(0, resources.meshUniformBindGroup, [cursor * meshUniformAlignment]);
334
+ if (dc.texture !== lastTexture) {
335
+ lastTexture = dc.texture;
336
+ pass.setBindGroup(1, this._getOrCreateCustomMeshTextureBindGroup(resources, backend, dc.texture));
337
+ }
338
+ pass.setVertexBuffer(0, resources.vertexBuffer, dc.vertexByteOffset);
339
+ pass.setIndexBuffer(resources.indexBuffer, 'uint16', dc.indexByteOffset);
340
+ pass.drawIndexed(dc.indexCount);
341
+ pass.popDebugGroup();
342
+ customDrawCursors.set(dc.customShader, cursor + 1);
210
343
  }
211
- pass.setVertexBuffer(0, this._vertexBuffer, dc.vertexByteOffset);
212
- pass.setIndexBuffer(this._indexBuffer, 'uint16', dc.indexByteOffset);
213
- pass.drawIndexed(dc.indexCount);
214
344
  backend.stats.batches++;
215
345
  backend.stats.drawCalls++;
216
346
  }
217
347
  pass.end();
218
348
  backend.submit(encoder.finish());
219
- this._drawCallCount = 0;
349
+ this._resetFrame();
220
350
  }
221
351
  destroy() {
222
352
  this.disconnect();
@@ -292,6 +422,15 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
292
422
  this._textureBindGroupLayout = null;
293
423
  this._uniformBindGroupLayout = null;
294
424
  this._shaderModule = null;
425
+ // Custom shaders are owned by user code (one MeshShader can be shared
426
+ // across multiple Mesh instances). Their resources are released when the
427
+ // user calls shader.destroy(), which fires our _onDispose callback. On
428
+ // backend disconnect we eagerly release everything to avoid GPU leaks
429
+ // even if the user keeps the shader reference around.
430
+ for (const resources of this._customShaders.values()) {
431
+ this._releaseCustomShaderResources(resources);
432
+ }
433
+ this._customShaders.clear();
295
434
  this._device = null;
296
435
  this._backend = null;
297
436
  this._drawCallCount = 0;
@@ -299,30 +438,47 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
299
438
  this._indexBufferCapacity = 0;
300
439
  this._uniformBufferCapacity = 0;
301
440
  }
302
- _writeMeshVertices(backend, mesh, vertexStart) {
303
- // Bake (view * globalTransform) into vertex positions on the CPU,
304
- // matching the primitive renderer's no-uniforms approach.
305
- const matrix = this._combinedTransform.copy(mesh.getGlobalTransform()).combine(backend.view.getTransform());
306
- const a = matrix.a;
307
- const b = matrix.b;
308
- const c = matrix.c;
309
- const d = matrix.d;
310
- const tx = matrix.x;
311
- const ty = matrix.y;
441
+ // ---------------------------------------------------------------------------
442
+ // Default-path helpers
443
+ // ---------------------------------------------------------------------------
444
+ _writeMeshVertices(backend, mesh, vertexStart, bake) {
312
445
  const vertices = mesh.vertices;
313
446
  const uvs = mesh.uvs;
314
447
  const colors = mesh.colors;
315
448
  const vertexCount = mesh.vertexCount;
316
- for (let i = 0; i < vertexCount; i++) {
317
- const sourceIndex = i * 2;
318
- const targetIndex = (vertexStart + i) * wordsPerVertex;
319
- const px = vertices[sourceIndex];
320
- const py = vertices[sourceIndex + 1];
321
- this._float32View[targetIndex + 0] = a * px + b * py + tx;
322
- this._float32View[targetIndex + 1] = c * px + d * py + ty;
323
- this._float32View[targetIndex + 2] = uvs !== null ? uvs[sourceIndex] : 0;
324
- this._float32View[targetIndex + 3] = uvs !== null ? uvs[sourceIndex + 1] : 0;
325
- this._uint32View[targetIndex + 4] = colors !== null ? colors[i] : 0xffffffff;
449
+ if (bake) {
450
+ // Bake (view * globalTransform) into vertex positions on the CPU,
451
+ // matching the primitive renderer's no-uniforms approach.
452
+ const matrix = this._combinedTransform.copy(mesh.getGlobalTransform()).combine(backend.view.getTransform());
453
+ const a = matrix.a;
454
+ const b = matrix.b;
455
+ const c = matrix.c;
456
+ const d = matrix.d;
457
+ const tx = matrix.x;
458
+ const ty = matrix.y;
459
+ for (let i = 0; i < vertexCount; i++) {
460
+ const sourceIndex = i * 2;
461
+ const targetIndex = (vertexStart + i) * wordsPerVertex;
462
+ const px = vertices[sourceIndex];
463
+ const py = vertices[sourceIndex + 1];
464
+ this._float32View[targetIndex + 0] = a * px + b * py + tx;
465
+ this._float32View[targetIndex + 1] = c * px + d * py + ty;
466
+ this._float32View[targetIndex + 2] = uvs !== null ? uvs[sourceIndex] : 0;
467
+ this._float32View[targetIndex + 3] = uvs !== null ? uvs[sourceIndex + 1] : 0;
468
+ this._uint32View[targetIndex + 4] = colors !== null ? colors[i] : 0xffffffff;
469
+ }
470
+ }
471
+ else {
472
+ // Should not happen — default path always bakes. Defensive no-op.
473
+ for (let i = 0; i < vertexCount; i++) {
474
+ const sourceIndex = i * 2;
475
+ const targetIndex = (vertexStart + i) * wordsPerVertex;
476
+ this._float32View[targetIndex + 0] = vertices[sourceIndex];
477
+ this._float32View[targetIndex + 1] = vertices[sourceIndex + 1];
478
+ this._float32View[targetIndex + 2] = uvs !== null ? uvs[sourceIndex] : 0;
479
+ this._float32View[targetIndex + 3] = uvs !== null ? uvs[sourceIndex + 1] : 0;
480
+ this._uint32View[targetIndex + 4] = colors !== null ? colors[i] : 0xffffffff;
481
+ }
326
482
  }
327
483
  }
328
484
  _getPipeline(key) {
@@ -416,6 +572,9 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
416
572
  }
417
573
  }
418
574
  _ensureUniformCapacity(drawCallCount) {
575
+ if (drawCallCount === 0) {
576
+ return;
577
+ }
419
578
  const requiredBytes = drawCallCount * this._uniformAlignment;
420
579
  if (requiredBytes > this._uniformBufferCapacity) {
421
580
  this._uniformBuffer?.destroy();
@@ -435,6 +594,400 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
435
594
  });
436
595
  }
437
596
  }
597
+ // ---------------------------------------------------------------------------
598
+ // Custom-path helpers
599
+ // ---------------------------------------------------------------------------
600
+ _totalCustomDraws() {
601
+ let total = 0;
602
+ for (const resources of this._customShaders.values()) {
603
+ total += resources.drawCount;
604
+ }
605
+ return total;
606
+ }
607
+ _resetFrame() {
608
+ this._drawCallCount = 0;
609
+ for (const resources of this._customShaders.values()) {
610
+ resources.drawCount = 0;
611
+ resources.totalVertices = 0;
612
+ resources.totalIndices = 0;
613
+ }
614
+ }
615
+ _getOrCreateCustomShaderResources(shader) {
616
+ let resources = this._customShaders.get(shader);
617
+ if (resources !== undefined) {
618
+ return resources;
619
+ }
620
+ if (this._device === null) {
621
+ throw new Error('WebGpuMeshRenderer is not connected to a backend.');
622
+ }
623
+ if (shader.wgsl === null) {
624
+ throw new Error('MeshShader has no `wgsl` source; cannot render through the WebGPU backend.');
625
+ }
626
+ const device = this._device;
627
+ const shaderModule = device.createShaderModule({ code: shader.wgsl });
628
+ const meshUniformLayout = device.createBindGroupLayout({
629
+ entries: [
630
+ {
631
+ binding: 0,
632
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
633
+ buffer: { type: 'uniform', hasDynamicOffset: true },
634
+ },
635
+ ],
636
+ });
637
+ const meshTextureLayout = device.createBindGroupLayout({
638
+ entries: [
639
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
640
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
641
+ ],
642
+ });
643
+ const userLayout = this._buildUserBindGroupLayout(device, shader);
644
+ const pipelineLayout = device.createPipelineLayout({
645
+ bindGroupLayouts: [meshUniformLayout, meshTextureLayout, userLayout],
646
+ });
647
+ const sampler = device.createSampler({
648
+ magFilter: 'linear',
649
+ minFilter: 'linear',
650
+ addressModeU: 'clamp-to-edge',
651
+ addressModeV: 'clamp-to-edge',
652
+ });
653
+ const initialVertexCount = 64;
654
+ const initialIndexCount = 192;
655
+ const vertexData = new ArrayBuffer(initialVertexCount * vertexStrideBytes);
656
+ resources = {
657
+ shaderModule,
658
+ meshUniformLayout,
659
+ meshTextureLayout,
660
+ userLayout,
661
+ pipelineLayout,
662
+ pipelines: new Map(),
663
+ vertexBuffer: null,
664
+ indexBuffer: null,
665
+ vertexBufferCapacity: 0,
666
+ indexBufferCapacity: 0,
667
+ vertexData,
668
+ vertexFloatView: new Float32Array(vertexData),
669
+ vertexUintView: new Uint32Array(vertexData),
670
+ indexData: new Uint16Array(initialIndexCount),
671
+ meshUniformBuffer: null,
672
+ meshUniformBufferCapacity: 0,
673
+ meshUniformBindGroup: null,
674
+ userUniformBuffer: null,
675
+ userUniformBufferCapacity: 0,
676
+ meshTextureBindGroups: new Map(),
677
+ sampler,
678
+ drawCount: 0,
679
+ totalVertices: 0,
680
+ totalIndices: 0,
681
+ };
682
+ this._customShaders.set(shader, resources);
683
+ // When the user calls shader.destroy(), evict and release.
684
+ shader._onDispose(() => {
685
+ const r = this._customShaders.get(shader);
686
+ if (r !== undefined) {
687
+ this._releaseCustomShaderResources(r);
688
+ this._customShaders.delete(shader);
689
+ }
690
+ });
691
+ return resources;
692
+ }
693
+ _ensureCustomCapacities(resources) {
694
+ const device = this._device;
695
+ // Vertex buffer
696
+ const vertexBytes = resources.totalVertices * vertexStrideBytes;
697
+ if (vertexBytes > resources.vertexData.byteLength) {
698
+ const newSize = Math.max(vertexBytes, resources.vertexData.byteLength * 2);
699
+ resources.vertexData = new ArrayBuffer(newSize);
700
+ resources.vertexFloatView = new Float32Array(resources.vertexData);
701
+ resources.vertexUintView = new Uint32Array(resources.vertexData);
702
+ }
703
+ if (vertexBytes > resources.vertexBufferCapacity) {
704
+ resources.vertexBuffer?.destroy();
705
+ resources.vertexBufferCapacity = Math.max(vertexBytes, resources.vertexBufferCapacity * 2 || vertexStrideBytes);
706
+ resources.vertexBuffer = device.createBuffer({
707
+ size: resources.vertexBufferCapacity,
708
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
709
+ });
710
+ }
711
+ // Index buffer
712
+ const indexBytes = resources.totalIndices * Uint16Array.BYTES_PER_ELEMENT;
713
+ if (resources.indexData.length < resources.totalIndices) {
714
+ resources.indexData = new Uint16Array(Math.max(resources.totalIndices, resources.indexData.length * 2));
715
+ }
716
+ if (indexBytes > resources.indexBufferCapacity) {
717
+ resources.indexBuffer?.destroy();
718
+ resources.indexBufferCapacity = Math.max(indexBytes, resources.indexBufferCapacity * 2 || Uint16Array.BYTES_PER_ELEMENT);
719
+ resources.indexBuffer = device.createBuffer({
720
+ size: resources.indexBufferCapacity,
721
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
722
+ });
723
+ }
724
+ // Mesh-uniform UBO (proj/trans/tint per draw, 256-byte aligned).
725
+ const meshUniformBytes = resources.drawCount * meshUniformAlignment;
726
+ if (meshUniformBytes > resources.meshUniformBufferCapacity) {
727
+ resources.meshUniformBuffer?.destroy();
728
+ resources.meshUniformBufferCapacity = Math.max(meshUniformBytes, resources.meshUniformBufferCapacity * 2 || meshUniformAlignment);
729
+ resources.meshUniformBuffer = device.createBuffer({
730
+ size: resources.meshUniformBufferCapacity,
731
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
732
+ });
733
+ resources.meshUniformBindGroup = device.createBindGroup({
734
+ layout: resources.meshUniformLayout,
735
+ entries: [
736
+ {
737
+ binding: 0,
738
+ resource: { buffer: resources.meshUniformBuffer, size: customMeshUniformBytes },
739
+ },
740
+ ],
741
+ });
742
+ }
743
+ }
744
+ _writeMeshVerticesIntoBuffer(mesh, vertexStart, floatView, uintView) {
745
+ const vertices = mesh.vertices;
746
+ const uvs = mesh.uvs;
747
+ const colors = mesh.colors;
748
+ const vertexCount = mesh.vertexCount;
749
+ for (let i = 0; i < vertexCount; i++) {
750
+ const sourceIndex = i * 2;
751
+ const targetIndex = (vertexStart + i) * wordsPerVertex;
752
+ floatView[targetIndex + 0] = vertices[sourceIndex];
753
+ floatView[targetIndex + 1] = vertices[sourceIndex + 1];
754
+ floatView[targetIndex + 2] = uvs !== null ? uvs[sourceIndex] : 0;
755
+ floatView[targetIndex + 3] = uvs !== null ? uvs[sourceIndex + 1] : 0;
756
+ uintView[targetIndex + 4] = colors !== null ? colors[i] : 0xffffffff;
757
+ }
758
+ }
759
+ _writeCustomMeshUniform(_shader, resources, drawCursor, mesh, backend) {
760
+ // Layout: mat3x3 projection (48B) + mat3x3 translation (48B) + vec4 tint (16B) = 112B.
761
+ // WGSL mat3x3 stores 3 vec3 columns padded to vec4 alignment.
762
+ const slotBytes = meshUniformAlignment;
763
+ const slotFloats = slotBytes / Float32Array.BYTES_PER_ELEMENT;
764
+ const data = new Float32Array(slotFloats);
765
+ const proj = backend.view.getTransform();
766
+ const trans = mesh.getGlobalTransform();
767
+ // mat3 (column-major): [a, c, tx | b, d, ty | 0, 0, 1] in 2D.
768
+ // WGSL mat3x3 has each column padded to vec4. Store as:
769
+ // col0 = [a, b, 0, 0] / [c, d, 0, 0] / ...
770
+ // ExoJS Matrix stores: a, b, c, d, x, y. Standard 2D affine is:
771
+ // [a c tx]
772
+ // [b d ty]
773
+ // [0 0 1 ]
774
+ // Column-major mat3: col0 = (a, b, 0), col1 = (c, d, 0), col2 = (tx, ty, 1).
775
+ let off = 0;
776
+ // projection
777
+ data[off + 0] = proj.a;
778
+ data[off + 1] = proj.b;
779
+ data[off + 2] = 0;
780
+ data[off + 3] = 0; // pad
781
+ data[off + 4] = proj.c;
782
+ data[off + 5] = proj.d;
783
+ data[off + 6] = 0;
784
+ data[off + 7] = 0; // pad
785
+ data[off + 8] = proj.x;
786
+ data[off + 9] = proj.y;
787
+ data[off + 10] = 1;
788
+ data[off + 11] = 0; // pad
789
+ off += 12;
790
+ // translation
791
+ data[off + 0] = trans.a;
792
+ data[off + 1] = trans.b;
793
+ data[off + 2] = 0;
794
+ data[off + 3] = 0;
795
+ data[off + 4] = trans.c;
796
+ data[off + 5] = trans.d;
797
+ data[off + 6] = 0;
798
+ data[off + 7] = 0;
799
+ data[off + 8] = trans.x;
800
+ data[off + 9] = trans.y;
801
+ data[off + 10] = 1;
802
+ data[off + 11] = 0;
803
+ off += 12;
804
+ // tint (vec4)
805
+ const tint = mesh.tint;
806
+ data[off + 0] = tint.red;
807
+ data[off + 1] = tint.green;
808
+ data[off + 2] = tint.blue;
809
+ data[off + 3] = tint.alpha;
810
+ this._device.queue.writeBuffer(resources.meshUniformBuffer, drawCursor * slotBytes, data);
811
+ }
812
+ _getOrCreateCustomPipeline(resources, blendMode, format) {
813
+ const cacheKey = `${blendMode}:${format}`;
814
+ let pipeline = resources.pipelines.get(cacheKey);
815
+ if (pipeline === undefined) {
816
+ pipeline = this._device.createRenderPipeline({
817
+ layout: resources.pipelineLayout,
818
+ vertex: {
819
+ module: resources.shaderModule,
820
+ entryPoint: 'vertexMain',
821
+ buffers: [
822
+ {
823
+ arrayStride: vertexStrideBytes,
824
+ stepMode: 'vertex',
825
+ attributes: [
826
+ { shaderLocation: 0, offset: 0, format: 'float32x2' },
827
+ { shaderLocation: 1, offset: 8, format: 'float32x2' },
828
+ { shaderLocation: 2, offset: 16, format: 'unorm8x4' },
829
+ ],
830
+ },
831
+ ],
832
+ },
833
+ fragment: {
834
+ module: resources.shaderModule,
835
+ entryPoint: 'fragmentMain',
836
+ targets: [
837
+ {
838
+ format,
839
+ blend: getWebGpuBlendState(blendMode),
840
+ writeMask: GPUColorWrite.ALL,
841
+ },
842
+ ],
843
+ },
844
+ primitive: {
845
+ topology: 'triangle-list',
846
+ cullMode: 'none',
847
+ },
848
+ });
849
+ resources.pipelines.set(cacheKey, pipeline);
850
+ }
851
+ return pipeline;
852
+ }
853
+ _getOrCreateCustomMeshTextureBindGroup(resources, backend, texture) {
854
+ let group = resources.meshTextureBindGroups.get(texture);
855
+ if (group === undefined) {
856
+ const binding = backend.getTextureBinding(texture);
857
+ group = this._device.createBindGroup({
858
+ layout: resources.meshTextureLayout,
859
+ entries: [
860
+ { binding: 0, resource: binding.view },
861
+ { binding: 1, resource: binding.sampler },
862
+ ],
863
+ });
864
+ resources.meshTextureBindGroups.set(texture, group);
865
+ }
866
+ return group;
867
+ }
868
+ _buildUserBindGroupLayout(device, shader) {
869
+ const entries = [];
870
+ const userUniforms = shader.uniforms;
871
+ Object.values(userUniforms).some(v => !isTextureUniform(v));
872
+ // Binding 0 always reserved for the user UBO (even if empty), so the
873
+ // bind-group layout is stable across user-uniform mutations.
874
+ entries.push({
875
+ binding: 0,
876
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
877
+ buffer: { type: 'uniform' },
878
+ });
879
+ let bindingIndex = 1;
880
+ let textureCount = 0;
881
+ for (const value of Object.values(userUniforms)) {
882
+ if (!isTextureUniform(value)) {
883
+ continue;
884
+ }
885
+ if (textureCount >= maxCustomTextureSlots) {
886
+ throw new Error(`MeshShader requested more than ${maxCustomTextureSlots} user texture uniforms.`);
887
+ }
888
+ entries.push({
889
+ binding: bindingIndex,
890
+ visibility: GPUShaderStage.FRAGMENT,
891
+ texture: { sampleType: 'float' },
892
+ });
893
+ bindingIndex++;
894
+ entries.push({
895
+ binding: bindingIndex,
896
+ visibility: GPUShaderStage.FRAGMENT,
897
+ sampler: { type: 'filtering' },
898
+ });
899
+ bindingIndex++;
900
+ textureCount++;
901
+ }
902
+ return device.createBindGroupLayout({ entries });
903
+ }
904
+ _uploadUserUniforms(_shader, resources) {
905
+ const device = this._device;
906
+ const uniforms = _shader.uniforms;
907
+ const scalarValues = Object.values(uniforms).filter(v => !isTextureUniform(v));
908
+ // Always create a UBO (even if empty) since binding 0 of the user layout
909
+ // is fixed. Min size 16 bytes to satisfy WebGPU's minimum buffer size.
910
+ const slotCount = Math.max(scalarValues.length, 1);
911
+ const bufferBytes = slotCount * 16;
912
+ if (resources.userUniformBuffer === null || resources.userUniformBufferCapacity < bufferBytes) {
913
+ resources.userUniformBuffer?.destroy();
914
+ resources.userUniformBufferCapacity = bufferBytes;
915
+ resources.userUniformBuffer = device.createBuffer({
916
+ size: bufferBytes,
917
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
918
+ });
919
+ }
920
+ const data = new Float32Array(bufferBytes / 4);
921
+ let slot = 0;
922
+ for (const value of scalarValues) {
923
+ const baseFloatIndex = slot * 4;
924
+ if (typeof value === 'number') {
925
+ data[baseFloatIndex] = value;
926
+ }
927
+ else if (value instanceof Float32Array) {
928
+ data.set(value, baseFloatIndex);
929
+ }
930
+ else if (value instanceof Int32Array) {
931
+ for (let i = 0; i < value.length; i++) {
932
+ data[baseFloatIndex + i] = value[i];
933
+ }
934
+ }
935
+ else {
936
+ const arr = value;
937
+ for (let i = 0; i < arr.length; i++) {
938
+ data[baseFloatIndex + i] = arr[i];
939
+ }
940
+ }
941
+ slot++;
942
+ }
943
+ device.queue.writeBuffer(resources.userUniformBuffer, 0, data);
944
+ }
945
+ _buildUserBindGroup(backend, shader, resources) {
946
+ const device = this._device;
947
+ const entries = [];
948
+ entries.push({ binding: 0, resource: { buffer: resources.userUniformBuffer } });
949
+ let bindingIndex = 1;
950
+ for (const value of Object.values(shader.uniforms)) {
951
+ if (!isTextureUniform(value)) {
952
+ continue;
953
+ }
954
+ const binding = backend.getTextureBinding(value);
955
+ entries.push({ binding: bindingIndex, resource: binding.view });
956
+ bindingIndex++;
957
+ entries.push({ binding: bindingIndex, resource: binding.sampler });
958
+ bindingIndex++;
959
+ }
960
+ return device.createBindGroup({
961
+ layout: resources.userLayout,
962
+ entries,
963
+ });
964
+ }
965
+ _releaseCustomShaderResources(resources) {
966
+ resources.vertexBuffer?.destroy();
967
+ resources.indexBuffer?.destroy();
968
+ resources.meshUniformBuffer?.destroy();
969
+ resources.userUniformBuffer?.destroy();
970
+ resources.pipelines.clear();
971
+ resources.meshTextureBindGroups.clear();
972
+ resources.vertexBuffer = null;
973
+ resources.indexBuffer = null;
974
+ resources.meshUniformBuffer = null;
975
+ resources.userUniformBuffer = null;
976
+ resources.meshUniformBindGroup = null;
977
+ resources.vertexBufferCapacity = 0;
978
+ resources.indexBufferCapacity = 0;
979
+ resources.meshUniformBufferCapacity = 0;
980
+ resources.userUniformBufferCapacity = 0;
981
+ }
982
+ }
983
+ function isTextureUniform(value) {
984
+ return (typeof value === 'object' &&
985
+ value !== null &&
986
+ 'width' in value &&
987
+ 'height' in value &&
988
+ !(value instanceof Float32Array) &&
989
+ !(value instanceof Int32Array) &&
990
+ !Array.isArray(value));
438
991
  }
439
992
 
440
993
  export { WebGpuMeshRenderer };