@codexo/exojs 0.6.1 → 0.6.2

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/dist/exo.esm.js CHANGED
@@ -5638,9 +5638,9 @@ class WebGl2VertexArrayObject {
5638
5638
  }
5639
5639
  }
5640
5640
 
5641
- var vertexSource$3 = "#version 300 es\nprecision lowp float;\nprecision lowp int;\n\n// Per-instance attributes (divisor = 1). Each Sprite contributes one entry\n// to the per-instance buffer; gl_VertexID 0..3 selects which corner of the\n// quad this invocation is computing.\nlayout(location = 0) in vec4 a_localBounds; // left, top, right, bottom (local space)\nlayout(location = 1) in vec3 a_transformAB; // a, b, x — first row of 2D affine\nlayout(location = 2) in vec3 a_transformCD; // c, d, y — second row\nlayout(location = 3) in vec4 a_uvBounds; // uMin, vMin, uMax, vMax (normalised, already flipY-swapped)\nlayout(location = 4) in vec4 a_color; // RGBA tint\nlayout(location = 5) in uint a_textureSlot;\n\nuniform mat3 u_projection;\n\nout vec2 v_texcoord;\nout vec4 v_color;\nflat out uint v_textureSlot;\n\nvoid main(void) {\n // gl_VertexID 0..3 → corner: 0=TL, 1=TR, 2=BL, 3=BR (TRIANGLE_STRIP order)\n int vid = gl_VertexID;\n int cornerX = vid & 1;\n int cornerY = (vid >> 1) & 1;\n\n // Local-space corner: pick from the bounds rectangle.\n float localX = (cornerX == 0) ? a_localBounds.x : a_localBounds.z;\n float localY = (cornerY == 0) ? a_localBounds.y : a_localBounds.w;\n\n // Apply the per-instance affine transform: world = M * (localX, localY, 1)\n float worldX = (a_transformAB.x * localX) + (a_transformAB.y * localY) + a_transformAB.z;\n float worldY = (a_transformCD.x * localX) + (a_transformCD.y * localY) + a_transformCD.z;\n\n gl_Position = vec4((u_projection * vec3(worldX, worldY, 1.0)).xy, 0.0, 1.0);\n\n // UV: pick from the bounds rectangle. The CPU pre-swaps Y bounds when\n // the texture is flipY, so the shader doesn't have to know.\n float u = (cornerX == 0) ? a_uvBounds.x : a_uvBounds.z;\n float v = (cornerY == 0) ? a_uvBounds.y : a_uvBounds.w;\n v_texcoord = vec2(u, v);\n\n v_color = vec4(a_color.rgb * a_color.a, a_color.a);\n v_textureSlot = a_textureSlot;\n}\n";
5641
+ var vertexSource$4 = "#version 300 es\nprecision lowp float;\nprecision lowp int;\n\n// Per-instance attributes (divisor = 1). Each Sprite contributes one entry\n// to the per-instance buffer; gl_VertexID 0..3 selects which corner of the\n// quad this invocation is computing.\nlayout(location = 0) in vec4 a_localBounds; // left, top, right, bottom (local space)\nlayout(location = 1) in vec3 a_transformAB; // a, b, x — first row of 2D affine\nlayout(location = 2) in vec3 a_transformCD; // c, d, y — second row\nlayout(location = 3) in vec4 a_uvBounds; // uMin, vMin, uMax, vMax (normalised, already flipY-swapped)\nlayout(location = 4) in vec4 a_color; // RGBA tint\nlayout(location = 5) in uint a_textureSlot;\n\nuniform mat3 u_projection;\n\nout vec2 v_texcoord;\nout vec4 v_color;\nflat out uint v_textureSlot;\n\nvoid main(void) {\n // gl_VertexID 0..3 → corner: 0=TL, 1=TR, 2=BL, 3=BR (TRIANGLE_STRIP order)\n int vid = gl_VertexID;\n int cornerX = vid & 1;\n int cornerY = (vid >> 1) & 1;\n\n // Local-space corner: pick from the bounds rectangle.\n float localX = (cornerX == 0) ? a_localBounds.x : a_localBounds.z;\n float localY = (cornerY == 0) ? a_localBounds.y : a_localBounds.w;\n\n // Apply the per-instance affine transform: world = M * (localX, localY, 1)\n float worldX = (a_transformAB.x * localX) + (a_transformAB.y * localY) + a_transformAB.z;\n float worldY = (a_transformCD.x * localX) + (a_transformCD.y * localY) + a_transformCD.z;\n\n gl_Position = vec4((u_projection * vec3(worldX, worldY, 1.0)).xy, 0.0, 1.0);\n\n // UV: pick from the bounds rectangle. The CPU pre-swaps Y bounds when\n // the texture is flipY, so the shader doesn't have to know.\n float u = (cornerX == 0) ? a_uvBounds.x : a_uvBounds.z;\n float v = (cornerY == 0) ? a_uvBounds.y : a_uvBounds.w;\n v_texcoord = vec2(u, v);\n\n v_color = vec4(a_color.rgb * a_color.a, a_color.a);\n v_textureSlot = a_textureSlot;\n}\n";
5642
5642
 
5643
- var fragmentSource$3 = "#version 300 es\r\nprecision lowp float;\r\nprecision lowp int;\r\n\r\n// Multi-texture sprite batching: up to 8 textures bound per draw call,\r\n// each fragment selects its source via a flat-interpolated slot index.\r\n//\r\n// GLSL ES 3.0 forbids non-constant array-of-sampler indexing unless the\r\n// expression is dynamically uniform — which a per-vertex slot is not\r\n// once different triangles in the same batch carry different slots. The\r\n// if/else chain below dispatches statically and dodges that constraint.\r\n\r\nuniform sampler2D u_texture0;\r\nuniform sampler2D u_texture1;\r\nuniform sampler2D u_texture2;\r\nuniform sampler2D u_texture3;\r\nuniform sampler2D u_texture4;\r\nuniform sampler2D u_texture5;\r\nuniform sampler2D u_texture6;\r\nuniform sampler2D u_texture7;\r\n\r\nin vec2 v_texcoord;\r\nin vec4 v_color;\r\nflat in uint v_textureSlot;\r\n\r\nlayout(location = 0) out vec4 fragColor;\r\n\r\nvoid main(void) {\r\n vec4 sampleColor;\r\n\r\n if (v_textureSlot == 0u) {\r\n sampleColor = texture(u_texture0, v_texcoord);\r\n } else if (v_textureSlot == 1u) {\r\n sampleColor = texture(u_texture1, v_texcoord);\r\n } else if (v_textureSlot == 2u) {\r\n sampleColor = texture(u_texture2, v_texcoord);\r\n } else if (v_textureSlot == 3u) {\r\n sampleColor = texture(u_texture3, v_texcoord);\r\n } else if (v_textureSlot == 4u) {\r\n sampleColor = texture(u_texture4, v_texcoord);\r\n } else if (v_textureSlot == 5u) {\r\n sampleColor = texture(u_texture5, v_texcoord);\r\n } else if (v_textureSlot == 6u) {\r\n sampleColor = texture(u_texture6, v_texcoord);\r\n } else {\r\n sampleColor = texture(u_texture7, v_texcoord);\r\n }\r\n\r\n fragColor = sampleColor * v_color;\r\n}\r\n";
5643
+ var fragmentSource$4 = "#version 300 es\r\nprecision lowp float;\r\nprecision lowp int;\r\n\r\n// Multi-texture sprite batching: up to 8 textures bound per draw call,\r\n// each fragment selects its source via a flat-interpolated slot index.\r\n//\r\n// GLSL ES 3.0 forbids non-constant array-of-sampler indexing unless the\r\n// expression is dynamically uniform — which a per-vertex slot is not\r\n// once different triangles in the same batch carry different slots. The\r\n// if/else chain below dispatches statically and dodges that constraint.\r\n\r\nuniform sampler2D u_texture0;\r\nuniform sampler2D u_texture1;\r\nuniform sampler2D u_texture2;\r\nuniform sampler2D u_texture3;\r\nuniform sampler2D u_texture4;\r\nuniform sampler2D u_texture5;\r\nuniform sampler2D u_texture6;\r\nuniform sampler2D u_texture7;\r\n\r\nin vec2 v_texcoord;\r\nin vec4 v_color;\r\nflat in uint v_textureSlot;\r\n\r\nlayout(location = 0) out vec4 fragColor;\r\n\r\nvoid main(void) {\r\n vec4 sampleColor;\r\n\r\n if (v_textureSlot == 0u) {\r\n sampleColor = texture(u_texture0, v_texcoord);\r\n } else if (v_textureSlot == 1u) {\r\n sampleColor = texture(u_texture1, v_texcoord);\r\n } else if (v_textureSlot == 2u) {\r\n sampleColor = texture(u_texture2, v_texcoord);\r\n } else if (v_textureSlot == 3u) {\r\n sampleColor = texture(u_texture3, v_texcoord);\r\n } else if (v_textureSlot == 4u) {\r\n sampleColor = texture(u_texture4, v_texcoord);\r\n } else if (v_textureSlot == 5u) {\r\n sampleColor = texture(u_texture5, v_texcoord);\r\n } else if (v_textureSlot == 6u) {\r\n sampleColor = texture(u_texture6, v_texcoord);\r\n } else {\r\n sampleColor = texture(u_texture7, v_texcoord);\r\n }\r\n\r\n fragColor = sampleColor * v_color;\r\n}\r\n";
5644
5644
 
5645
5645
  /**
5646
5646
  * Instanced sprite renderer for WebGL2.
@@ -5688,7 +5688,7 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
5688
5688
  constructor(batchSize) {
5689
5689
  super();
5690
5690
  this._batchSize = batchSize;
5691
- this._shader = new Shader(vertexSource$3, fragmentSource$3);
5691
+ this._shader = new Shader(vertexSource$4, fragmentSource$4);
5692
5692
  this._instanceData = new ArrayBuffer(batchSize * instanceStrideBytes$3);
5693
5693
  this._instanceFloat32 = new Float32Array(this._instanceData);
5694
5694
  this._instanceUint32 = new Uint32Array(this._instanceData);
@@ -5911,226 +5911,385 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
5911
5911
  }
5912
5912
  }
5913
5913
 
5914
- var vertexSource$2 = "#version 300 es\nprecision lowp float;\nprecision lowp int;\n\n// Per-instance attributes (one entry per particle, 24 bytes total).\nlayout(location = 0) in vec2 a_translation; // particle position in system-local space\nlayout(location = 1) in vec2 a_scale; // particle scale\nlayout(location = 2) in float a_rotation; // particle rotation in degrees\nlayout(location = 3) in vec4 a_color; // RGBA tint\n\nuniform mat3 u_projection;\nuniform mat3 u_systemTransform;\nuniform vec4 u_localBounds; // left, top, right, bottom (system.vertices)\nuniform vec4 u_uvBounds; // uMin, vMin, uMax, vMax (flipY-swapped)\n\nout vec2 v_texcoord;\nout vec4 v_color;\n\nvoid main(void) {\n // Static index buffer is [0,1,2,0,2,3] (triangle-list), so gl_VertexID 0..3\n // maps to TL, TR, BR, BL via the same bit math the sprite renderer uses.\n int vid = gl_VertexID;\n int cornerX = ((vid + 1) >> 1) & 1;\n int cornerY = vid >> 1;\n\n float localX = (cornerX == 0) ? u_localBounds.x : u_localBounds.z;\n float localY = (cornerY == 0) ? u_localBounds.y : u_localBounds.w;\n\n // Per-particle scale + rotation.\n vec2 rotation = vec2(sin(radians(a_rotation)), cos(radians(a_rotation)));\n vec2 transformed = vec2(\n (localX * (a_scale.x * rotation.y)) + (localY * (a_scale.y * rotation.x)),\n (localX * (a_scale.x * -rotation.x)) + (localY * (a_scale.y * rotation.y))\n );\n\n vec3 worldPos = vec3(transformed + a_translation, 1.0);\n\n gl_Position = vec4((u_projection * u_systemTransform * worldPos).xy, 0.0, 1.0);\n\n float u = (cornerX == 0) ? u_uvBounds.x : u_uvBounds.z;\n float v = (cornerY == 0) ? u_uvBounds.y : u_uvBounds.w;\n v_texcoord = vec2(u, v);\n\n v_color = vec4(a_color.rgb * a_color.a, a_color.a);\n}\n";
5915
-
5916
- var fragmentSource$2 = "#version 300 es\r\nprecision lowp float;\r\n\r\nuniform sampler2D u_texture;\r\n\r\nin vec2 v_texcoord;\r\nin vec4 v_color;\r\n\r\nlayout(location = 0) out vec4 fragColor;\r\n\r\nvoid main(void) {\r\n fragColor = texture(u_texture, v_texcoord) * v_color;\r\n}\r\n";
5917
-
5918
- /**
5919
- * Instanced particle renderer for WebGL2.
5920
- *
5921
- * One ParticleSystem = one batch. Each `render(system)` flushes any
5922
- * pending batch, sets the system-level uniforms (transform, local
5923
- * bounds, UV bounds, texture), and packs every active particle into
5924
- * the per-instance buffer. The next `flush()` issues a single
5925
- * `drawElementsInstanced` for that system.
5926
- *
5927
- * Per-instance layout (24 bytes per particle, 4 attributes):
5928
- * ```
5929
- * translation f32x2 (offset 0, 8 bytes) particle position (system-local)
5930
- * scale f32x2 (offset 8, 8 bytes)
5931
- * rotation f32 (offset 16, 4 bytes) degrees
5932
- * color u8x4 (offset 20, 4 bytes) RGBA tint, normalised
5933
- * ```
5934
- *
5935
- * vs the previous per-vertex layout (36 bytes per vertex × 4 verts =
5936
- * 144 bytes per particle), this is an 83% bandwidth reduction and
5937
- * roughly 80% fewer CPU writes per particle (one pack call vs four
5938
- * duplicated vertex writes plus per-corner UV coords).
5939
- *
5940
- * The system transform stays as a uniform — mixing systems in one
5941
- * batch would require either a per-instance transform matrix (more
5942
- * bandwidth) or per-particle texture-slot indexing (multi-texture
5943
- * support similar to the sprite renderer). Both are follow-ups; the
5944
- * current pattern keeps the renderer focused on the per-particle win.
5945
- */
5946
- const instanceStrideBytes$2 = 24;
5947
- const wordsPerInstance$1 = instanceStrideBytes$2 / Uint32Array.BYTES_PER_ELEMENT;
5948
- const indicesPerQuad = 6;
5949
- const quadIndices$2 = new Uint16Array([0, 1, 2, 0, 2, 3]);
5950
- class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
5951
- _shader;
5952
- _batchSize;
5953
- _instanceData;
5954
- _instanceFloat32;
5955
- _instanceUint32;
5956
- _instanceCount = 0;
5957
- _currentTexture = null;
5958
- _currentBlendMode = null;
5959
- _currentView = null;
5960
- _currentViewId = -1;
5961
- _instanceBuffer = null;
5962
- _indexBuffer = null;
5963
- _vao = null;
5964
- _connection = null;
5965
- constructor(batchSize) {
5966
- super();
5967
- this._batchSize = batchSize;
5968
- this._shader = new Shader(vertexSource$2, fragmentSource$2);
5969
- this._instanceData = new ArrayBuffer(batchSize * instanceStrideBytes$2);
5970
- this._instanceFloat32 = new Float32Array(this._instanceData);
5971
- this._instanceUint32 = new Uint32Array(this._instanceData);
5914
+ const createQuadIndices = (size) => {
5915
+ const data = new Uint16Array(size * 6);
5916
+ const len = data.length;
5917
+ for (let i = 0, offset = 0; i < len; i += 6, offset += 4) {
5918
+ data[i] = offset;
5919
+ data[i + 1] = offset + 1;
5920
+ data[i + 2] = offset + 2;
5921
+ data[i + 3] = offset;
5922
+ data[i + 4] = offset + 2;
5923
+ data[i + 5] = offset + 3;
5972
5924
  }
5973
- render(system) {
5974
- const backend = this.getBackend();
5975
- const { texture, particles, blendMode } = system;
5976
- const textureChanged = texture !== this._currentTexture;
5977
- const blendModeChanged = blendMode !== this._currentBlendMode;
5978
- // System transform / texture / UV / local-bounds are uniforms, so
5979
- // mixing systems in one batch is invalid. Flush any prior system
5980
- // before setting up this one.
5981
- this.flush();
5982
- if (textureChanged) {
5983
- this._currentTexture = texture;
5984
- backend.bindTexture(texture);
5985
- }
5986
- if (blendModeChanged) {
5987
- this._currentBlendMode = blendMode;
5988
- backend.setBlendMode(blendMode);
5989
- }
5990
- // System-level uniforms are set before packing so the eventual
5991
- // flush() can sync them in one go.
5992
- const localBounds = system.vertices;
5993
- const uvBounds = this._unpackUvBounds(system);
5994
- this._shader
5995
- .getUniform('u_systemTransform')
5996
- .setValue(system.getGlobalTransform().toArray(false));
5997
- this._shader
5998
- .getUniform('u_localBounds')
5999
- .setValue(localBounds);
6000
- this._shader
6001
- .getUniform('u_uvBounds')
6002
- .setValue(uvBounds);
6003
- const f32 = this._instanceFloat32;
6004
- const u32 = this._instanceUint32;
6005
- const limit = Math.min(particles.length, this._batchSize);
6006
- for (let i = 0; i < limit; i++) {
6007
- const particle = particles[i];
6008
- const offset = i * wordsPerInstance$1;
6009
- f32[offset + 0] = particle.position.x;
6010
- f32[offset + 1] = particle.position.y;
6011
- f32[offset + 2] = particle.scale.x;
6012
- f32[offset + 3] = particle.scale.y;
6013
- f32[offset + 4] = particle.rotation;
6014
- u32[offset + 5] = particle.tint.toRgba();
5925
+ return data;
5926
+ };
5927
+ const createCanvas = (options = {}) => {
5928
+ const { canvas, fillStyle, width, height } = options;
5929
+ const newCanvas = canvas ?? document.createElement('canvas');
5930
+ const context = newCanvas.getContext('2d');
5931
+ newCanvas.width = width ?? 10;
5932
+ newCanvas.height = height ?? 10;
5933
+ context.fillStyle = fillStyle ?? '#6495ed';
5934
+ context.fillRect(0, 0, newCanvas.width, newCanvas.height);
5935
+ return newCanvas;
5936
+ };
5937
+ const heightCache = new Map();
5938
+ const determineFontHeight = (font) => {
5939
+ if (!heightCache.has(font)) {
5940
+ const body = document.body;
5941
+ const dummy = document.createElement('div');
5942
+ dummy.appendChild(document.createTextNode('M'));
5943
+ dummy.setAttribute('style', `font: ${font};position:absolute;top:0;left:0`);
5944
+ body.appendChild(dummy);
5945
+ heightCache.set(font, dummy.offsetHeight);
5946
+ body.removeChild(dummy);
5947
+ }
5948
+ return heightCache.get(font);
5949
+ };
5950
+
5951
+ class Texture {
5952
+ static _black = null;
5953
+ static _white = null;
5954
+ static defaultSamplerOptions = {
5955
+ scaleMode: ScaleModes.Linear,
5956
+ wrapMode: WrapModes.ClampToEdge,
5957
+ premultiplyAlpha: true,
5958
+ generateMipMap: true,
5959
+ flipY: false,
5960
+ };
5961
+ static empty = new Texture(null);
5962
+ static get black() {
5963
+ if (Texture._black === null) {
5964
+ Texture._black = new Texture(createCanvas({ fillStyle: '#000' }));
6015
5965
  }
6016
- this._instanceCount = limit;
6017
- return this;
5966
+ return Texture._black;
6018
5967
  }
6019
- flush() {
6020
- const backend = this.getBackendOrNull();
6021
- const instanceBuffer = this._instanceBuffer;
6022
- const indexBuffer = this._indexBuffer;
6023
- const vao = this._vao;
6024
- if (this._instanceCount === 0 || backend === null || instanceBuffer === null || indexBuffer === null || vao === null) {
6025
- return;
5968
+ static get white() {
5969
+ if (Texture._white === null) {
5970
+ Texture._white = new Texture(createCanvas({ fillStyle: '#fff' }));
6026
5971
  }
6027
- const view = backend.view;
6028
- if (this._currentView !== view || this._currentViewId !== view.updateId) {
6029
- this._currentView = view;
6030
- this._currentViewId = view.updateId;
6031
- this._shader
6032
- .getUniform('u_projection')
6033
- .setValue(view.getTransform().toArray(false));
5972
+ return Texture._white;
5973
+ }
5974
+ _version = 0;
5975
+ _source = null;
5976
+ _size = new Size(0, 0);
5977
+ _destroyListeners = new Set();
5978
+ _scaleMode;
5979
+ _wrapMode;
5980
+ _premultiplyAlpha = false;
5981
+ _generateMipMap = false;
5982
+ _flipY = false;
5983
+ constructor(source = null, options) {
5984
+ const { scaleMode, wrapMode, premultiplyAlpha, generateMipMap, flipY } = { ...Texture.defaultSamplerOptions, ...options };
5985
+ this._scaleMode = scaleMode;
5986
+ this._wrapMode = wrapMode;
5987
+ this._premultiplyAlpha = premultiplyAlpha;
5988
+ this._generateMipMap = generateMipMap;
5989
+ this._flipY = flipY;
5990
+ if (source !== null) {
5991
+ this.setSource(source);
6034
5992
  }
6035
- this._shader.sync();
6036
- backend.bindVertexArrayObject(vao);
6037
- instanceBuffer.upload(this._instanceFloat32.subarray(0, this._instanceCount * wordsPerInstance$1));
6038
- vao.drawInstanced(indicesPerQuad, 0, this._instanceCount, RenderingPrimitives.Triangles);
6039
- backend.stats.batches++;
6040
- backend.stats.drawCalls++;
6041
- this._instanceCount = 0;
6042
5993
  }
6043
- onConnect(backend) {
6044
- const gl = backend.context;
6045
- this._shader.connect(createWebGl2ShaderProgram(gl));
6046
- this._connection = this._createConnection(gl);
6047
- this._indexBuffer = new WebGl2RenderBuffer(BufferTypes.ElementArrayBuffer, quadIndices$2, BufferUsage.StaticDraw)
6048
- .connect(this._createBufferRuntime(this._connection));
6049
- this._instanceBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._instanceData, BufferUsage.DynamicDraw)
6050
- .connect(this._createBufferRuntime(this._connection));
6051
- this._shader.sync();
6052
- this._vao = new WebGl2VertexArrayObject()
6053
- .addIndex(this._indexBuffer)
6054
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_translation'), gl.FLOAT, false, instanceStrideBytes$2, 0, false, 1)
6055
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_scale'), gl.FLOAT, false, instanceStrideBytes$2, 8, false, 1)
6056
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_rotation'), gl.FLOAT, false, instanceStrideBytes$2, 16, false, 1)
6057
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, instanceStrideBytes$2, 20, false, 1)
6058
- .connect(this._createVaoRuntime(this._connection));
5994
+ get source() {
5995
+ return this._source;
6059
5996
  }
6060
- onDisconnect() {
6061
- this._shader.disconnect();
6062
- this._instanceBuffer?.destroy();
6063
- this._instanceBuffer = null;
6064
- this._indexBuffer?.destroy();
6065
- this._indexBuffer = null;
6066
- this._vao?.destroy();
6067
- this._vao = null;
6068
- this._connection = null;
6069
- this._currentTexture = null;
6070
- this._currentBlendMode = null;
6071
- this._currentView = null;
6072
- this._currentViewId = -1;
6073
- this._instanceCount = 0;
5997
+ set source(source) {
5998
+ this.setSource(source);
6074
5999
  }
6075
- destroy() {
6076
- this.disconnect();
6077
- this._shader.destroy();
6000
+ get size() {
6001
+ return this._size;
6078
6002
  }
6079
- /**
6080
- * Convert the system's per-corner packed-u32 texCoords into the
6081
- * (uMin, vMin, uMax, vMax) bounds the new vertex shader expects.
6082
- * Already accounts for flipY (the system's `texCoords` baked it in).
6083
- *
6084
- * The four packed u32s, by corner index, are:
6085
- * [0] TL = (uMin/uMax | vMin/vMax depending on flipY)
6086
- * [1] TR
6087
- * [2] BR
6088
- * [3] BL
6089
- * We need (uMin, vMin, uMax, vMax) — the corner-extreme values.
6090
- */
6091
- _uvBoundsScratch = new Float32Array(4);
6092
- _unpackUvBounds(system) {
6093
- const texCoords = system.texCoords;
6094
- // Each packed u32: low 16 bits = U normalised to 0..65535,
6095
- // high 16 bits = V normalised.
6096
- const uTopLeft = (texCoords[0] & 0xFFFF) / 0xFFFF;
6097
- const vTopLeft = ((texCoords[0] >>> 16) & 0xFFFF) / 0xFFFF;
6098
- const uBottomRight = (texCoords[2] & 0xFFFF) / 0xFFFF;
6099
- const vBottomRight = ((texCoords[2] >>> 16) & 0xFFFF) / 0xFFFF;
6100
- // For flipY: TL.v becomes vMax (was minY → maxY). The shader picks
6101
- // (cornerY == 0 ? vMin : vMax); writing TL.v into vMin and BR.v into
6102
- // vMax matches the original per-corner ordering whether or not flipY
6103
- // was applied at texCoords pack time.
6104
- this._uvBoundsScratch[0] = uTopLeft;
6105
- this._uvBoundsScratch[1] = vTopLeft;
6106
- this._uvBoundsScratch[2] = uBottomRight;
6107
- this._uvBoundsScratch[3] = vBottomRight;
6108
- return this._uvBoundsScratch;
6003
+ set size(size) {
6004
+ this.setSize(size.width, size.height);
6109
6005
  }
6110
- _createConnection(gl) {
6006
+ get width() {
6007
+ return this._size.width;
6008
+ }
6009
+ set width(width) {
6010
+ this.setSize(width, this.height);
6011
+ }
6012
+ get height() {
6013
+ return this._size.height;
6014
+ }
6015
+ set height(height) {
6016
+ this.setSize(this.width, height);
6017
+ }
6018
+ get scaleMode() {
6019
+ return this._scaleMode;
6020
+ }
6021
+ set scaleMode(scaleMode) {
6022
+ this.setScaleMode(scaleMode);
6023
+ }
6024
+ get wrapMode() {
6025
+ return this._wrapMode;
6026
+ }
6027
+ set wrapMode(wrapMode) {
6028
+ this.setWrapMode(wrapMode);
6029
+ }
6030
+ get premultiplyAlpha() {
6031
+ return this._premultiplyAlpha;
6032
+ }
6033
+ set premultiplyAlpha(premultiplyAlpha) {
6034
+ this.setPremultiplyAlpha(premultiplyAlpha);
6035
+ }
6036
+ get generateMipMap() {
6037
+ return this._generateMipMap;
6038
+ }
6039
+ set generateMipMap(generateMipMap) {
6040
+ this._generateMipMap = generateMipMap;
6041
+ }
6042
+ get flipY() {
6043
+ return this._flipY;
6044
+ }
6045
+ set flipY(flipY) {
6046
+ this._flipY = flipY;
6047
+ }
6048
+ get powerOfTwo() {
6049
+ return isPowerOfTwo(this.width) && isPowerOfTwo(this.height);
6050
+ }
6051
+ get version() {
6052
+ return this._version;
6053
+ }
6054
+ addDestroyListener(listener) {
6055
+ this._destroyListeners.add(listener);
6056
+ return this;
6057
+ }
6058
+ removeDestroyListener(listener) {
6059
+ this._destroyListeners.delete(listener);
6060
+ return this;
6061
+ }
6062
+ setScaleMode(scaleMode) {
6063
+ if (this._scaleMode !== scaleMode) {
6064
+ this._scaleMode = scaleMode;
6065
+ this._touch();
6066
+ }
6067
+ return this;
6068
+ }
6069
+ setWrapMode(wrapMode) {
6070
+ if (this._wrapMode !== wrapMode) {
6071
+ this._wrapMode = wrapMode;
6072
+ this._touch();
6073
+ }
6074
+ return this;
6075
+ }
6076
+ setPremultiplyAlpha(premultiplyAlpha) {
6077
+ if (this._premultiplyAlpha !== premultiplyAlpha) {
6078
+ this._premultiplyAlpha = premultiplyAlpha;
6079
+ this._touch();
6080
+ }
6081
+ return this;
6082
+ }
6083
+ setSource(source) {
6084
+ if (this._source !== source) {
6085
+ this._source = source;
6086
+ this.updateSource();
6087
+ }
6088
+ return this;
6089
+ }
6090
+ updateSource() {
6091
+ const { width, height } = getTextureSourceSize(this._source);
6092
+ this.setSize(width, height);
6093
+ this._touch();
6094
+ return this;
6095
+ }
6096
+ setSize(width, height) {
6097
+ if (!this._size.equals({ width, height })) {
6098
+ this._size.set(width, height);
6099
+ this._touch();
6100
+ }
6101
+ return this;
6102
+ }
6103
+ destroy() {
6104
+ for (const listener of Array.from(this._destroyListeners)) {
6105
+ listener();
6106
+ }
6107
+ this._destroyListeners.clear();
6108
+ this._size.destroy();
6109
+ this._source = null;
6110
+ }
6111
+ _touch() {
6112
+ this._version++;
6113
+ }
6114
+ }
6115
+
6116
+ var vertexSource$3 = "#version 300 es\nprecision lowp float;\n\nlayout(location = 0) in vec2 a_position;\nlayout(location = 1) in vec2 a_texcoord;\nlayout(location = 2) in vec4 a_color;\n\nuniform mat3 u_projection;\nuniform mat3 u_translation;\n\nout vec2 v_texcoord;\nout vec4 v_color;\n\nvoid main(void) {\n gl_Position = vec4((u_projection * u_translation * vec3(a_position, 1.0)).xy, 0.0, 1.0);\n v_texcoord = a_texcoord;\n v_color = a_color;\n}\n";
6117
+
6118
+ var fragmentSource$3 = "#version 300 es\nprecision lowp float;\n\nuniform sampler2D u_texture;\nuniform vec4 u_tint;\n\nin vec2 v_texcoord;\nin vec4 v_color;\n\nlayout(location = 0) out vec4 fragColor;\n\nvoid main(void) {\n vec4 base = texture(u_texture, v_texcoord) * v_color * u_tint;\n fragColor = vec4(base.rgb * base.a, base.a);\n}\n";
6119
+
6120
+ // Per-vertex layout (20 bytes):
6121
+ // position: vec2 f32 (offset 0, 8 bytes)
6122
+ // texcoord: vec2 f32 (offset 8, 8 bytes)
6123
+ // color: u8x4 norm (offset 16, 4 bytes)
6124
+ const vertexStrideBytes$5 = 20;
6125
+ const vertexStrideWords$1 = vertexStrideBytes$5 / 4;
6126
+ const initialVertexCapacity = 64;
6127
+ const initialIndexCapacity = 192;
6128
+ const defaultVertexColor = 0xFFFFFFFF; // white, full alpha
6129
+ class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
6130
+ _shader = new Shader(vertexSource$3, fragmentSource$3);
6131
+ _tintScratch = new Float32Array(4);
6132
+ _textureUnitScratch = new Int32Array([0]);
6133
+ _vertexCapacity = initialVertexCapacity;
6134
+ _indexCapacity = initialIndexCapacity;
6135
+ _vertexData = new ArrayBuffer(initialVertexCapacity * vertexStrideBytes$5);
6136
+ _float32View = new Float32Array(this._vertexData);
6137
+ _uint32View = new Uint32Array(this._vertexData);
6138
+ _indexData = new Uint16Array(initialIndexCapacity);
6139
+ _connection = null;
6140
+ _currentBlendMode = null;
6141
+ _currentView = null;
6142
+ _viewId = -1;
6143
+ render(mesh) {
6144
+ const connection = this._connection;
6145
+ if (!connection) {
6146
+ throw new Error('WebGl2MeshRenderer is not connected to a backend.');
6147
+ }
6148
+ const vertexCount = mesh.vertexCount;
6149
+ if (vertexCount === 0) {
6150
+ return;
6151
+ }
6152
+ const backend = this.getBackend();
6153
+ const blendMode = mesh.blendMode;
6154
+ if (blendMode !== this._currentBlendMode) {
6155
+ this._currentBlendMode = blendMode;
6156
+ backend.setBlendMode(blendMode);
6157
+ }
6158
+ const view = backend.view;
6159
+ if (this._currentView !== view || this._viewId !== view.updateId) {
6160
+ this._currentView = view;
6161
+ this._viewId = view.updateId;
6162
+ this._shader.getUniform('u_projection').setValue(view.getTransform().toArray(false));
6163
+ }
6164
+ this._shader.getUniform('u_translation').setValue(mesh.getGlobalTransform().toArray(false));
6165
+ const tint = mesh.tint;
6166
+ this._tintScratch[0] = tint.red;
6167
+ this._tintScratch[1] = tint.green;
6168
+ this._tintScratch[2] = tint.blue;
6169
+ this._tintScratch[3] = tint.alpha;
6170
+ this._shader.getUniform('u_tint').setValue(this._tintScratch);
6171
+ // The fragment shader always samples u_texture; meshes without an
6172
+ // explicit texture get the engine's 1x1 white default so the shader
6173
+ // path stays branchless.
6174
+ const meshTexture = mesh.texture ?? Texture.white;
6175
+ this._shader.getUniform('u_texture').setValue(this._textureUnitScratch);
6176
+ backend.bindTexture(meshTexture, 0);
6177
+ this._ensureVertexCapacity(vertexCount);
6178
+ const positions = mesh.vertices;
6179
+ const uvs = mesh.uvs;
6180
+ const colors = mesh.colors;
6181
+ for (let i = 0; i < vertexCount; i++) {
6182
+ const word = i * vertexStrideWords$1;
6183
+ const pair = i * 2;
6184
+ this._float32View[word] = positions[pair];
6185
+ this._float32View[word + 1] = positions[pair + 1];
6186
+ if (uvs !== null) {
6187
+ this._float32View[word + 2] = uvs[pair];
6188
+ this._float32View[word + 3] = uvs[pair + 1];
6189
+ }
6190
+ else {
6191
+ this._float32View[word + 2] = 0;
6192
+ this._float32View[word + 3] = 0;
6193
+ }
6194
+ this._uint32View[word + 4] = colors !== null ? colors[i] : defaultVertexColor;
6195
+ }
6196
+ const indexCount = mesh.indexCount;
6197
+ this._ensureIndexCapacity(indexCount);
6198
+ if (mesh.indices !== null) {
6199
+ this._indexData.set(mesh.indices, 0);
6200
+ }
6201
+ else {
6202
+ for (let i = 0; i < indexCount; i++) {
6203
+ this._indexData[i] = i;
6204
+ }
6205
+ }
6206
+ this._shader.sync();
6207
+ backend.bindVertexArrayObject(connection.vao);
6208
+ connection.vertexBuffer.upload(this._float32View.subarray(0, vertexCount * vertexStrideWords$1));
6209
+ connection.indexBuffer.upload(this._indexData.subarray(0, indexCount));
6210
+ connection.vao.draw(indexCount, 0, RenderingPrimitives.Triangles);
6211
+ backend.stats.batches++;
6212
+ backend.stats.drawCalls++;
6213
+ }
6214
+ flush() {
6215
+ // Mesh draws are immediate per-mesh; nothing to batch.
6216
+ }
6217
+ destroy() {
6218
+ this.disconnect();
6219
+ this._shader.destroy();
6220
+ this._currentBlendMode = null;
6221
+ this._currentView = null;
6222
+ }
6223
+ onConnect(backend) {
6224
+ const gl = backend.context;
6111
6225
  const vaoHandle = gl.createVertexArray();
6112
6226
  if (vaoHandle === null) {
6113
- throw new Error('WebGl2ParticleRenderer: could not create vertex array object.');
6227
+ throw new Error('Could not create vertex array object.');
6114
6228
  }
6115
- return {
6116
- gl,
6117
- buffers: new Map(),
6118
- vaoHandle,
6119
- };
6229
+ this._shader.connect(createWebGl2ShaderProgram(gl));
6230
+ const buffers = new Map();
6231
+ const indexBuffer = new WebGl2RenderBuffer(BufferTypes.ElementArrayBuffer, this._indexData, BufferUsage.DynamicDraw)
6232
+ .connect(this._createBufferRuntime(gl, buffers));
6233
+ const vertexBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._vertexData, BufferUsage.DynamicDraw)
6234
+ .connect(this._createBufferRuntime(gl, buffers));
6235
+ // Force the shader's first finalize so attribute locations are
6236
+ // available immediately (the async-compile path defers extraction
6237
+ // to first sync(), but we need attributes here for VAO setup).
6238
+ this._shader.sync();
6239
+ const vao = new WebGl2VertexArrayObject()
6240
+ .addIndex(indexBuffer)
6241
+ .addAttribute(vertexBuffer, this._shader.getAttribute('a_position'), gl.FLOAT, false, vertexStrideBytes$5, 0)
6242
+ .addAttribute(vertexBuffer, this._shader.getAttribute('a_texcoord'), gl.FLOAT, false, vertexStrideBytes$5, 8)
6243
+ .addAttribute(vertexBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, vertexStrideBytes$5, 16)
6244
+ .connect(this._createVaoRuntime(gl, vaoHandle));
6245
+ this._connection = { gl, vao, vertexBuffer, indexBuffer };
6120
6246
  }
6121
- _createBufferRuntime(connection) {
6122
- const handle = connection.gl.createBuffer();
6247
+ onDisconnect() {
6248
+ const connection = this._connection;
6249
+ if (!connection) {
6250
+ return;
6251
+ }
6252
+ this._shader.disconnect();
6253
+ connection.indexBuffer.destroy();
6254
+ connection.vertexBuffer.destroy();
6255
+ connection.vao.destroy();
6256
+ this._connection = null;
6257
+ this._currentBlendMode = null;
6258
+ this._currentView = null;
6259
+ this._viewId = -1;
6260
+ }
6261
+ _ensureVertexCapacity(vertexCount) {
6262
+ if (vertexCount <= this._vertexCapacity) {
6263
+ return;
6264
+ }
6265
+ while (this._vertexCapacity < vertexCount) {
6266
+ this._vertexCapacity *= 2;
6267
+ }
6268
+ this._vertexData = new ArrayBuffer(this._vertexCapacity * vertexStrideBytes$5);
6269
+ this._float32View = new Float32Array(this._vertexData);
6270
+ this._uint32View = new Uint32Array(this._vertexData);
6271
+ }
6272
+ _ensureIndexCapacity(indexCount) {
6273
+ if (indexCount <= this._indexCapacity) {
6274
+ return;
6275
+ }
6276
+ while (this._indexCapacity < indexCount) {
6277
+ this._indexCapacity *= 2;
6278
+ }
6279
+ this._indexData = new Uint16Array(this._indexCapacity);
6280
+ }
6281
+ _createBufferRuntime(gl, buffers) {
6282
+ const handle = gl.createBuffer();
6123
6283
  if (handle === null) {
6124
- throw new Error('WebGl2ParticleRenderer: could not create render buffer.');
6284
+ throw new Error('Could not create render buffer.');
6125
6285
  }
6126
6286
  return {
6127
6287
  bind: (buffer) => {
6128
- connection.gl.bindBuffer(buffer.type, handle);
6288
+ gl.bindBuffer(buffer.type, handle);
6129
6289
  },
6130
6290
  upload: (buffer, offset) => {
6131
- const gl = connection.gl;
6291
+ const state = buffers.get(buffer);
6132
6292
  const data = buffer.data;
6133
- const state = connection.buffers.get(buffer);
6134
6293
  gl.bindBuffer(buffer.type, handle);
6135
6294
  if (state && state.dataByteLength >= data.byteLength) {
6136
6295
  gl.bufferSubData(buffer.type, offset, data);
@@ -6138,22 +6297,21 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
6138
6297
  }
6139
6298
  else {
6140
6299
  gl.bufferData(buffer.type, data, buffer.usage);
6141
- connection.buffers.set(buffer, { handle, dataByteLength: data.byteLength });
6300
+ buffers.set(buffer, { handle, dataByteLength: data.byteLength });
6142
6301
  }
6143
6302
  },
6144
6303
  destroy: (buffer) => {
6145
- connection.gl.deleteBuffer(handle);
6146
- connection.buffers.delete(buffer);
6304
+ gl.deleteBuffer(handle);
6305
+ buffers.delete(buffer);
6147
6306
  buffer.disconnect();
6148
6307
  },
6149
6308
  };
6150
6309
  }
6151
- _createVaoRuntime(connection) {
6310
+ _createVaoRuntime(gl, vaoHandle) {
6152
6311
  let appliedVersion = -1;
6153
6312
  return {
6154
6313
  bind: (vao) => {
6155
- const gl = connection.gl;
6156
- gl.bindVertexArray(connection.vaoHandle);
6314
+ gl.bindVertexArray(vaoHandle);
6157
6315
  if (appliedVersion !== vao.version) {
6158
6316
  let lastBuffer = null;
6159
6317
  for (const attribute of vao.attributes) {
@@ -6161,14 +6319,8 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
6161
6319
  attribute.buffer.bind();
6162
6320
  lastBuffer = attribute.buffer;
6163
6321
  }
6164
- if (attribute.integer) {
6165
- gl.vertexAttribIPointer(attribute.location, attribute.size, attribute.type, attribute.stride, attribute.start);
6166
- }
6167
- else {
6168
- gl.vertexAttribPointer(attribute.location, attribute.size, attribute.type, attribute.normalized, attribute.stride, attribute.start);
6169
- }
6322
+ gl.vertexAttribPointer(attribute.location, attribute.size, attribute.type, attribute.normalized, attribute.stride, attribute.start);
6170
6323
  gl.enableVertexAttribArray(attribute.location);
6171
- gl.vertexAttribDivisor(attribute.location, attribute.divisor);
6172
6324
  }
6173
6325
  if (vao.indexBuffer) {
6174
6326
  vao.indexBuffer.bind();
@@ -6177,10 +6329,9 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
6177
6329
  }
6178
6330
  },
6179
6331
  unbind: () => {
6180
- connection.gl.bindVertexArray(null);
6332
+ gl.bindVertexArray(null);
6181
6333
  },
6182
6334
  draw: (vao, size, start, type) => {
6183
- const gl = connection.gl;
6184
6335
  if (vao.indexBuffer) {
6185
6336
  gl.drawElements(type, size, gl.UNSIGNED_SHORT, start);
6186
6337
  }
@@ -6188,181 +6339,234 @@ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
6188
6339
  gl.drawArrays(type, start, size);
6189
6340
  }
6190
6341
  },
6191
- drawInstanced: (vao, count, start, instanceCount, type) => {
6192
- const gl = connection.gl;
6193
- if (vao.indexBuffer) {
6194
- gl.drawElementsInstanced(type, count, gl.UNSIGNED_SHORT, start, instanceCount);
6195
- }
6196
- else {
6197
- gl.drawArraysInstanced(type, start, count, instanceCount);
6198
- }
6199
- },
6200
6342
  destroy: (vao) => {
6201
- connection.gl.deleteVertexArray(connection.vaoHandle);
6343
+ gl.deleteVertexArray(vaoHandle);
6202
6344
  vao.disconnect();
6203
6345
  },
6204
6346
  };
6205
6347
  }
6206
6348
  }
6207
6349
 
6208
- var vertexSource$1 = "#version 300 es\r\nprecision lowp float;\r\n\r\nlayout(location = 0) in vec2 a_position;\r\nlayout(location = 1) in vec4 a_color;\r\n\r\nuniform mat3 u_projection;\r\nuniform mat3 u_translation;\r\n\r\nout vec4 v_color;\r\n\r\nvoid main(void) {\r\n gl_Position = vec4((u_projection * u_translation * vec3(a_position, 1.0)).xy, 0.0, 1.0);\r\n v_color = vec4(a_color.rgb * a_color.a, a_color.a);\r\n}\r\n";
6350
+ var vertexSource$2 = "#version 300 es\nprecision lowp float;\nprecision lowp int;\n\n// Per-instance attributes (one entry per particle, 24 bytes total).\nlayout(location = 0) in vec2 a_translation; // particle position in system-local space\nlayout(location = 1) in vec2 a_scale; // particle scale\nlayout(location = 2) in float a_rotation; // particle rotation in degrees\nlayout(location = 3) in vec4 a_color; // RGBA tint\n\nuniform mat3 u_projection;\nuniform mat3 u_systemTransform;\nuniform vec4 u_localBounds; // left, top, right, bottom (system.vertices)\nuniform vec4 u_uvBounds; // uMin, vMin, uMax, vMax (flipY-swapped)\n\nout vec2 v_texcoord;\nout vec4 v_color;\n\nvoid main(void) {\n // Static index buffer is [0,1,2,0,2,3] (triangle-list), so gl_VertexID 0..3\n // maps to TL, TR, BR, BL via the same bit math the sprite renderer uses.\n int vid = gl_VertexID;\n int cornerX = ((vid + 1) >> 1) & 1;\n int cornerY = vid >> 1;\n\n float localX = (cornerX == 0) ? u_localBounds.x : u_localBounds.z;\n float localY = (cornerY == 0) ? u_localBounds.y : u_localBounds.w;\n\n // Per-particle scale + rotation.\n vec2 rotation = vec2(sin(radians(a_rotation)), cos(radians(a_rotation)));\n vec2 transformed = vec2(\n (localX * (a_scale.x * rotation.y)) + (localY * (a_scale.y * rotation.x)),\n (localX * (a_scale.x * -rotation.x)) + (localY * (a_scale.y * rotation.y))\n );\n\n vec3 worldPos = vec3(transformed + a_translation, 1.0);\n\n gl_Position = vec4((u_projection * u_systemTransform * worldPos).xy, 0.0, 1.0);\n\n float u = (cornerX == 0) ? u_uvBounds.x : u_uvBounds.z;\n float v = (cornerY == 0) ? u_uvBounds.y : u_uvBounds.w;\n v_texcoord = vec2(u, v);\n\n v_color = vec4(a_color.rgb * a_color.a, a_color.a);\n}\n";
6209
6351
 
6210
- var fragmentSource$1 = "#version 300 es\r\nprecision lowp float;\r\n\r\nlayout(location = 0) out vec4 fragColor;\r\n\r\nin vec4 v_color;\r\n\r\nvoid main(void) {\r\n fragColor = v_color;\r\n}\r\n";
6352
+ var fragmentSource$2 = "#version 300 es\r\nprecision lowp float;\r\n\r\nuniform sampler2D u_texture;\r\n\r\nin vec2 v_texcoord;\r\nin vec4 v_color;\r\n\r\nlayout(location = 0) out vec4 fragColor;\r\n\r\nvoid main(void) {\r\n fragColor = texture(u_texture, v_texcoord) * v_color;\r\n}\r\n";
6211
6353
 
6212
- const minBatchVertexSize = 4;
6213
- const vertexStrideBytes$3 = 12;
6214
- const vertexStrideWords = vertexStrideBytes$3 / 4;
6215
- class WebGl2PrimitiveRenderer extends AbstractWebGl2Renderer {
6216
- _vertexCapacity;
6217
- _indexCapacity;
6218
- _vertexData;
6219
- _indexData;
6220
- _float32View;
6221
- _uint32View;
6222
- _shader = new Shader(vertexSource$1, fragmentSource$1);
6223
- _connection = null;
6354
+ /**
6355
+ * Instanced particle renderer for WebGL2.
6356
+ *
6357
+ * One ParticleSystem = one batch. Each `render(system)` flushes any
6358
+ * pending batch, sets the system-level uniforms (transform, local
6359
+ * bounds, UV bounds, texture), and packs every active particle into
6360
+ * the per-instance buffer. The next `flush()` issues a single
6361
+ * `drawElementsInstanced` for that system.
6362
+ *
6363
+ * Per-instance layout (24 bytes per particle, 4 attributes):
6364
+ * ```
6365
+ * translation f32x2 (offset 0, 8 bytes) particle position (system-local)
6366
+ * scale f32x2 (offset 8, 8 bytes)
6367
+ * rotation f32 (offset 16, 4 bytes) degrees
6368
+ * color u8x4 (offset 20, 4 bytes) RGBA tint, normalised
6369
+ * ```
6370
+ *
6371
+ * vs the previous per-vertex layout (36 bytes per vertex × 4 verts =
6372
+ * 144 bytes per particle), this is an 83% bandwidth reduction and
6373
+ * roughly 80% fewer CPU writes per particle (one pack call vs four
6374
+ * duplicated vertex writes plus per-corner UV coords).
6375
+ *
6376
+ * The system transform stays as a uniform — mixing systems in one
6377
+ * batch would require either a per-instance transform matrix (more
6378
+ * bandwidth) or per-particle texture-slot indexing (multi-texture
6379
+ * support similar to the sprite renderer). Both are follow-ups; the
6380
+ * current pattern keeps the renderer focused on the per-particle win.
6381
+ */
6382
+ const instanceStrideBytes$2 = 24;
6383
+ const wordsPerInstance$1 = instanceStrideBytes$2 / Uint32Array.BYTES_PER_ELEMENT;
6384
+ const indicesPerQuad = 6;
6385
+ const quadIndices$2 = new Uint16Array([0, 1, 2, 0, 2, 3]);
6386
+ class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
6387
+ _shader;
6388
+ _batchSize;
6389
+ _instanceData;
6390
+ _instanceFloat32;
6391
+ _instanceUint32;
6392
+ _instanceCount = 0;
6393
+ _currentTexture = null;
6224
6394
  _currentBlendMode = null;
6225
6395
  _currentView = null;
6226
- _viewId = -1;
6396
+ _currentViewId = -1;
6397
+ _instanceBuffer = null;
6398
+ _indexBuffer = null;
6399
+ _vao = null;
6400
+ _connection = null;
6227
6401
  constructor(batchSize) {
6228
6402
  super();
6229
- this._vertexCapacity = Math.max(minBatchVertexSize, batchSize);
6230
- this._indexCapacity = Math.max(6, this._vertexCapacity * 3);
6231
- this._vertexData = new ArrayBuffer(this._vertexCapacity * vertexStrideBytes$3);
6232
- this._indexData = new Uint16Array(this._indexCapacity);
6233
- this._float32View = new Float32Array(this._vertexData);
6234
- this._uint32View = new Uint32Array(this._vertexData);
6403
+ this._batchSize = batchSize;
6404
+ this._shader = new Shader(vertexSource$2, fragmentSource$2);
6405
+ this._instanceData = new ArrayBuffer(batchSize * instanceStrideBytes$2);
6406
+ this._instanceFloat32 = new Float32Array(this._instanceData);
6407
+ this._instanceUint32 = new Uint32Array(this._instanceData);
6235
6408
  }
6236
- render(shape) {
6237
- const connection = this._connection;
6238
- if (!connection) {
6239
- throw new Error('Renderer not connected');
6240
- }
6409
+ render(system) {
6241
6410
  const backend = this.getBackend();
6242
- const { geometry, drawMode, color, blendMode } = shape;
6243
- const vertices = geometry.vertices;
6244
- const sourceIndices = geometry.indices;
6245
- const vertexCount = vertices.length / 2;
6246
- const indexCount = sourceIndices.length > 0 ? sourceIndices.length : vertexCount;
6247
- if (vertexCount === 0 || indexCount === 0) {
6248
- return;
6411
+ const { texture, particles, blendMode } = system;
6412
+ const textureChanged = texture !== this._currentTexture;
6413
+ const blendModeChanged = blendMode !== this._currentBlendMode;
6414
+ // System transform / texture / UV / local-bounds are uniforms, so
6415
+ // mixing systems in one batch is invalid. Flush any prior system
6416
+ // before setting up this one.
6417
+ this.flush();
6418
+ if (textureChanged) {
6419
+ this._currentTexture = texture;
6420
+ backend.bindTexture(texture);
6249
6421
  }
6250
- this._ensureVertexCapacity(vertexCount);
6251
- this._ensureIndexCapacity(indexCount);
6252
- if (blendMode !== this._currentBlendMode) {
6422
+ if (blendModeChanged) {
6253
6423
  this._currentBlendMode = blendMode;
6254
6424
  backend.setBlendMode(blendMode);
6255
6425
  }
6256
- const view = backend.view;
6257
- if (this._currentView !== view || this._viewId !== view.updateId) {
6258
- this._currentView = view;
6259
- this._viewId = view.updateId;
6260
- this._shader.getUniform('u_projection').setValue(view.getTransform().toArray(false));
6261
- }
6262
- this._shader.getUniform('u_translation').setValue(shape.getGlobalTransform().toArray(false));
6263
- const packedColor = color.toRgba();
6264
- for (let i = 0; i < vertexCount; i++) {
6265
- const sourceIndex = i * 2;
6266
- const targetIndex = i * vertexStrideWords;
6267
- this._float32View[targetIndex] = vertices[sourceIndex];
6268
- this._float32View[targetIndex + 1] = vertices[sourceIndex + 1];
6269
- this._uint32View[targetIndex + 2] = packedColor;
6426
+ // System-level uniforms are set before packing so the eventual
6427
+ // flush() can sync them in one go.
6428
+ const localBounds = system.vertices;
6429
+ const uvBounds = this._unpackUvBounds(system);
6430
+ this._shader
6431
+ .getUniform('u_systemTransform')
6432
+ .setValue(system.getGlobalTransform().toArray(false));
6433
+ this._shader
6434
+ .getUniform('u_localBounds')
6435
+ .setValue(localBounds);
6436
+ this._shader
6437
+ .getUniform('u_uvBounds')
6438
+ .setValue(uvBounds);
6439
+ const f32 = this._instanceFloat32;
6440
+ const u32 = this._instanceUint32;
6441
+ const limit = Math.min(particles.length, this._batchSize);
6442
+ for (let i = 0; i < limit; i++) {
6443
+ const particle = particles[i];
6444
+ const offset = i * wordsPerInstance$1;
6445
+ f32[offset + 0] = particle.position.x;
6446
+ f32[offset + 1] = particle.position.y;
6447
+ f32[offset + 2] = particle.scale.x;
6448
+ f32[offset + 3] = particle.scale.y;
6449
+ f32[offset + 4] = particle.rotation;
6450
+ u32[offset + 5] = particle.tint.toRgba();
6270
6451
  }
6271
- if (sourceIndices.length > 0) {
6272
- this._indexData.set(sourceIndices, 0);
6452
+ this._instanceCount = limit;
6453
+ return this;
6454
+ }
6455
+ flush() {
6456
+ const backend = this.getBackendOrNull();
6457
+ const instanceBuffer = this._instanceBuffer;
6458
+ const indexBuffer = this._indexBuffer;
6459
+ const vao = this._vao;
6460
+ if (this._instanceCount === 0 || backend === null || instanceBuffer === null || indexBuffer === null || vao === null) {
6461
+ return;
6273
6462
  }
6274
- else {
6275
- for (let i = 0; i < vertexCount; i++) {
6276
- this._indexData[i] = i;
6277
- }
6463
+ const view = backend.view;
6464
+ if (this._currentView !== view || this._currentViewId !== view.updateId) {
6465
+ this._currentView = view;
6466
+ this._currentViewId = view.updateId;
6467
+ this._shader
6468
+ .getUniform('u_projection')
6469
+ .setValue(view.getTransform().toArray(false));
6278
6470
  }
6279
6471
  this._shader.sync();
6280
- backend.bindVertexArrayObject(connection.vao);
6281
- connection.vertexBuffer.upload(this._float32View.subarray(0, vertexCount * vertexStrideWords));
6282
- connection.indexBuffer.upload(this._indexData.subarray(0, indexCount));
6283
- connection.vao.draw(indexCount, 0, drawMode);
6472
+ backend.bindVertexArrayObject(vao);
6473
+ instanceBuffer.upload(this._instanceFloat32.subarray(0, this._instanceCount * wordsPerInstance$1));
6474
+ vao.drawInstanced(indicesPerQuad, 0, this._instanceCount, RenderingPrimitives.Triangles);
6284
6475
  backend.stats.batches++;
6285
6476
  backend.stats.drawCalls++;
6286
- }
6287
- flush() {
6288
- // Primitive rendering is immediate per shape in this bridge stage.
6289
- }
6290
- destroy() {
6291
- this.disconnect();
6292
- this._shader.destroy();
6293
- this._currentBlendMode = null;
6294
- this._currentView = null;
6477
+ this._instanceCount = 0;
6295
6478
  }
6296
6479
  onConnect(backend) {
6297
6480
  const gl = backend.context;
6298
- const vaoHandle = gl.createVertexArray();
6299
6481
  this._shader.connect(createWebGl2ShaderProgram(gl));
6300
- if (vaoHandle === null) {
6301
- throw new Error('Could not create vertex array object.');
6302
- }
6303
- const buffers = new Map();
6304
- const indexBuffer = new WebGl2RenderBuffer(BufferTypes.ElementArrayBuffer, this._indexData, BufferUsage.DynamicDraw)
6305
- .connect(this._createBufferRuntime(gl, buffers));
6306
- const vertexBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._vertexData, BufferUsage.DynamicDraw)
6307
- .connect(this._createBufferRuntime(gl, buffers));
6308
- // Force shader finalize so the attribute table is populated. The
6309
- // async-compile path defers attribute extraction from initialize()
6310
- // to first sync(); without this nudge, getAttribute() below would
6311
- // throw "Attribute 'a_position' is not available".
6482
+ this._connection = this._createConnection(gl);
6483
+ this._indexBuffer = new WebGl2RenderBuffer(BufferTypes.ElementArrayBuffer, quadIndices$2, BufferUsage.StaticDraw)
6484
+ .connect(this._createBufferRuntime(this._connection));
6485
+ this._instanceBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._instanceData, BufferUsage.DynamicDraw)
6486
+ .connect(this._createBufferRuntime(this._connection));
6312
6487
  this._shader.sync();
6313
- const vao = new WebGl2VertexArrayObject()
6314
- .addIndex(indexBuffer)
6315
- .addAttribute(vertexBuffer, this._shader.getAttribute('a_position'), gl.FLOAT, false, vertexStrideBytes$3, 0)
6316
- .addAttribute(vertexBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, vertexStrideBytes$3, 8)
6317
- .connect(this._createVaoRuntime(gl, vaoHandle));
6318
- this._connection = { gl, buffers, vaoHandle, vao, indexBuffer, vertexBuffer };
6488
+ this._vao = new WebGl2VertexArrayObject()
6489
+ .addIndex(this._indexBuffer)
6490
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_translation'), gl.FLOAT, false, instanceStrideBytes$2, 0, false, 1)
6491
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_scale'), gl.FLOAT, false, instanceStrideBytes$2, 8, false, 1)
6492
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_rotation'), gl.FLOAT, false, instanceStrideBytes$2, 16, false, 1)
6493
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, instanceStrideBytes$2, 20, false, 1)
6494
+ .connect(this._createVaoRuntime(this._connection));
6319
6495
  }
6320
6496
  onDisconnect() {
6321
- const connection = this._connection;
6322
- if (!connection) {
6323
- return;
6324
- }
6325
6497
  this._shader.disconnect();
6326
- connection.indexBuffer.destroy();
6327
- connection.vertexBuffer.destroy();
6328
- connection.vao.destroy();
6498
+ this._instanceBuffer?.destroy();
6499
+ this._instanceBuffer = null;
6500
+ this._indexBuffer?.destroy();
6501
+ this._indexBuffer = null;
6502
+ this._vao?.destroy();
6503
+ this._vao = null;
6329
6504
  this._connection = null;
6505
+ this._currentTexture = null;
6330
6506
  this._currentBlendMode = null;
6331
6507
  this._currentView = null;
6332
- this._viewId = -1;
6333
- }
6334
- _ensureVertexCapacity(vertexCount) {
6335
- if (vertexCount <= this._vertexCapacity) {
6336
- return;
6337
- }
6338
- while (this._vertexCapacity < vertexCount) {
6339
- this._vertexCapacity *= 2;
6340
- }
6341
- this._vertexData = new ArrayBuffer(this._vertexCapacity * vertexStrideBytes$3);
6342
- this._float32View = new Float32Array(this._vertexData);
6343
- this._uint32View = new Uint32Array(this._vertexData);
6508
+ this._currentViewId = -1;
6509
+ this._instanceCount = 0;
6344
6510
  }
6345
- _ensureIndexCapacity(indexCount) {
6346
- if (indexCount <= this._indexCapacity) {
6347
- return;
6348
- }
6349
- while (this._indexCapacity < indexCount) {
6350
- this._indexCapacity *= 2;
6351
- }
6352
- this._indexData = new Uint16Array(this._indexCapacity);
6511
+ destroy() {
6512
+ this.disconnect();
6513
+ this._shader.destroy();
6353
6514
  }
6354
- _createBufferRuntime(gl, buffers) {
6355
- const handle = gl.createBuffer();
6515
+ /**
6516
+ * Convert the system's per-corner packed-u32 texCoords into the
6517
+ * (uMin, vMin, uMax, vMax) bounds the new vertex shader expects.
6518
+ * Already accounts for flipY (the system's `texCoords` baked it in).
6519
+ *
6520
+ * The four packed u32s, by corner index, are:
6521
+ * [0] TL = (uMin/uMax | vMin/vMax depending on flipY)
6522
+ * [1] TR
6523
+ * [2] BR
6524
+ * [3] BL
6525
+ * We need (uMin, vMin, uMax, vMax) — the corner-extreme values.
6526
+ */
6527
+ _uvBoundsScratch = new Float32Array(4);
6528
+ _unpackUvBounds(system) {
6529
+ const texCoords = system.texCoords;
6530
+ // Each packed u32: low 16 bits = U normalised to 0..65535,
6531
+ // high 16 bits = V normalised.
6532
+ const uTopLeft = (texCoords[0] & 0xFFFF) / 0xFFFF;
6533
+ const vTopLeft = ((texCoords[0] >>> 16) & 0xFFFF) / 0xFFFF;
6534
+ const uBottomRight = (texCoords[2] & 0xFFFF) / 0xFFFF;
6535
+ const vBottomRight = ((texCoords[2] >>> 16) & 0xFFFF) / 0xFFFF;
6536
+ // For flipY: TL.v becomes vMax (was minY → maxY). The shader picks
6537
+ // (cornerY == 0 ? vMin : vMax); writing TL.v into vMin and BR.v into
6538
+ // vMax matches the original per-corner ordering whether or not flipY
6539
+ // was applied at texCoords pack time.
6540
+ this._uvBoundsScratch[0] = uTopLeft;
6541
+ this._uvBoundsScratch[1] = vTopLeft;
6542
+ this._uvBoundsScratch[2] = uBottomRight;
6543
+ this._uvBoundsScratch[3] = vBottomRight;
6544
+ return this._uvBoundsScratch;
6545
+ }
6546
+ _createConnection(gl) {
6547
+ const vaoHandle = gl.createVertexArray();
6548
+ if (vaoHandle === null) {
6549
+ throw new Error('WebGl2ParticleRenderer: could not create vertex array object.');
6550
+ }
6551
+ return {
6552
+ gl,
6553
+ buffers: new Map(),
6554
+ vaoHandle,
6555
+ };
6556
+ }
6557
+ _createBufferRuntime(connection) {
6558
+ const handle = connection.gl.createBuffer();
6356
6559
  if (handle === null) {
6357
- throw new Error('Could not create render buffer.');
6560
+ throw new Error('WebGl2ParticleRenderer: could not create render buffer.');
6358
6561
  }
6359
6562
  return {
6360
6563
  bind: (buffer) => {
6361
- gl.bindBuffer(buffer.type, handle);
6564
+ connection.gl.bindBuffer(buffer.type, handle);
6362
6565
  },
6363
6566
  upload: (buffer, offset) => {
6364
- const state = buffers.get(buffer);
6567
+ const gl = connection.gl;
6365
6568
  const data = buffer.data;
6569
+ const state = connection.buffers.get(buffer);
6366
6570
  gl.bindBuffer(buffer.type, handle);
6367
6571
  if (state && state.dataByteLength >= data.byteLength) {
6368
6572
  gl.bufferSubData(buffer.type, offset, data);
@@ -6370,21 +6574,22 @@ class WebGl2PrimitiveRenderer extends AbstractWebGl2Renderer {
6370
6574
  }
6371
6575
  else {
6372
6576
  gl.bufferData(buffer.type, data, buffer.usage);
6373
- buffers.set(buffer, { handle, dataByteLength: data.byteLength });
6577
+ connection.buffers.set(buffer, { handle, dataByteLength: data.byteLength });
6374
6578
  }
6375
6579
  },
6376
6580
  destroy: (buffer) => {
6377
- gl.deleteBuffer(handle);
6378
- buffers.delete(buffer);
6581
+ connection.gl.deleteBuffer(handle);
6582
+ connection.buffers.delete(buffer);
6379
6583
  buffer.disconnect();
6380
6584
  },
6381
6585
  };
6382
6586
  }
6383
- _createVaoRuntime(gl, vaoHandle) {
6587
+ _createVaoRuntime(connection) {
6384
6588
  let appliedVersion = -1;
6385
6589
  return {
6386
6590
  bind: (vao) => {
6387
- gl.bindVertexArray(vaoHandle);
6591
+ const gl = connection.gl;
6592
+ gl.bindVertexArray(connection.vaoHandle);
6388
6593
  if (appliedVersion !== vao.version) {
6389
6594
  let lastBuffer = null;
6390
6595
  for (const attribute of vao.attributes) {
@@ -6392,8 +6597,14 @@ class WebGl2PrimitiveRenderer extends AbstractWebGl2Renderer {
6392
6597
  attribute.buffer.bind();
6393
6598
  lastBuffer = attribute.buffer;
6394
6599
  }
6395
- gl.vertexAttribPointer(attribute.location, attribute.size, attribute.type, attribute.normalized, attribute.stride, attribute.start);
6600
+ if (attribute.integer) {
6601
+ gl.vertexAttribIPointer(attribute.location, attribute.size, attribute.type, attribute.stride, attribute.start);
6602
+ }
6603
+ else {
6604
+ gl.vertexAttribPointer(attribute.location, attribute.size, attribute.type, attribute.normalized, attribute.stride, attribute.start);
6605
+ }
6396
6606
  gl.enableVertexAttribArray(attribute.location);
6607
+ gl.vertexAttribDivisor(attribute.location, attribute.divisor);
6397
6608
  }
6398
6609
  if (vao.indexBuffer) {
6399
6610
  vao.indexBuffer.bind();
@@ -6402,9 +6613,10 @@ class WebGl2PrimitiveRenderer extends AbstractWebGl2Renderer {
6402
6613
  }
6403
6614
  },
6404
6615
  unbind: () => {
6405
- gl.bindVertexArray(null);
6616
+ connection.gl.bindVertexArray(null);
6406
6617
  },
6407
6618
  draw: (vao, size, start, type) => {
6619
+ const gl = connection.gl;
6408
6620
  if (vao.indexBuffer) {
6409
6621
  gl.drawElements(type, size, gl.UNSIGNED_SHORT, start);
6410
6622
  }
@@ -6412,111 +6624,335 @@ class WebGl2PrimitiveRenderer extends AbstractWebGl2Renderer {
6412
6624
  gl.drawArrays(type, start, size);
6413
6625
  }
6414
6626
  },
6627
+ drawInstanced: (vao, count, start, instanceCount, type) => {
6628
+ const gl = connection.gl;
6629
+ if (vao.indexBuffer) {
6630
+ gl.drawElementsInstanced(type, count, gl.UNSIGNED_SHORT, start, instanceCount);
6631
+ }
6632
+ else {
6633
+ gl.drawArraysInstanced(type, start, count, instanceCount);
6634
+ }
6635
+ },
6415
6636
  destroy: (vao) => {
6416
- gl.deleteVertexArray(vaoHandle);
6637
+ connection.gl.deleteVertexArray(connection.vaoHandle);
6417
6638
  vao.disconnect();
6418
6639
  },
6419
6640
  };
6420
6641
  }
6421
6642
  }
6422
6643
 
6423
- var vertexSource = "#version 300 es\r\nprecision lowp float;\r\n\r\nlayout(location = 0) in vec2 a_position;\r\nlayout(location = 1) in vec2 a_texcoord;\r\n\r\nuniform mat3 u_projection;\r\n\r\nout vec2 v_texcoord;\r\n\r\nvoid main(void) {\r\n gl_Position = vec4((u_projection * vec3(a_position, 1.0)).xy, 0.0, 1.0);\r\n v_texcoord = a_texcoord;\r\n}\r\n";
6644
+ var vertexSource$1 = "#version 300 es\r\nprecision lowp float;\r\n\r\nlayout(location = 0) in vec2 a_position;\r\nlayout(location = 1) in vec4 a_color;\r\n\r\nuniform mat3 u_projection;\r\nuniform mat3 u_translation;\r\n\r\nout vec4 v_color;\r\n\r\nvoid main(void) {\r\n gl_Position = vec4((u_projection * u_translation * vec3(a_position, 1.0)).xy, 0.0, 1.0);\r\n v_color = vec4(a_color.rgb * a_color.a, a_color.a);\r\n}\r\n";
6424
6645
 
6425
- var fragmentSource = "#version 300 es\r\nprecision lowp float;\r\n\r\nuniform sampler2D u_content;\r\nuniform sampler2D u_mask;\r\n\r\nin vec2 v_texcoord;\r\n\r\nlayout(location = 0) out vec4 fragColor;\r\n\r\nvoid main(void) {\r\n vec4 contentColor = texture(u_content, v_texcoord);\r\n float maskAlpha = texture(u_mask, v_texcoord).a;\r\n\r\n fragColor = vec4(contentColor.rgb * maskAlpha, contentColor.a * maskAlpha);\r\n}\r\n";
6646
+ var fragmentSource$1 = "#version 300 es\r\nprecision lowp float;\r\n\r\nlayout(location = 0) out vec4 fragColor;\r\n\r\nin vec4 v_color;\r\n\r\nvoid main(void) {\r\n fragColor = v_color;\r\n}\r\n";
6426
6647
 
6427
- // 4 floats per vertex: position(x, y) + texcoord(u, v).
6428
- const vertexStrideBytes$2 = 16;
6429
- const quadIndices$1 = new Uint16Array([0, 1, 2, 0, 2, 3]);
6430
- /**
6431
- * Single-quad two-texture compositor used by `WebGl2Backend.composeWithAlphaMask`.
6432
- *
6433
- * Renders the content texture onto the active render target with each
6434
- * output texel's alpha multiplied by the mask texture's alpha at the
6435
- * same UV. Both textures are sampled with stretched-fit UVs over the
6436
- * destination rectangle.
6437
- *
6438
- * Intentionally not a {@link AbstractWebGl2Renderer} subclass: this
6439
- * compositor is invoked directly by the manager for non-Drawable
6440
- * compositing operations and never participates in the renderer
6441
- * registry dispatch path.
6442
- */
6443
- class WebGl2MaskCompositor {
6444
- _shader = new Shader(vertexSource, fragmentSource);
6445
- _vertexData = new ArrayBuffer(4 * vertexStrideBytes$2);
6446
- _float32View = new Float32Array(this._vertexData);
6447
- _contentSamplerSlot = new Int32Array([0]);
6448
- _maskSamplerSlot = new Int32Array([1]);
6648
+ const minBatchVertexSize = 4;
6649
+ const vertexStrideBytes$4 = 12;
6650
+ const vertexStrideWords = vertexStrideBytes$4 / 4;
6651
+ class WebGl2PrimitiveRenderer extends AbstractWebGl2Renderer {
6652
+ _vertexCapacity;
6653
+ _indexCapacity;
6654
+ _vertexData;
6655
+ _indexData;
6656
+ _float32View;
6657
+ _uint32View;
6658
+ _shader = new Shader(vertexSource$1, fragmentSource$1);
6449
6659
  _connection = null;
6450
- connect(backend) {
6451
- if (this._connection !== null) {
6660
+ _currentBlendMode = null;
6661
+ _currentView = null;
6662
+ _viewId = -1;
6663
+ constructor(batchSize) {
6664
+ super();
6665
+ this._vertexCapacity = Math.max(minBatchVertexSize, batchSize);
6666
+ this._indexCapacity = Math.max(6, this._vertexCapacity * 3);
6667
+ this._vertexData = new ArrayBuffer(this._vertexCapacity * vertexStrideBytes$4);
6668
+ this._indexData = new Uint16Array(this._indexCapacity);
6669
+ this._float32View = new Float32Array(this._vertexData);
6670
+ this._uint32View = new Uint32Array(this._vertexData);
6671
+ }
6672
+ render(shape) {
6673
+ const connection = this._connection;
6674
+ if (!connection) {
6675
+ throw new Error('Renderer not connected');
6676
+ }
6677
+ const backend = this.getBackend();
6678
+ const { geometry, drawMode, color, blendMode } = shape;
6679
+ const vertices = geometry.vertices;
6680
+ const sourceIndices = geometry.indices;
6681
+ const vertexCount = vertices.length / 2;
6682
+ const indexCount = sourceIndices.length > 0 ? sourceIndices.length : vertexCount;
6683
+ if (vertexCount === 0 || indexCount === 0) {
6452
6684
  return;
6453
6685
  }
6686
+ this._ensureVertexCapacity(vertexCount);
6687
+ this._ensureIndexCapacity(indexCount);
6688
+ if (blendMode !== this._currentBlendMode) {
6689
+ this._currentBlendMode = blendMode;
6690
+ backend.setBlendMode(blendMode);
6691
+ }
6692
+ const view = backend.view;
6693
+ if (this._currentView !== view || this._viewId !== view.updateId) {
6694
+ this._currentView = view;
6695
+ this._viewId = view.updateId;
6696
+ this._shader.getUniform('u_projection').setValue(view.getTransform().toArray(false));
6697
+ }
6698
+ this._shader.getUniform('u_translation').setValue(shape.getGlobalTransform().toArray(false));
6699
+ const packedColor = color.toRgba();
6700
+ for (let i = 0; i < vertexCount; i++) {
6701
+ const sourceIndex = i * 2;
6702
+ const targetIndex = i * vertexStrideWords;
6703
+ this._float32View[targetIndex] = vertices[sourceIndex];
6704
+ this._float32View[targetIndex + 1] = vertices[sourceIndex + 1];
6705
+ this._uint32View[targetIndex + 2] = packedColor;
6706
+ }
6707
+ if (sourceIndices.length > 0) {
6708
+ this._indexData.set(sourceIndices, 0);
6709
+ }
6710
+ else {
6711
+ for (let i = 0; i < vertexCount; i++) {
6712
+ this._indexData[i] = i;
6713
+ }
6714
+ }
6715
+ this._shader.sync();
6716
+ backend.bindVertexArrayObject(connection.vao);
6717
+ connection.vertexBuffer.upload(this._float32View.subarray(0, vertexCount * vertexStrideWords));
6718
+ connection.indexBuffer.upload(this._indexData.subarray(0, indexCount));
6719
+ connection.vao.draw(indexCount, 0, drawMode);
6720
+ backend.stats.batches++;
6721
+ backend.stats.drawCalls++;
6722
+ }
6723
+ flush() {
6724
+ // Primitive rendering is immediate per shape in this bridge stage.
6725
+ }
6726
+ destroy() {
6727
+ this.disconnect();
6728
+ this._shader.destroy();
6729
+ this._currentBlendMode = null;
6730
+ this._currentView = null;
6731
+ }
6732
+ onConnect(backend) {
6454
6733
  const gl = backend.context;
6455
6734
  const vaoHandle = gl.createVertexArray();
6735
+ this._shader.connect(createWebGl2ShaderProgram(gl));
6456
6736
  if (vaoHandle === null) {
6457
- throw new Error('WebGl2MaskCompositor: could not create vertex array object.');
6737
+ throw new Error('Could not create vertex array object.');
6458
6738
  }
6459
- this._shader.connect(createWebGl2ShaderProgram(gl));
6460
- const bufferHandles = new Map();
6461
- const indexBuffer = new WebGl2RenderBuffer(BufferTypes.ElementArrayBuffer, quadIndices$1, BufferUsage.StaticDraw)
6462
- .connect(this._createBufferRuntime(gl, bufferHandles));
6739
+ const buffers = new Map();
6740
+ const indexBuffer = new WebGl2RenderBuffer(BufferTypes.ElementArrayBuffer, this._indexData, BufferUsage.DynamicDraw)
6741
+ .connect(this._createBufferRuntime(gl, buffers));
6463
6742
  const vertexBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._vertexData, BufferUsage.DynamicDraw)
6464
- .connect(this._createBufferRuntime(gl, bufferHandles));
6465
- // Force shader finalize so getAttribute() below sees a populated
6466
- // attribute table; the async-compile path defers extraction until
6467
- // the first sync() call.
6743
+ .connect(this._createBufferRuntime(gl, buffers));
6744
+ // Force shader finalize so the attribute table is populated. The
6745
+ // async-compile path defers attribute extraction from initialize()
6746
+ // to first sync(); without this nudge, getAttribute() below would
6747
+ // throw "Attribute 'a_position' is not available".
6468
6748
  this._shader.sync();
6469
6749
  const vao = new WebGl2VertexArrayObject()
6470
6750
  .addIndex(indexBuffer)
6471
- .addAttribute(vertexBuffer, this._shader.getAttribute('a_position'), gl.FLOAT, false, vertexStrideBytes$2, 0)
6472
- .addAttribute(vertexBuffer, this._shader.getAttribute('a_texcoord'), gl.FLOAT, false, vertexStrideBytes$2, 8)
6751
+ .addAttribute(vertexBuffer, this._shader.getAttribute('a_position'), gl.FLOAT, false, vertexStrideBytes$4, 0)
6752
+ .addAttribute(vertexBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, vertexStrideBytes$4, 8)
6473
6753
  .connect(this._createVaoRuntime(gl, vaoHandle));
6474
- this._connection = { gl, vaoHandle, vao, indexBuffer, vertexBuffer, bufferHandles };
6754
+ this._connection = { gl, buffers, vaoHandle, vao, indexBuffer, vertexBuffer };
6475
6755
  }
6476
- disconnect() {
6756
+ onDisconnect() {
6477
6757
  const connection = this._connection;
6478
- if (connection === null) {
6758
+ if (!connection) {
6479
6759
  return;
6480
6760
  }
6761
+ this._shader.disconnect();
6481
6762
  connection.indexBuffer.destroy();
6482
6763
  connection.vertexBuffer.destroy();
6483
6764
  connection.vao.destroy();
6484
- this._shader.disconnect();
6485
6765
  this._connection = null;
6766
+ this._currentBlendMode = null;
6767
+ this._currentView = null;
6768
+ this._viewId = -1;
6486
6769
  }
6487
- compose(backend, content, mask, x, y, width, height, blendMode) {
6488
- const connection = this._connection;
6489
- if (connection === null) {
6490
- throw new Error('WebGl2MaskCompositor: not connected.');
6770
+ _ensureVertexCapacity(vertexCount) {
6771
+ if (vertexCount <= this._vertexCapacity) {
6772
+ return;
6491
6773
  }
6492
- // Update the quad vertices for this destination rect. UVs are 0..1
6493
- // mapped over (left, top) → (right, bottom) with an explicit Y-flip
6494
- // for render-texture sampling: the mask path samples textures
6495
- // already authored as RGBA in render-texture orientation.
6496
- this._writeQuadVertices(x, y, x + width, y + height);
6497
- // Bind the compositor program. Setting projection + sampler uniforms
6498
- // each call because they need to match the current render target.
6499
- backend.bindShader(this._shader);
6500
- const view = backend.view;
6501
- const projection = view.getTransform().toArray(false);
6502
- this._shader.getUniform('u_projection').setValue(projection);
6503
- this._shader.getUniform('u_content').setValue(this._contentSamplerSlot);
6504
- this._shader.getUniform('u_mask').setValue(this._maskSamplerSlot);
6505
- this._shader.sync();
6506
- backend.bindTexture(content, 0);
6507
- backend.bindTexture(mask, 1);
6508
- backend.setBlendMode(blendMode);
6509
- backend.bindVertexArrayObject(connection.vao);
6510
- connection.vertexBuffer.upload(this._float32View);
6511
- connection.vao.draw(6, 0);
6512
- backend.stats.batches++;
6513
- backend.stats.drawCalls++;
6514
- // Reset the active texture unit to 0 to avoid leaking unit 1 into
6515
- // subsequent renderer state.
6516
- backend.bindTexture(null, 1);
6517
- }
6518
- _writeQuadVertices(left, top, right, bottom) {
6519
- const view = this._float32View;
6774
+ while (this._vertexCapacity < vertexCount) {
6775
+ this._vertexCapacity *= 2;
6776
+ }
6777
+ this._vertexData = new ArrayBuffer(this._vertexCapacity * vertexStrideBytes$4);
6778
+ this._float32View = new Float32Array(this._vertexData);
6779
+ this._uint32View = new Uint32Array(this._vertexData);
6780
+ }
6781
+ _ensureIndexCapacity(indexCount) {
6782
+ if (indexCount <= this._indexCapacity) {
6783
+ return;
6784
+ }
6785
+ while (this._indexCapacity < indexCount) {
6786
+ this._indexCapacity *= 2;
6787
+ }
6788
+ this._indexData = new Uint16Array(this._indexCapacity);
6789
+ }
6790
+ _createBufferRuntime(gl, buffers) {
6791
+ const handle = gl.createBuffer();
6792
+ if (handle === null) {
6793
+ throw new Error('Could not create render buffer.');
6794
+ }
6795
+ return {
6796
+ bind: (buffer) => {
6797
+ gl.bindBuffer(buffer.type, handle);
6798
+ },
6799
+ upload: (buffer, offset) => {
6800
+ const state = buffers.get(buffer);
6801
+ const data = buffer.data;
6802
+ gl.bindBuffer(buffer.type, handle);
6803
+ if (state && state.dataByteLength >= data.byteLength) {
6804
+ gl.bufferSubData(buffer.type, offset, data);
6805
+ state.dataByteLength = data.byteLength;
6806
+ }
6807
+ else {
6808
+ gl.bufferData(buffer.type, data, buffer.usage);
6809
+ buffers.set(buffer, { handle, dataByteLength: data.byteLength });
6810
+ }
6811
+ },
6812
+ destroy: (buffer) => {
6813
+ gl.deleteBuffer(handle);
6814
+ buffers.delete(buffer);
6815
+ buffer.disconnect();
6816
+ },
6817
+ };
6818
+ }
6819
+ _createVaoRuntime(gl, vaoHandle) {
6820
+ let appliedVersion = -1;
6821
+ return {
6822
+ bind: (vao) => {
6823
+ gl.bindVertexArray(vaoHandle);
6824
+ if (appliedVersion !== vao.version) {
6825
+ let lastBuffer = null;
6826
+ for (const attribute of vao.attributes) {
6827
+ if (lastBuffer !== attribute.buffer) {
6828
+ attribute.buffer.bind();
6829
+ lastBuffer = attribute.buffer;
6830
+ }
6831
+ gl.vertexAttribPointer(attribute.location, attribute.size, attribute.type, attribute.normalized, attribute.stride, attribute.start);
6832
+ gl.enableVertexAttribArray(attribute.location);
6833
+ }
6834
+ if (vao.indexBuffer) {
6835
+ vao.indexBuffer.bind();
6836
+ }
6837
+ appliedVersion = vao.version;
6838
+ }
6839
+ },
6840
+ unbind: () => {
6841
+ gl.bindVertexArray(null);
6842
+ },
6843
+ draw: (vao, size, start, type) => {
6844
+ if (vao.indexBuffer) {
6845
+ gl.drawElements(type, size, gl.UNSIGNED_SHORT, start);
6846
+ }
6847
+ else {
6848
+ gl.drawArrays(type, start, size);
6849
+ }
6850
+ },
6851
+ destroy: (vao) => {
6852
+ gl.deleteVertexArray(vaoHandle);
6853
+ vao.disconnect();
6854
+ },
6855
+ };
6856
+ }
6857
+ }
6858
+
6859
+ var vertexSource = "#version 300 es\r\nprecision lowp float;\r\n\r\nlayout(location = 0) in vec2 a_position;\r\nlayout(location = 1) in vec2 a_texcoord;\r\n\r\nuniform mat3 u_projection;\r\n\r\nout vec2 v_texcoord;\r\n\r\nvoid main(void) {\r\n gl_Position = vec4((u_projection * vec3(a_position, 1.0)).xy, 0.0, 1.0);\r\n v_texcoord = a_texcoord;\r\n}\r\n";
6860
+
6861
+ var fragmentSource = "#version 300 es\r\nprecision lowp float;\r\n\r\nuniform sampler2D u_content;\r\nuniform sampler2D u_mask;\r\n\r\nin vec2 v_texcoord;\r\n\r\nlayout(location = 0) out vec4 fragColor;\r\n\r\nvoid main(void) {\r\n vec4 contentColor = texture(u_content, v_texcoord);\r\n float maskAlpha = texture(u_mask, v_texcoord).a;\r\n\r\n fragColor = vec4(contentColor.rgb * maskAlpha, contentColor.a * maskAlpha);\r\n}\r\n";
6862
+
6863
+ // 4 floats per vertex: position(x, y) + texcoord(u, v).
6864
+ const vertexStrideBytes$3 = 16;
6865
+ const quadIndices$1 = new Uint16Array([0, 1, 2, 0, 2, 3]);
6866
+ /**
6867
+ * Single-quad two-texture compositor used by `WebGl2Backend.composeWithAlphaMask`.
6868
+ *
6869
+ * Renders the content texture onto the active render target with each
6870
+ * output texel's alpha multiplied by the mask texture's alpha at the
6871
+ * same UV. Both textures are sampled with stretched-fit UVs over the
6872
+ * destination rectangle.
6873
+ *
6874
+ * Intentionally not a {@link AbstractWebGl2Renderer} subclass: this
6875
+ * compositor is invoked directly by the manager for non-Drawable
6876
+ * compositing operations and never participates in the renderer
6877
+ * registry dispatch path.
6878
+ */
6879
+ class WebGl2MaskCompositor {
6880
+ _shader = new Shader(vertexSource, fragmentSource);
6881
+ _vertexData = new ArrayBuffer(4 * vertexStrideBytes$3);
6882
+ _float32View = new Float32Array(this._vertexData);
6883
+ _contentSamplerSlot = new Int32Array([0]);
6884
+ _maskSamplerSlot = new Int32Array([1]);
6885
+ _connection = null;
6886
+ connect(backend) {
6887
+ if (this._connection !== null) {
6888
+ return;
6889
+ }
6890
+ const gl = backend.context;
6891
+ const vaoHandle = gl.createVertexArray();
6892
+ if (vaoHandle === null) {
6893
+ throw new Error('WebGl2MaskCompositor: could not create vertex array object.');
6894
+ }
6895
+ this._shader.connect(createWebGl2ShaderProgram(gl));
6896
+ const bufferHandles = new Map();
6897
+ const indexBuffer = new WebGl2RenderBuffer(BufferTypes.ElementArrayBuffer, quadIndices$1, BufferUsage.StaticDraw)
6898
+ .connect(this._createBufferRuntime(gl, bufferHandles));
6899
+ const vertexBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._vertexData, BufferUsage.DynamicDraw)
6900
+ .connect(this._createBufferRuntime(gl, bufferHandles));
6901
+ // Force shader finalize so getAttribute() below sees a populated
6902
+ // attribute table; the async-compile path defers extraction until
6903
+ // the first sync() call.
6904
+ this._shader.sync();
6905
+ const vao = new WebGl2VertexArrayObject()
6906
+ .addIndex(indexBuffer)
6907
+ .addAttribute(vertexBuffer, this._shader.getAttribute('a_position'), gl.FLOAT, false, vertexStrideBytes$3, 0)
6908
+ .addAttribute(vertexBuffer, this._shader.getAttribute('a_texcoord'), gl.FLOAT, false, vertexStrideBytes$3, 8)
6909
+ .connect(this._createVaoRuntime(gl, vaoHandle));
6910
+ this._connection = { gl, vaoHandle, vao, indexBuffer, vertexBuffer, bufferHandles };
6911
+ }
6912
+ disconnect() {
6913
+ const connection = this._connection;
6914
+ if (connection === null) {
6915
+ return;
6916
+ }
6917
+ connection.indexBuffer.destroy();
6918
+ connection.vertexBuffer.destroy();
6919
+ connection.vao.destroy();
6920
+ this._shader.disconnect();
6921
+ this._connection = null;
6922
+ }
6923
+ compose(backend, content, mask, x, y, width, height, blendMode) {
6924
+ const connection = this._connection;
6925
+ if (connection === null) {
6926
+ throw new Error('WebGl2MaskCompositor: not connected.');
6927
+ }
6928
+ // Update the quad vertices for this destination rect. UVs are 0..1
6929
+ // mapped over (left, top) → (right, bottom) with an explicit Y-flip
6930
+ // for render-texture sampling: the mask path samples textures
6931
+ // already authored as RGBA in render-texture orientation.
6932
+ this._writeQuadVertices(x, y, x + width, y + height);
6933
+ // Bind the compositor program. Setting projection + sampler uniforms
6934
+ // each call because they need to match the current render target.
6935
+ backend.bindShader(this._shader);
6936
+ const view = backend.view;
6937
+ const projection = view.getTransform().toArray(false);
6938
+ this._shader.getUniform('u_projection').setValue(projection);
6939
+ this._shader.getUniform('u_content').setValue(this._contentSamplerSlot);
6940
+ this._shader.getUniform('u_mask').setValue(this._maskSamplerSlot);
6941
+ this._shader.sync();
6942
+ backend.bindTexture(content, 0);
6943
+ backend.bindTexture(mask, 1);
6944
+ backend.setBlendMode(blendMode);
6945
+ backend.bindVertexArrayObject(connection.vao);
6946
+ connection.vertexBuffer.upload(this._float32View);
6947
+ connection.vao.draw(6, 0);
6948
+ backend.stats.batches++;
6949
+ backend.stats.drawCalls++;
6950
+ // Reset the active texture unit to 0 to avoid leaking unit 1 into
6951
+ // subsequent renderer state.
6952
+ backend.bindTexture(null, 1);
6953
+ }
6954
+ _writeQuadVertices(left, top, right, bottom) {
6955
+ const view = this._float32View;
6520
6956
  // Vertex 0: top-left (UV 0, 0)
6521
6957
  view[0] = left;
6522
6958
  view[1] = top;
@@ -6761,6 +7197,116 @@ class Sprite extends Drawable {
6761
7197
  }
6762
7198
  RenderNode.setInternalSpriteFactory(() => new Sprite(null));
6763
7199
 
7200
+ /**
7201
+ * Arbitrary 2D triangle-mesh primitive.
7202
+ *
7203
+ * `Mesh` lives alongside {@link Sprite} as a public Drawable: it has the
7204
+ * same transform (position/rotation/scale/origin), tint, blendMode,
7205
+ * filters, masks, and cacheAsBitmap — but the geometry it renders is
7206
+ * user-supplied rather than implied by a texture frame. The intended use
7207
+ * cases are:
7208
+ *
7209
+ * - Custom-shape sprites whose silhouette isn't a quad (badges, speech
7210
+ * bubbles, region overlays).
7211
+ * - Deformable visuals (rope/ribbon, banner, water surface): mutate the
7212
+ * vertex array between frames and the GPU re-tessellates nothing —
7213
+ * only the transform changes per frame.
7214
+ * - Particles or trails with custom geometry per emitter.
7215
+ *
7216
+ * The mesh data is **immutable after construction** in v1: vertex /
7217
+ * index / UV / color arrays are exposed as readonly references. Mutate
7218
+ * the underlying typed arrays in-place if you need per-frame updates,
7219
+ * but the array lengths and topology cannot change. Texture is the only
7220
+ * post-construction mutable property.
7221
+ *
7222
+ * The vertex stream is a flat `Float32Array` of (x, y) pairs in local
7223
+ * space. The mesh's local bounds are computed once at construction from
7224
+ * the AABB of those vertices and used by the cull pass. Re-computing
7225
+ * after in-place mutation is the caller's responsibility (call
7226
+ * `recomputeLocalBounds()`).
7227
+ */
7228
+ class Mesh extends Drawable {
7229
+ vertices;
7230
+ indices;
7231
+ uvs;
7232
+ colors;
7233
+ _texture;
7234
+ constructor(options) {
7235
+ super();
7236
+ const { vertices, indices = null, uvs = null, colors = null, texture = null } = options;
7237
+ if (vertices.length === 0 || vertices.length % 2 !== 0) {
7238
+ throw new Error(`Mesh vertices must be a non-empty flat array of (x,y) pairs (got length ${vertices.length}).`);
7239
+ }
7240
+ const vertexCount = vertices.length / 2;
7241
+ if (vertexCount < 3) {
7242
+ throw new Error(`Mesh requires at least 3 vertices (got ${vertexCount}).`);
7243
+ }
7244
+ if (uvs !== null && uvs.length !== vertices.length) {
7245
+ throw new Error(`Mesh uvs length ${uvs.length} must equal vertices length ${vertices.length}.`);
7246
+ }
7247
+ if (colors !== null && colors.length !== vertexCount) {
7248
+ throw new Error(`Mesh colors length ${colors.length} must equal vertex count ${vertexCount}.`);
7249
+ }
7250
+ if (indices !== null) {
7251
+ if (indices.length === 0 || indices.length % 3 !== 0) {
7252
+ throw new Error(`Mesh indices must be a non-empty multiple of 3 (got length ${indices.length}).`);
7253
+ }
7254
+ for (let i = 0; i < indices.length; i++) {
7255
+ if (indices[i] >= vertexCount) {
7256
+ throw new Error(`Mesh index ${indices[i]} at position ${i} is out of range for vertex count ${vertexCount}.`);
7257
+ }
7258
+ }
7259
+ }
7260
+ else if (vertexCount % 3 !== 0) {
7261
+ throw new Error(`Non-indexed Mesh requires a vertex count that is a multiple of 3 (got ${vertexCount}).`);
7262
+ }
7263
+ this.vertices = vertices;
7264
+ this.indices = indices;
7265
+ this.uvs = uvs;
7266
+ this.colors = colors;
7267
+ this._texture = texture;
7268
+ this.recomputeLocalBounds();
7269
+ }
7270
+ get vertexCount() {
7271
+ return this.vertices.length / 2;
7272
+ }
7273
+ get indexCount() {
7274
+ return this.indices?.length ?? this.vertexCount;
7275
+ }
7276
+ get texture() {
7277
+ return this._texture;
7278
+ }
7279
+ set texture(texture) {
7280
+ this._texture = texture;
7281
+ this.invalidateCache();
7282
+ }
7283
+ /**
7284
+ * Recompute the local AABB from the current vertex array. Call after
7285
+ * mutating `vertices` in place to keep culling correct; otherwise the
7286
+ * bounds the cull pass sees will be the AABB at construction time.
7287
+ */
7288
+ recomputeLocalBounds() {
7289
+ let minX = Infinity;
7290
+ let minY = Infinity;
7291
+ let maxX = -Infinity;
7292
+ let maxY = -Infinity;
7293
+ for (let i = 0; i < this.vertices.length; i += 2) {
7294
+ const x = this.vertices[i];
7295
+ const y = this.vertices[i + 1];
7296
+ if (x < minX)
7297
+ minX = x;
7298
+ if (x > maxX)
7299
+ maxX = x;
7300
+ if (y < minY)
7301
+ minY = y;
7302
+ if (y > maxY)
7303
+ maxY = y;
7304
+ }
7305
+ this.localBounds.set(minX, minY, maxX - minX, maxY - minY);
7306
+ return this;
7307
+ }
7308
+ }
7309
+
6764
7310
  class Particle {
6765
7311
  _totalLifetime = Time.oneSecond.clone();
6766
7312
  _elapsedLifetime = Time.zero.clone();
@@ -7040,208 +7586,6 @@ class ParticleSystem extends Drawable {
7040
7586
  }
7041
7587
  }
7042
7588
 
7043
- const createQuadIndices = (size) => {
7044
- const data = new Uint16Array(size * 6);
7045
- const len = data.length;
7046
- for (let i = 0, offset = 0; i < len; i += 6, offset += 4) {
7047
- data[i] = offset;
7048
- data[i + 1] = offset + 1;
7049
- data[i + 2] = offset + 2;
7050
- data[i + 3] = offset;
7051
- data[i + 4] = offset + 2;
7052
- data[i + 5] = offset + 3;
7053
- }
7054
- return data;
7055
- };
7056
- const createCanvas = (options = {}) => {
7057
- const { canvas, fillStyle, width, height } = options;
7058
- const newCanvas = canvas ?? document.createElement('canvas');
7059
- const context = newCanvas.getContext('2d');
7060
- newCanvas.width = width ?? 10;
7061
- newCanvas.height = height ?? 10;
7062
- context.fillStyle = fillStyle ?? '#6495ed';
7063
- context.fillRect(0, 0, newCanvas.width, newCanvas.height);
7064
- return newCanvas;
7065
- };
7066
- const heightCache = new Map();
7067
- const determineFontHeight = (font) => {
7068
- if (!heightCache.has(font)) {
7069
- const body = document.body;
7070
- const dummy = document.createElement('div');
7071
- dummy.appendChild(document.createTextNode('M'));
7072
- dummy.setAttribute('style', `font: ${font};position:absolute;top:0;left:0`);
7073
- body.appendChild(dummy);
7074
- heightCache.set(font, dummy.offsetHeight);
7075
- body.removeChild(dummy);
7076
- }
7077
- return heightCache.get(font);
7078
- };
7079
-
7080
- class Texture {
7081
- static _black = null;
7082
- static _white = null;
7083
- static defaultSamplerOptions = {
7084
- scaleMode: ScaleModes.Linear,
7085
- wrapMode: WrapModes.ClampToEdge,
7086
- premultiplyAlpha: true,
7087
- generateMipMap: true,
7088
- flipY: false,
7089
- };
7090
- static empty = new Texture(null);
7091
- static get black() {
7092
- if (Texture._black === null) {
7093
- Texture._black = new Texture(createCanvas({ fillStyle: '#000' }));
7094
- }
7095
- return Texture._black;
7096
- }
7097
- static get white() {
7098
- if (Texture._white === null) {
7099
- Texture._white = new Texture(createCanvas({ fillStyle: '#fff' }));
7100
- }
7101
- return Texture._white;
7102
- }
7103
- _version = 0;
7104
- _source = null;
7105
- _size = new Size(0, 0);
7106
- _destroyListeners = new Set();
7107
- _scaleMode;
7108
- _wrapMode;
7109
- _premultiplyAlpha = false;
7110
- _generateMipMap = false;
7111
- _flipY = false;
7112
- constructor(source = null, options) {
7113
- const { scaleMode, wrapMode, premultiplyAlpha, generateMipMap, flipY } = { ...Texture.defaultSamplerOptions, ...options };
7114
- this._scaleMode = scaleMode;
7115
- this._wrapMode = wrapMode;
7116
- this._premultiplyAlpha = premultiplyAlpha;
7117
- this._generateMipMap = generateMipMap;
7118
- this._flipY = flipY;
7119
- if (source !== null) {
7120
- this.setSource(source);
7121
- }
7122
- }
7123
- get source() {
7124
- return this._source;
7125
- }
7126
- set source(source) {
7127
- this.setSource(source);
7128
- }
7129
- get size() {
7130
- return this._size;
7131
- }
7132
- set size(size) {
7133
- this.setSize(size.width, size.height);
7134
- }
7135
- get width() {
7136
- return this._size.width;
7137
- }
7138
- set width(width) {
7139
- this.setSize(width, this.height);
7140
- }
7141
- get height() {
7142
- return this._size.height;
7143
- }
7144
- set height(height) {
7145
- this.setSize(this.width, height);
7146
- }
7147
- get scaleMode() {
7148
- return this._scaleMode;
7149
- }
7150
- set scaleMode(scaleMode) {
7151
- this.setScaleMode(scaleMode);
7152
- }
7153
- get wrapMode() {
7154
- return this._wrapMode;
7155
- }
7156
- set wrapMode(wrapMode) {
7157
- this.setWrapMode(wrapMode);
7158
- }
7159
- get premultiplyAlpha() {
7160
- return this._premultiplyAlpha;
7161
- }
7162
- set premultiplyAlpha(premultiplyAlpha) {
7163
- this.setPremultiplyAlpha(premultiplyAlpha);
7164
- }
7165
- get generateMipMap() {
7166
- return this._generateMipMap;
7167
- }
7168
- set generateMipMap(generateMipMap) {
7169
- this._generateMipMap = generateMipMap;
7170
- }
7171
- get flipY() {
7172
- return this._flipY;
7173
- }
7174
- set flipY(flipY) {
7175
- this._flipY = flipY;
7176
- }
7177
- get powerOfTwo() {
7178
- return isPowerOfTwo(this.width) && isPowerOfTwo(this.height);
7179
- }
7180
- get version() {
7181
- return this._version;
7182
- }
7183
- addDestroyListener(listener) {
7184
- this._destroyListeners.add(listener);
7185
- return this;
7186
- }
7187
- removeDestroyListener(listener) {
7188
- this._destroyListeners.delete(listener);
7189
- return this;
7190
- }
7191
- setScaleMode(scaleMode) {
7192
- if (this._scaleMode !== scaleMode) {
7193
- this._scaleMode = scaleMode;
7194
- this._touch();
7195
- }
7196
- return this;
7197
- }
7198
- setWrapMode(wrapMode) {
7199
- if (this._wrapMode !== wrapMode) {
7200
- this._wrapMode = wrapMode;
7201
- this._touch();
7202
- }
7203
- return this;
7204
- }
7205
- setPremultiplyAlpha(premultiplyAlpha) {
7206
- if (this._premultiplyAlpha !== premultiplyAlpha) {
7207
- this._premultiplyAlpha = premultiplyAlpha;
7208
- this._touch();
7209
- }
7210
- return this;
7211
- }
7212
- setSource(source) {
7213
- if (this._source !== source) {
7214
- this._source = source;
7215
- this.updateSource();
7216
- }
7217
- return this;
7218
- }
7219
- updateSource() {
7220
- const { width, height } = getTextureSourceSize(this._source);
7221
- this.setSize(width, height);
7222
- this._touch();
7223
- return this;
7224
- }
7225
- setSize(width, height) {
7226
- if (!this._size.equals({ width, height })) {
7227
- this._size.set(width, height);
7228
- this._touch();
7229
- }
7230
- return this;
7231
- }
7232
- destroy() {
7233
- for (const listener of Array.from(this._destroyListeners)) {
7234
- listener();
7235
- }
7236
- this._destroyListeners.clear();
7237
- this._size.destroy();
7238
- this._source = null;
7239
- }
7240
- _touch() {
7241
- this._version++;
7242
- }
7243
- }
7244
-
7245
7589
  /**
7246
7590
  * Instance-based renderer registry.
7247
7591
  *
@@ -7427,6 +7771,7 @@ class WebGl2Backend {
7427
7771
  this._setupContext();
7428
7772
  this._addEvents();
7429
7773
  this.rendererRegistry.registerRenderer(Sprite, new WebGl2SpriteRenderer(spriteRendererBatchSize));
7774
+ this.rendererRegistry.registerRenderer(Mesh, new WebGl2MeshRenderer());
7430
7775
  this.rendererRegistry.registerRenderer(ParticleSystem, new WebGl2ParticleRenderer(particleRendererBatchSize));
7431
7776
  this.rendererRegistry.registerRenderer(DrawableShape, new WebGl2PrimitiveRenderer(primitiveRendererBatchSize));
7432
7777
  this.rendererRegistry.connect(this);
@@ -8076,334 +8421,994 @@ function getWebGpuBlendState(blendMode) {
8076
8421
  },
8077
8422
  };
8078
8423
  }
8079
- }
8080
-
8081
- /// <reference types="@webgpu/types" />
8082
- const primitiveShaderSource = `
8083
- struct VertexInput {
8084
- @location(0) position: vec4<f32>,
8085
- @location(1) color: vec4<f32>,
8086
- };
8087
-
8088
- struct VertexOutput {
8089
- @builtin(position) position: vec4<f32>,
8090
- @location(0) color: vec4<f32>,
8091
- };
8092
-
8093
- @vertex
8094
- fn vertexMain(input: VertexInput) -> VertexOutput {
8095
- var output: VertexOutput;
8096
-
8097
- output.position = input.position;
8098
- output.color = vec4<f32>(input.color.rgb * input.color.a, input.color.a);
8099
-
8100
- return output;
8101
- }
8102
-
8103
- @fragment
8104
- fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
8105
- return input.color;
8106
- }
8107
- `;
8108
- // 4 floats (pre-transformed clip-space position) + 1 u32 (color) = 20 bytes.
8109
- // The CPU applies (view * shape.globalTransform) to each vertex before writing
8110
- // it into the vertex buffer, so the shader outputs the position as-is. This
8111
- // matches the sprite renderer's approach and eliminates the need for a per-
8112
- // drawcall uniform binding.
8113
- const vertexStrideBytes$1 = 20;
8114
- const wordsPerVertex = vertexStrideBytes$1 / Uint32Array.BYTES_PER_ELEMENT;
8115
- class WebGpuPrimitiveRenderer extends AbstractWebGpuRenderer {
8116
- _combinedTransform = new Matrix();
8117
- _drawCalls = [];
8118
- _drawCallCount = 0;
8119
- _pipelines = new Map();
8120
- _device = null;
8121
- _shaderModule = null;
8122
- _pipelineLayout = null;
8123
- _vertexBuffer = null;
8124
- _indexBuffer = null;
8125
- _vertexBufferCapacity = 0;
8126
- _indexBufferCapacity = 0;
8127
- _vertexData = new ArrayBuffer(0);
8128
- _float32View = new Float32Array(this._vertexData);
8129
- _uint32View = new Uint32Array(this._vertexData);
8130
- _packedIndexData = new Uint16Array(0);
8131
- _generatedIndexData = new Uint16Array(0);
8132
- _sequentialIndexData = new Uint16Array(0);
8133
- render(shape) {
8424
+ }
8425
+
8426
+ /// <reference types="@webgpu/types" />
8427
+ const primitiveShaderSource = `
8428
+ struct VertexInput {
8429
+ @location(0) position: vec4<f32>,
8430
+ @location(1) color: vec4<f32>,
8431
+ };
8432
+
8433
+ struct VertexOutput {
8434
+ @builtin(position) position: vec4<f32>,
8435
+ @location(0) color: vec4<f32>,
8436
+ };
8437
+
8438
+ @vertex
8439
+ fn vertexMain(input: VertexInput) -> VertexOutput {
8440
+ var output: VertexOutput;
8441
+
8442
+ output.position = input.position;
8443
+ output.color = vec4<f32>(input.color.rgb * input.color.a, input.color.a);
8444
+
8445
+ return output;
8446
+ }
8447
+
8448
+ @fragment
8449
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
8450
+ return input.color;
8451
+ }
8452
+ `;
8453
+ // 4 floats (pre-transformed clip-space position) + 1 u32 (color) = 20 bytes.
8454
+ // The CPU applies (view * shape.globalTransform) to each vertex before writing
8455
+ // it into the vertex buffer, so the shader outputs the position as-is. This
8456
+ // matches the sprite renderer's approach and eliminates the need for a per-
8457
+ // drawcall uniform binding.
8458
+ const vertexStrideBytes$2 = 20;
8459
+ const wordsPerVertex$1 = vertexStrideBytes$2 / Uint32Array.BYTES_PER_ELEMENT;
8460
+ class WebGpuPrimitiveRenderer extends AbstractWebGpuRenderer {
8461
+ _combinedTransform = new Matrix();
8462
+ _drawCalls = [];
8463
+ _drawCallCount = 0;
8464
+ _pipelines = new Map();
8465
+ _device = null;
8466
+ _shaderModule = null;
8467
+ _pipelineLayout = null;
8468
+ _vertexBuffer = null;
8469
+ _indexBuffer = null;
8470
+ _vertexBufferCapacity = 0;
8471
+ _indexBufferCapacity = 0;
8472
+ _vertexData = new ArrayBuffer(0);
8473
+ _float32View = new Float32Array(this._vertexData);
8474
+ _uint32View = new Uint32Array(this._vertexData);
8475
+ _packedIndexData = new Uint16Array(0);
8476
+ _generatedIndexData = new Uint16Array(0);
8477
+ _sequentialIndexData = new Uint16Array(0);
8478
+ render(shape) {
8479
+ const backend = this._backend;
8480
+ if (backend === null) {
8481
+ throw new Error('Renderer not connected');
8482
+ }
8483
+ if (shape.drawMode !== RenderingPrimitives.Points
8484
+ && shape.drawMode !== RenderingPrimitives.Lines
8485
+ && shape.drawMode !== RenderingPrimitives.LineLoop
8486
+ && shape.drawMode !== RenderingPrimitives.LineStrip
8487
+ && shape.drawMode !== RenderingPrimitives.Triangles
8488
+ && shape.drawMode !== RenderingPrimitives.TriangleFan
8489
+ && shape.drawMode !== RenderingPrimitives.TriangleStrip) {
8490
+ throw new Error(`WebGPU primitive renderer does not support draw mode "${shape.drawMode}" yet.`);
8491
+ }
8492
+ backend.setBlendMode(shape.blendMode);
8493
+ if (shape.geometry.vertices.length === 0) {
8494
+ return;
8495
+ }
8496
+ const drawCallIndex = this._drawCallCount++;
8497
+ const drawCall = this._drawCalls[drawCallIndex];
8498
+ if (drawCall) {
8499
+ drawCall.shape = shape;
8500
+ drawCall.blendMode = shape.blendMode;
8501
+ }
8502
+ else {
8503
+ this._drawCalls.push({
8504
+ shape,
8505
+ blendMode: shape.blendMode,
8506
+ });
8507
+ }
8508
+ }
8509
+ flush() {
8510
+ const backend = this._backend;
8511
+ const device = this._device;
8512
+ if (!backend || !device) {
8513
+ return;
8514
+ }
8515
+ if (this._drawCallCount === 0 && !backend.clearRequested) {
8516
+ return;
8517
+ }
8518
+ const scissor = backend.getScissorRect();
8519
+ const maskClipsAll = scissor !== null && (scissor.width <= 0 || scissor.height <= 0);
8520
+ // Phase 1: resolve drawcalls and record each one's offsets into the
8521
+ // shared packed buffers. Transform gets baked into the vertex data
8522
+ // during phase 2 so no per-drawcall uniform binding is needed.
8523
+ const plan = [];
8524
+ const resolvedDrawCalls = [];
8525
+ let totalVertices = 0;
8526
+ let totalIndices = 0;
8527
+ if (this._drawCallCount > 0 && !maskClipsAll) {
8528
+ for (let drawCallIndex = 0; drawCallIndex < this._drawCallCount; drawCallIndex++) {
8529
+ const drawCall = this._drawCalls[drawCallIndex];
8530
+ const shape = drawCall.shape;
8531
+ const resolved = this._resolveDrawCall(shape);
8532
+ resolvedDrawCalls.push(resolved);
8533
+ if (resolved === null) {
8534
+ continue;
8535
+ }
8536
+ const pipeline = this._getPipeline({
8537
+ topology: resolved.topology,
8538
+ usesStripIndex: resolved.usesStripIndex,
8539
+ blendMode: drawCall.blendMode,
8540
+ format: backend.renderTargetFormat,
8541
+ });
8542
+ plan.push({
8543
+ pipeline,
8544
+ vertexByteOffset: totalVertices * vertexStrideBytes$2,
8545
+ vertexCount: resolved.vertexCount,
8546
+ indexByteOffset: totalIndices * Uint16Array.BYTES_PER_ELEMENT,
8547
+ indexCount: resolved.indexCount,
8548
+ });
8549
+ totalVertices += resolved.vertexCount;
8550
+ totalIndices += resolved.indexCount;
8551
+ }
8552
+ }
8553
+ // If nothing will actually render, still honor a pending clear with
8554
+ // a single empty pass so createColorAttachment consumes the clear
8555
+ // state exactly once.
8556
+ if (plan.length === 0) {
8557
+ if (backend.clearRequested) {
8558
+ const encoder = device.createCommandEncoder();
8559
+ const pass = encoder.beginRenderPass({
8560
+ colorAttachments: [backend.createColorAttachment()],
8561
+ });
8562
+ backend.stats.renderPasses++;
8563
+ pass.end();
8564
+ backend.submit(encoder.finish());
8565
+ }
8566
+ this._drawCallCount = 0;
8567
+ return;
8568
+ }
8569
+ // Phase 2: size GPU buffers for the whole-frame totals, then pack
8570
+ // every drawcall's CPU-side data. _writeShapeVertices applies
8571
+ // (view * shape.globalTransform) per-vertex so the shader simply
8572
+ // outputs input.position unchanged.
8573
+ this._ensureVertexCapacity(totalVertices);
8574
+ if (totalIndices > 0) {
8575
+ this._ensureIndexCapacity(totalIndices);
8576
+ if (this._packedIndexData.length < totalIndices) {
8577
+ this._packedIndexData = new Uint16Array(Math.max(totalIndices, this._packedIndexData.length === 0 ? 1 : this._packedIndexData.length * 2));
8578
+ }
8579
+ }
8580
+ {
8581
+ let vOffset = 0;
8582
+ let iOffset = 0;
8583
+ for (let i = 0; i < this._drawCallCount; i++) {
8584
+ const resolved = resolvedDrawCalls[i];
8585
+ if (resolved === null) {
8586
+ continue;
8587
+ }
8588
+ const drawCall = this._drawCalls[i];
8589
+ const shape = drawCall.shape;
8590
+ this._writeShapeVertices(backend, shape, vOffset);
8591
+ if (resolved.indices !== null && resolved.indexCount > 0) {
8592
+ this._packedIndexData.set(resolved.indices.subarray(0, resolved.indexCount), iOffset);
8593
+ iOffset += resolved.indexCount;
8594
+ }
8595
+ vOffset += resolved.vertexCount;
8596
+ }
8597
+ }
8598
+ // Phase 3: single writeBuffer per GPU buffer covers the whole frame.
8599
+ device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, totalVertices * vertexStrideBytes$2);
8600
+ if (totalIndices > 0) {
8601
+ device.queue.writeBuffer(this._indexBuffer, 0, this._packedIndexData.buffer, this._packedIndexData.byteOffset, totalIndices * Uint16Array.BYTES_PER_ELEMENT);
8602
+ }
8603
+ // Phase 4: single render pass. Per-draw state is just pipeline and
8604
+ // vertex/index subrange offsets — the transform has already been
8605
+ // baked into the vertex data.
8606
+ const encoder = device.createCommandEncoder();
8607
+ const pass = encoder.beginRenderPass({
8608
+ colorAttachments: [backend.createColorAttachment()],
8609
+ });
8610
+ backend.stats.renderPasses++;
8611
+ if (scissor !== null) {
8612
+ pass.setScissorRect(scissor.x, scissor.y, scissor.width, scissor.height);
8613
+ }
8614
+ for (const planned of plan) {
8615
+ pass.setPipeline(planned.pipeline);
8616
+ pass.setVertexBuffer(0, this._vertexBuffer, planned.vertexByteOffset);
8617
+ if (planned.indexCount > 0) {
8618
+ pass.setIndexBuffer(this._indexBuffer, 'uint16', planned.indexByteOffset);
8619
+ pass.drawIndexed(planned.indexCount);
8620
+ }
8621
+ else {
8622
+ pass.draw(planned.vertexCount);
8623
+ }
8624
+ backend.stats.batches++;
8625
+ backend.stats.drawCalls++;
8626
+ }
8627
+ pass.end();
8628
+ backend.submit(encoder.finish());
8629
+ this._drawCallCount = 0;
8630
+ }
8631
+ destroy() {
8632
+ this.disconnect();
8633
+ this._combinedTransform.destroy();
8634
+ }
8635
+ onConnect(backend) {
8636
+ this._backend = backend;
8637
+ this._device = this._backend.device;
8638
+ this._shaderModule = this._device.createShaderModule({ code: primitiveShaderSource });
8639
+ // Transform is applied per-vertex on the CPU, so no uniform binding
8640
+ // is needed — the shader outputs input.position directly.
8641
+ this._pipelineLayout = this._device.createPipelineLayout({
8642
+ bindGroupLayouts: [],
8643
+ });
8644
+ }
8645
+ onDisconnect() {
8646
+ this.flush();
8647
+ this._destroyBuffers();
8648
+ this._pipelines.clear();
8649
+ this._pipelineLayout = null;
8650
+ this._shaderModule = null;
8651
+ this._device = null;
8652
+ this._backend = null;
8653
+ this._drawCallCount = 0;
8654
+ }
8655
+ _writeShapeVertices(backend, shape, vertexStart) {
8656
+ // Matrix.combine is `other * this` (see Matrix.rotate and
8657
+ // SceneNode.getGlobalTransform, both of which chain via
8658
+ // local.combine(parent.global) to yield parent.global * local).
8659
+ //
8660
+ // We need view * global applied to a local vertex, so start with
8661
+ // global and combine with view — that gives
8662
+ // _combinedTransform = view * global.
8663
+ const matrix = this._combinedTransform
8664
+ .copy(shape.getGlobalTransform())
8665
+ .combine(backend.view.getTransform());
8666
+ // Match the original uniform-based WGSL layout exactly.
8667
+ //
8668
+ // The shader packs the Matrix's 9 fields into a 4x4 mat (column-major
8669
+ // in WGSL):
8670
+ // col 0 = [a, c, 0, 0]
8671
+ // col 1 = [b, d, 0, 0]
8672
+ // col 2 = [0, 0, 1, 0]
8673
+ // col 3 = [x, y, 0, z]
8674
+ //
8675
+ // Multiplied by vec4(px, py, 0, 1):
8676
+ // out = col0*px + col1*py + col2*0 + col3*1
8677
+ // out.x = a*px + b*py + x
8678
+ // out.y = c*px + d*py + y
8679
+ // out.z = 0
8680
+ // out.w = z
8681
+ //
8682
+ // The Matrix class represents the affine matrix in the order
8683
+ // [a b x]
8684
+ // [c d y]
8685
+ // [e f z]
8686
+ // so a/b/c/d are rotation+scale (note: b on the TOP row, c on the
8687
+ // LEFT column, not the other way around) and x/y/z the translation /
8688
+ // w component. Matrix.toArray(false) confirms this layout.
8689
+ const a = matrix.a;
8690
+ const b = matrix.b;
8691
+ const c = matrix.c;
8692
+ const d = matrix.d;
8693
+ const tx = matrix.x;
8694
+ const ty = matrix.y;
8695
+ const tw = matrix.z;
8696
+ const color = shape.color.toRgba();
8697
+ const vertices = shape.geometry.vertices;
8698
+ const vertexCount = vertices.length / 2;
8699
+ for (let i = 0; i < vertexCount; i++) {
8700
+ const sourceIndex = i * 2;
8701
+ const targetIndex = (vertexStart + i) * wordsPerVertex$1;
8702
+ const px = vertices[sourceIndex];
8703
+ const py = vertices[sourceIndex + 1];
8704
+ this._float32View[targetIndex + 0] = a * px + b * py + tx;
8705
+ this._float32View[targetIndex + 1] = c * px + d * py + ty;
8706
+ this._float32View[targetIndex + 2] = 0;
8707
+ this._float32View[targetIndex + 3] = tw;
8708
+ this._uint32View[targetIndex + 4] = color;
8709
+ }
8710
+ }
8711
+ _ensureVertexCapacity(vertexCount) {
8712
+ const requiredBytes = vertexCount * vertexStrideBytes$2;
8713
+ if (requiredBytes > this._vertexData.byteLength) {
8714
+ const byteLength = Math.max(requiredBytes, this._vertexData.byteLength === 0 ? vertexStrideBytes$2 : this._vertexData.byteLength * 2);
8715
+ this._vertexData = new ArrayBuffer(byteLength);
8716
+ this._float32View = new Float32Array(this._vertexData);
8717
+ this._uint32View = new Uint32Array(this._vertexData);
8718
+ }
8719
+ if (requiredBytes > this._vertexBufferCapacity) {
8720
+ this._vertexBuffer?.destroy();
8721
+ this._vertexBufferCapacity = Math.max(requiredBytes, this._vertexBufferCapacity === 0 ? vertexStrideBytes$2 : this._vertexBufferCapacity * 2);
8722
+ this._vertexBuffer = this._device.createBuffer({
8723
+ size: this._vertexBufferCapacity,
8724
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
8725
+ });
8726
+ }
8727
+ }
8728
+ _ensureIndexCapacity(indexCount) {
8729
+ const requiredBytes = indexCount * Uint16Array.BYTES_PER_ELEMENT;
8730
+ if (requiredBytes > this._indexBufferCapacity) {
8731
+ this._indexBuffer?.destroy();
8732
+ this._indexBufferCapacity = Math.max(requiredBytes, this._indexBufferCapacity === 0 ? Uint16Array.BYTES_PER_ELEMENT : this._indexBufferCapacity * 2);
8733
+ this._indexBuffer = this._device.createBuffer({
8734
+ size: this._indexBufferCapacity,
8735
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
8736
+ });
8737
+ }
8738
+ }
8739
+ _getPipeline(key) {
8740
+ const pipelineKey = `${key.topology}:${key.usesStripIndex ? 1 : 0}:${key.blendMode}:${key.format}`;
8741
+ const existingPipeline = this._pipelines.get(pipelineKey);
8742
+ if (existingPipeline) {
8743
+ return existingPipeline;
8744
+ }
8745
+ const pipeline = this._device.createRenderPipeline({
8746
+ layout: this._pipelineLayout,
8747
+ vertex: {
8748
+ module: this._shaderModule,
8749
+ entryPoint: 'vertexMain',
8750
+ buffers: [{
8751
+ arrayStride: vertexStrideBytes$2,
8752
+ attributes: [{
8753
+ shaderLocation: 0,
8754
+ offset: 0,
8755
+ format: 'float32x4',
8756
+ }, {
8757
+ shaderLocation: 1,
8758
+ offset: 16,
8759
+ format: 'unorm8x4',
8760
+ }],
8761
+ }],
8762
+ },
8763
+ fragment: {
8764
+ module: this._shaderModule,
8765
+ entryPoint: 'fragmentMain',
8766
+ targets: [{
8767
+ format: key.format,
8768
+ blend: getWebGpuBlendState(key.blendMode),
8769
+ writeMask: GPUColorWrite.ALL,
8770
+ }],
8771
+ },
8772
+ primitive: {
8773
+ topology: key.topology,
8774
+ stripIndexFormat: key.usesStripIndex ? 'uint16' : undefined,
8775
+ },
8776
+ });
8777
+ this._pipelines.set(pipelineKey, pipeline);
8778
+ return pipeline;
8779
+ }
8780
+ _getTopology(drawMode) {
8781
+ switch (drawMode) {
8782
+ case RenderingPrimitives.Points:
8783
+ return 'point-list';
8784
+ case RenderingPrimitives.Lines:
8785
+ return 'line-list';
8786
+ case RenderingPrimitives.LineLoop:
8787
+ case RenderingPrimitives.LineStrip:
8788
+ return 'line-strip';
8789
+ case RenderingPrimitives.Triangles:
8790
+ case RenderingPrimitives.TriangleFan:
8791
+ return 'triangle-list';
8792
+ case RenderingPrimitives.TriangleStrip:
8793
+ return 'triangle-strip';
8794
+ default:
8795
+ throw new Error(`WebGPU primitive renderer does not support draw mode "${drawMode}" yet.`);
8796
+ }
8797
+ }
8798
+ _resolveDrawCall(shape) {
8799
+ const vertices = shape.geometry.vertices;
8800
+ const vertexCount = vertices.length / 2;
8801
+ if (vertexCount === 0) {
8802
+ return null;
8803
+ }
8804
+ switch (shape.drawMode) {
8805
+ case RenderingPrimitives.LineLoop:
8806
+ return this._resolveLineLoopDrawCall(shape.geometry.indices, vertexCount);
8807
+ case RenderingPrimitives.TriangleFan:
8808
+ return this._resolveTriangleFanDrawCall(shape.geometry.indices, vertexCount);
8809
+ default: {
8810
+ const indices = shape.geometry.indices;
8811
+ const topology = this._getTopology(shape.drawMode);
8812
+ const indexCount = indices.length;
8813
+ const usesStripIndex = indexCount > 0 && (shape.drawMode === RenderingPrimitives.LineStrip
8814
+ || shape.drawMode === RenderingPrimitives.TriangleStrip);
8815
+ if (indexCount > 0) {
8816
+ return {
8817
+ topology,
8818
+ usesStripIndex,
8819
+ vertexCount,
8820
+ indices,
8821
+ indexCount,
8822
+ };
8823
+ }
8824
+ return {
8825
+ topology,
8826
+ usesStripIndex,
8827
+ vertexCount,
8828
+ indices: null,
8829
+ indexCount: 0,
8830
+ };
8831
+ }
8832
+ }
8833
+ }
8834
+ _resolveLineLoopDrawCall(indices, vertexCount) {
8835
+ const sourceIndices = indices.length > 0 ? indices : this._getSequentialIndices(vertexCount);
8836
+ const sourceCount = sourceIndices.length;
8837
+ if (sourceCount < 2) {
8838
+ return null;
8839
+ }
8840
+ const loopIndexCount = sourceCount + 1;
8841
+ const generatedIndices = this._ensureGeneratedIndexCapacity(loopIndexCount);
8842
+ generatedIndices.set(sourceIndices.subarray(0, sourceCount), 0);
8843
+ generatedIndices[sourceCount] = sourceIndices[0];
8844
+ return {
8845
+ topology: 'line-strip',
8846
+ usesStripIndex: true,
8847
+ vertexCount,
8848
+ indices: generatedIndices,
8849
+ indexCount: loopIndexCount,
8850
+ };
8851
+ }
8852
+ _resolveTriangleFanDrawCall(indices, vertexCount) {
8853
+ const sourceIndices = indices.length > 0 ? indices : this._getSequentialIndices(vertexCount);
8854
+ const sourceCount = sourceIndices.length;
8855
+ if (sourceCount < 3) {
8856
+ return null;
8857
+ }
8858
+ const indexCount = (sourceCount - 2) * 3;
8859
+ const generatedIndices = this._ensureGeneratedIndexCapacity(indexCount);
8860
+ let targetIndex = 0;
8861
+ for (let index = 1; index < sourceCount - 1; index++) {
8862
+ generatedIndices[targetIndex++] = sourceIndices[0];
8863
+ generatedIndices[targetIndex++] = sourceIndices[index];
8864
+ generatedIndices[targetIndex++] = sourceIndices[index + 1];
8865
+ }
8866
+ return {
8867
+ topology: 'triangle-list',
8868
+ usesStripIndex: false,
8869
+ vertexCount,
8870
+ indices: generatedIndices,
8871
+ indexCount,
8872
+ };
8873
+ }
8874
+ _getSequentialIndices(vertexCount) {
8875
+ if (vertexCount > this._sequentialIndexData.length) {
8876
+ let nextLength = Math.max(1, this._sequentialIndexData.length);
8877
+ while (nextLength < vertexCount) {
8878
+ nextLength *= 2;
8879
+ }
8880
+ this._sequentialIndexData = new Uint16Array(nextLength);
8881
+ }
8882
+ for (let index = 0; index < vertexCount; index++) {
8883
+ this._sequentialIndexData[index] = index;
8884
+ }
8885
+ return this._sequentialIndexData.subarray(0, vertexCount);
8886
+ }
8887
+ _ensureGeneratedIndexCapacity(indexCount) {
8888
+ if (indexCount > this._generatedIndexData.length) {
8889
+ let nextLength = Math.max(1, this._generatedIndexData.length);
8890
+ while (nextLength < indexCount) {
8891
+ nextLength *= 2;
8892
+ }
8893
+ this._generatedIndexData = new Uint16Array(nextLength);
8894
+ }
8895
+ return this._generatedIndexData.subarray(0, indexCount);
8896
+ }
8897
+ _destroyBuffers() {
8898
+ this._vertexBuffer?.destroy();
8899
+ this._indexBuffer?.destroy();
8900
+ this._vertexBuffer = null;
8901
+ this._indexBuffer = null;
8902
+ this._vertexBufferCapacity = 0;
8903
+ this._indexBufferCapacity = 0;
8904
+ }
8905
+ }
8906
+
8907
+ /// <reference types="@webgpu/types" />
8908
+ const spriteShaderSource = `
8909
+ struct ProjectionUniforms {
8910
+ matrix: mat4x4<f32>,
8911
+ };
8912
+
8913
+ @group(0) @binding(0)
8914
+ var<uniform> projection: ProjectionUniforms;
8915
+
8916
+ @group(1) @binding(0)
8917
+ var spriteTexture0: texture_2d<f32>;
8918
+ @group(1) @binding(1)
8919
+ var spriteTexture1: texture_2d<f32>;
8920
+ @group(1) @binding(2)
8921
+ var spriteTexture2: texture_2d<f32>;
8922
+ @group(1) @binding(3)
8923
+ var spriteTexture3: texture_2d<f32>;
8924
+ @group(1) @binding(4)
8925
+ var spriteTexture4: texture_2d<f32>;
8926
+ @group(1) @binding(5)
8927
+ var spriteTexture5: texture_2d<f32>;
8928
+ @group(1) @binding(6)
8929
+ var spriteTexture6: texture_2d<f32>;
8930
+ @group(1) @binding(7)
8931
+ var spriteTexture7: texture_2d<f32>;
8932
+
8933
+ @group(1) @binding(8)
8934
+ var spriteSampler0: sampler;
8935
+ @group(1) @binding(9)
8936
+ var spriteSampler1: sampler;
8937
+ @group(1) @binding(10)
8938
+ var spriteSampler2: sampler;
8939
+ @group(1) @binding(11)
8940
+ var spriteSampler3: sampler;
8941
+ @group(1) @binding(12)
8942
+ var spriteSampler4: sampler;
8943
+ @group(1) @binding(13)
8944
+ var spriteSampler5: sampler;
8945
+ @group(1) @binding(14)
8946
+ var spriteSampler6: sampler;
8947
+ @group(1) @binding(15)
8948
+ var spriteSampler7: sampler;
8949
+
8950
+ // Per-instance vertex layout (56 bytes per sprite). The four corners
8951
+ // of the quad are derived from @builtin(vertex_index) 0..3 inside the
8952
+ // vertex shader — there is no per-vertex stream.
8953
+ struct VertexInput {
8954
+ @location(0) localBounds: vec4<f32>, // left, top, right, bottom (local space)
8955
+ @location(1) transformAB: vec3<f32>, // first row of 2D affine
8956
+ @location(2) transformCD: vec3<f32>, // second row of 2D affine
8957
+ @location(3) uvBounds: vec4<f32>, // uMin, vMin, uMax, vMax (CPU pre-swaps for flipY)
8958
+ @location(4) color: vec4<f32>, // RGBA tint
8959
+ @location(5) packedSlotFlags: u32, // bits 0..7 = slot, bit 8 = premultiply
8960
+ };
8961
+
8962
+ struct VertexOutput {
8963
+ @builtin(position) position: vec4<f32>,
8964
+ @location(0) texcoord: vec2<f32>,
8965
+ @location(1) color: vec4<f32>,
8966
+ @location(2) @interpolate(flat) premultiplySample: u32,
8967
+ @location(3) @interpolate(flat) textureSlot: u32,
8968
+ };
8969
+
8970
+ @vertex
8971
+ fn vertexMain(input: VertexInput, @builtin(vertex_index) vid: u32) -> VertexOutput {
8972
+ var output: VertexOutput;
8973
+
8974
+ // vid 0..3 → corners in TL, TR, BR, BL order (matches the static index
8975
+ // buffer [0,1,2,0,2,3] used for indexed triangle-list drawing).
8976
+ let cornerX = ((vid + 1u) >> 1u) & 1u;
8977
+ let cornerY = vid >> 1u;
8978
+
8979
+ let localX = select(input.localBounds.x, input.localBounds.z, cornerX == 1u);
8980
+ let localY = select(input.localBounds.y, input.localBounds.w, cornerY == 1u);
8981
+
8982
+ let worldX = input.transformAB.x * localX + input.transformAB.y * localY + input.transformAB.z;
8983
+ let worldY = input.transformCD.x * localX + input.transformCD.y * localY + input.transformCD.z;
8984
+
8985
+ output.position = projection.matrix * vec4<f32>(worldX, worldY, 0.0, 1.0);
8986
+
8987
+ let u = select(input.uvBounds.x, input.uvBounds.z, cornerX == 1u);
8988
+ let v = select(input.uvBounds.y, input.uvBounds.w, cornerY == 1u);
8989
+ output.texcoord = vec2<f32>(u, v);
8990
+
8991
+ output.color = vec4(input.color.rgb * input.color.a, input.color.a);
8992
+ output.textureSlot = input.packedSlotFlags & 0xFFu;
8993
+ output.premultiplySample = (input.packedSlotFlags >> 8u) & 1u;
8994
+
8995
+ return output;
8996
+ }
8997
+
8998
+ fn sampleTexture(slot: u32, uv: vec2<f32>, ddx: vec2<f32>, ddy: vec2<f32>) -> vec4<f32> {
8999
+ switch slot {
9000
+ case 0u: {
9001
+ return textureSampleGrad(spriteTexture0, spriteSampler0, uv, ddx, ddy);
9002
+ }
9003
+ case 1u: {
9004
+ return textureSampleGrad(spriteTexture1, spriteSampler1, uv, ddx, ddy);
9005
+ }
9006
+ case 2u: {
9007
+ return textureSampleGrad(spriteTexture2, spriteSampler2, uv, ddx, ddy);
9008
+ }
9009
+ case 3u: {
9010
+ return textureSampleGrad(spriteTexture3, spriteSampler3, uv, ddx, ddy);
9011
+ }
9012
+ case 4u: {
9013
+ return textureSampleGrad(spriteTexture4, spriteSampler4, uv, ddx, ddy);
9014
+ }
9015
+ case 5u: {
9016
+ return textureSampleGrad(spriteTexture5, spriteSampler5, uv, ddx, ddy);
9017
+ }
9018
+ case 6u: {
9019
+ return textureSampleGrad(spriteTexture6, spriteSampler6, uv, ddx, ddy);
9020
+ }
9021
+ default: {
9022
+ return textureSampleGrad(spriteTexture7, spriteSampler7, uv, ddx, ddy);
9023
+ }
9024
+ }
9025
+ }
9026
+
9027
+ @fragment
9028
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
9029
+ // Compute screen-space derivatives in uniform control flow before the
9030
+ // per-slot switch. WGSL requires textureSample (implicit LOD) to run in
9031
+ // uniform control flow, which multi-texture batching breaks because the
9032
+ // slot varies per fragment. textureSampleGrad takes explicit derivatives
9033
+ // and is valid regardless of control-flow uniformity, while preserving
9034
+ // mipmap-correct LOD when sprites use mipmapped textures.
9035
+ let ddx = dpdx(input.texcoord);
9036
+ let ddy = dpdy(input.texcoord);
9037
+ let sample = sampleTexture(input.textureSlot, input.texcoord, ddx, ddy);
9038
+ let resolvedSample = select(sample, vec4(sample.rgb * sample.a, sample.a), input.premultiplySample == 1u);
9039
+
9040
+ return resolvedSample * input.color;
9041
+ }
9042
+ `;
9043
+ const instanceStrideBytes$1 = 56;
9044
+ const wordsPerInstance = instanceStrideBytes$1 / Uint32Array.BYTES_PER_ELEMENT;
9045
+ const projectionByteLength = 64;
9046
+ const initialBatchCapacity = 32;
9047
+ const maxBatchTextures = 8;
9048
+ const indicesPerSprite = 6;
9049
+ // Static index buffer: two triangles forming a quad, vertex IDs 0..3 in
9050
+ // TL/TR/BR/BL order so the WGSL `cornerX/cornerY` derivation matches.
9051
+ const quadIndices = new Uint16Array([0, 1, 2, 0, 2, 3]);
9052
+ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
9053
+ _projectionData = new Float32Array(projectionByteLength / Float32Array.BYTES_PER_ELEMENT);
9054
+ _device = null;
9055
+ _shaderModule = null;
9056
+ _uniformBindGroupLayout = null;
9057
+ _textureBindGroupLayout = null;
9058
+ _pipelineLayout = null;
9059
+ _uniformBuffer = null;
9060
+ _uniformBindGroup = null;
9061
+ _indexBuffer = null;
9062
+ _instanceBuffer = null;
9063
+ _instanceCapacity = 0;
9064
+ _instanceData = new ArrayBuffer(0);
9065
+ _instanceFloat32 = new Float32Array(this._instanceData);
9066
+ _instanceUint32 = new Uint32Array(this._instanceData);
9067
+ _pipelines = new Map();
9068
+ _activeTextures = new Array(maxBatchTextures).fill(null);
9069
+ _textureSlots = new Map();
9070
+ _slotCount = 0;
9071
+ _instanceCount = 0;
9072
+ _currentBlendMode = null;
9073
+ onConnect(backend) {
9074
+ if (this._device) {
9075
+ return;
9076
+ }
9077
+ this._device = backend.device;
9078
+ this._shaderModule = this._device.createShaderModule({ code: spriteShaderSource });
9079
+ this._uniformBindGroupLayout = this._device.createBindGroupLayout({
9080
+ entries: [{
9081
+ binding: 0,
9082
+ visibility: GPUShaderStage.VERTEX,
9083
+ buffer: {
9084
+ type: 'uniform',
9085
+ },
9086
+ }],
9087
+ });
9088
+ this._textureBindGroupLayout = this._device.createBindGroupLayout({
9089
+ entries: [
9090
+ ...Array.from({ length: maxBatchTextures }, (_, index) => ({
9091
+ binding: index,
9092
+ visibility: GPUShaderStage.FRAGMENT,
9093
+ texture: {
9094
+ sampleType: 'float',
9095
+ },
9096
+ })),
9097
+ ...Array.from({ length: maxBatchTextures }, (_, index) => ({
9098
+ binding: maxBatchTextures + index,
9099
+ visibility: GPUShaderStage.FRAGMENT,
9100
+ sampler: {
9101
+ type: 'filtering',
9102
+ },
9103
+ })),
9104
+ ],
9105
+ });
9106
+ this._pipelineLayout = this._device.createPipelineLayout({
9107
+ bindGroupLayouts: [this._uniformBindGroupLayout, this._textureBindGroupLayout],
9108
+ });
9109
+ this._uniformBuffer = this._device.createBuffer({
9110
+ size: projectionByteLength,
9111
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
9112
+ });
9113
+ this._uniformBindGroup = this._device.createBindGroup({
9114
+ layout: this._uniformBindGroupLayout,
9115
+ entries: [{
9116
+ binding: 0,
9117
+ resource: {
9118
+ buffer: this._uniformBuffer,
9119
+ },
9120
+ }],
9121
+ });
9122
+ // Static index buffer for the quad. Allocated once at connect; its
9123
+ // contents never change.
9124
+ this._indexBuffer = this._device.createBuffer({
9125
+ size: quadIndices.byteLength,
9126
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
9127
+ });
9128
+ this._device.queue.writeBuffer(this._indexBuffer, 0, quadIndices.buffer, quadIndices.byteOffset, quadIndices.byteLength);
9129
+ }
9130
+ onDisconnect() {
9131
+ this._instanceBuffer?.destroy();
9132
+ this._indexBuffer?.destroy();
9133
+ this._uniformBuffer?.destroy();
9134
+ this._pipelines.clear();
9135
+ this._instanceBuffer = null;
9136
+ this._indexBuffer = null;
9137
+ this._uniformBindGroup = null;
9138
+ this._uniformBuffer = null;
9139
+ this._pipelineLayout = null;
9140
+ this._textureBindGroupLayout = null;
9141
+ this._uniformBindGroupLayout = null;
9142
+ this._shaderModule = null;
9143
+ this._device = null;
9144
+ this._backend = null;
9145
+ this._instanceCapacity = 0;
9146
+ this._instanceData = new ArrayBuffer(0);
9147
+ this._instanceFloat32 = new Float32Array(this._instanceData);
9148
+ this._instanceUint32 = new Uint32Array(this._instanceData);
9149
+ this._instanceCount = 0;
9150
+ this._currentBlendMode = null;
9151
+ this._resetSlots();
9152
+ }
9153
+ render(sprite) {
8134
9154
  const backend = this._backend;
8135
- if (backend === null) {
8136
- throw new Error('Renderer not connected');
8137
- }
8138
- if (shape.drawMode !== RenderingPrimitives.Points
8139
- && shape.drawMode !== RenderingPrimitives.Lines
8140
- && shape.drawMode !== RenderingPrimitives.LineLoop
8141
- && shape.drawMode !== RenderingPrimitives.LineStrip
8142
- && shape.drawMode !== RenderingPrimitives.Triangles
8143
- && shape.drawMode !== RenderingPrimitives.TriangleFan
8144
- && shape.drawMode !== RenderingPrimitives.TriangleStrip) {
8145
- throw new Error(`WebGPU primitive renderer does not support draw mode "${shape.drawMode}" yet.`);
8146
- }
8147
- backend.setBlendMode(shape.blendMode);
8148
- if (shape.geometry.vertices.length === 0) {
9155
+ const texture = sprite.texture;
9156
+ // Same early-out conditions as the deferred renderer used to apply.
9157
+ if (backend === null
9158
+ || (!(texture instanceof Texture) && !(texture instanceof RenderTexture))
9159
+ || texture.width === 0
9160
+ || texture.height === 0
9161
+ || (texture instanceof Texture && texture.source === null)) {
8149
9162
  return;
8150
9163
  }
8151
- const drawCallIndex = this._drawCallCount++;
8152
- const drawCall = this._drawCalls[drawCallIndex];
8153
- if (drawCall) {
8154
- drawCall.shape = shape;
8155
- drawCall.blendMode = shape.blendMode;
9164
+ const blendMode = sprite.blendMode;
9165
+ // Flush triggers: blend-mode change, instance buffer full at current
9166
+ // capacity (we'll grow on next render), or texture-slot exhaustion.
9167
+ const blendModeChanged = this._currentBlendMode !== null && blendMode !== this._currentBlendMode;
9168
+ const slotExhausted = !this._textureSlots.has(texture) && this._slotCount >= maxBatchTextures;
9169
+ if (blendModeChanged || slotExhausted) {
9170
+ this.flush();
8156
9171
  }
8157
- else {
8158
- this._drawCalls.push({
8159
- shape,
8160
- blendMode: shape.blendMode,
8161
- });
9172
+ this._currentBlendMode = blendMode;
9173
+ backend.setBlendMode(blendMode);
9174
+ // Resolve / assign texture slot.
9175
+ let slot = this._textureSlots.get(texture);
9176
+ if (slot === undefined) {
9177
+ slot = this._slotCount++;
9178
+ this._textureSlots.set(texture, slot);
9179
+ this._activeTextures[slot] = texture;
8162
9180
  }
9181
+ const premultiplySample = backend.shouldPremultiplyTextureSample(texture) ? 1 : 0;
9182
+ const packedSlotFlags = slot | (premultiplySample << 8);
9183
+ // Ensure capacity covers the new entry BEFORE packing — otherwise the
9184
+ // typed-array writes in _packInstance silently fall off the end of a
9185
+ // too-small buffer.
9186
+ this._ensureInstanceCapacity(this._instanceCount + 1);
9187
+ this._packInstance(sprite, texture, packedSlotFlags);
9188
+ this._instanceCount++;
8163
9189
  }
8164
9190
  flush() {
8165
9191
  const backend = this._backend;
8166
9192
  const device = this._device;
8167
- if (!backend || !device) {
8168
- return;
8169
- }
8170
- if (this._drawCallCount === 0 && !backend.clearRequested) {
9193
+ const uniformBuffer = this._uniformBuffer;
9194
+ const uniformBindGroup = this._uniformBindGroup;
9195
+ if (!backend || !device || !uniformBuffer || !uniformBindGroup) {
8171
9196
  return;
8172
9197
  }
8173
- const scissor = backend.getScissorRect();
8174
- const maskClipsAll = scissor !== null && (scissor.width <= 0 || scissor.height <= 0);
8175
- // Phase 1: resolve drawcalls and record each one's offsets into the
8176
- // shared packed buffers. Transform gets baked into the vertex data
8177
- // during phase 2 so no per-drawcall uniform binding is needed.
8178
- const plan = [];
8179
- const resolvedDrawCalls = [];
8180
- let totalVertices = 0;
8181
- let totalIndices = 0;
8182
- if (this._drawCallCount > 0 && !maskClipsAll) {
8183
- for (let drawCallIndex = 0; drawCallIndex < this._drawCallCount; drawCallIndex++) {
8184
- const drawCall = this._drawCalls[drawCallIndex];
8185
- const shape = drawCall.shape;
8186
- const resolved = this._resolveDrawCall(shape);
8187
- resolvedDrawCalls.push(resolved);
8188
- if (resolved === null) {
8189
- continue;
8190
- }
8191
- const pipeline = this._getPipeline({
8192
- topology: resolved.topology,
8193
- usesStripIndex: resolved.usesStripIndex,
8194
- blendMode: drawCall.blendMode,
8195
- format: backend.renderTargetFormat,
8196
- });
8197
- plan.push({
8198
- pipeline,
8199
- vertexByteOffset: totalVertices * vertexStrideBytes$1,
8200
- vertexCount: resolved.vertexCount,
8201
- indexByteOffset: totalIndices * Uint16Array.BYTES_PER_ELEMENT,
8202
- indexCount: resolved.indexCount,
8203
- });
8204
- totalVertices += resolved.vertexCount;
8205
- totalIndices += resolved.indexCount;
8206
- }
8207
- }
8208
- // If nothing will actually render, still honor a pending clear with
8209
- // a single empty pass so createColorAttachment consumes the clear
8210
- // state exactly once.
8211
- if (plan.length === 0) {
8212
- if (backend.clearRequested) {
8213
- const encoder = device.createCommandEncoder();
8214
- const pass = encoder.beginRenderPass({
8215
- colorAttachments: [backend.createColorAttachment()],
8216
- });
8217
- backend.stats.renderPasses++;
8218
- pass.end();
8219
- backend.submit(encoder.finish());
8220
- }
8221
- this._drawCallCount = 0;
9198
+ if (this._instanceCount === 0 && !backend.clearRequested) {
8222
9199
  return;
8223
9200
  }
8224
- // Phase 2: size GPU buffers for the whole-frame totals, then pack
8225
- // every drawcall's CPU-side data. _writeShapeVertices applies
8226
- // (view * shape.globalTransform) per-vertex so the shader simply
8227
- // outputs input.position unchanged.
8228
- this._ensureVertexCapacity(totalVertices);
8229
- if (totalIndices > 0) {
8230
- this._ensureIndexCapacity(totalIndices);
8231
- if (this._packedIndexData.length < totalIndices) {
8232
- this._packedIndexData = new Uint16Array(Math.max(totalIndices, this._packedIndexData.length === 0 ? 1 : this._packedIndexData.length * 2));
8233
- }
8234
- }
8235
- {
8236
- let vOffset = 0;
8237
- let iOffset = 0;
8238
- for (let i = 0; i < this._drawCallCount; i++) {
8239
- const resolved = resolvedDrawCalls[i];
8240
- if (resolved === null) {
8241
- continue;
8242
- }
8243
- const drawCall = this._drawCalls[i];
8244
- const shape = drawCall.shape;
8245
- this._writeShapeVertices(backend, shape, vOffset);
8246
- if (resolved.indices !== null && resolved.indexCount > 0) {
8247
- this._packedIndexData.set(resolved.indices.subarray(0, resolved.indexCount), iOffset);
8248
- iOffset += resolved.indexCount;
8249
- }
8250
- vOffset += resolved.vertexCount;
8251
- }
8252
- }
8253
- // Phase 3: single writeBuffer per GPU buffer covers the whole frame.
8254
- device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, totalVertices * vertexStrideBytes$1);
8255
- if (totalIndices > 0) {
8256
- device.queue.writeBuffer(this._indexBuffer, 0, this._packedIndexData.buffer, this._packedIndexData.byteOffset, totalIndices * Uint16Array.BYTES_PER_ELEMENT);
8257
- }
8258
- // Phase 4: single render pass. Per-draw state is just pipeline and
8259
- // vertex/index subrange offsets — the transform has already been
8260
- // baked into the vertex data.
9201
+ const viewMatrix = backend.view.getTransform();
9202
+ this._projectionData.set([
9203
+ viewMatrix.a, viewMatrix.c, 0, 0,
9204
+ viewMatrix.b, viewMatrix.d, 0, 0,
9205
+ 0, 0, 1, 0,
9206
+ viewMatrix.x, viewMatrix.y, 0, viewMatrix.z,
9207
+ ]);
9208
+ device.queue.writeBuffer(uniformBuffer, 0, this._projectionData.buffer, this._projectionData.byteOffset, this._projectionData.byteLength);
8261
9209
  const encoder = device.createCommandEncoder();
8262
9210
  const pass = encoder.beginRenderPass({
8263
9211
  colorAttachments: [backend.createColorAttachment()],
8264
9212
  });
8265
9213
  backend.stats.renderPasses++;
8266
- if (scissor !== null) {
9214
+ const scissor = backend.getScissorRect();
9215
+ const maskClipsAll = scissor !== null && (scissor.width <= 0 || scissor.height <= 0);
9216
+ if (scissor !== null && !maskClipsAll) {
8267
9217
  pass.setScissorRect(scissor.x, scissor.y, scissor.width, scissor.height);
8268
9218
  }
8269
- for (const planned of plan) {
8270
- pass.setPipeline(planned.pipeline);
8271
- pass.setVertexBuffer(0, this._vertexBuffer, planned.vertexByteOffset);
8272
- if (planned.indexCount > 0) {
8273
- pass.setIndexBuffer(this._indexBuffer, 'uint16', planned.indexByteOffset);
8274
- pass.drawIndexed(planned.indexCount);
8275
- }
8276
- else {
8277
- pass.draw(planned.vertexCount);
9219
+ if (this._instanceCount > 0 && !maskClipsAll && this._instanceBuffer !== null && this._indexBuffer !== null && this._currentBlendMode !== null) {
9220
+ device.queue.writeBuffer(this._instanceBuffer, 0, this._instanceData, 0, this._instanceCount * instanceStrideBytes$1);
9221
+ const pipeline = this._getPipeline(this._currentBlendMode, backend.renderTargetFormat);
9222
+ const textureBindGroup = this._createTextureBindGroup(device, backend);
9223
+ pass.setPipeline(pipeline);
9224
+ pass.setBindGroup(0, uniformBindGroup);
9225
+ pass.setBindGroup(1, textureBindGroup);
9226
+ pass.setVertexBuffer(0, this._instanceBuffer);
9227
+ pass.setIndexBuffer(this._indexBuffer, 'uint16');
9228
+ pass.drawIndexed(indicesPerSprite, this._instanceCount, 0, 0, 0);
9229
+ backend.stats.batches++;
9230
+ backend.stats.drawCalls++;
9231
+ }
9232
+ pass.end();
9233
+ backend.submit(encoder.finish());
9234
+ this._instanceCount = 0;
9235
+ this._resetSlots();
9236
+ this._currentBlendMode = null;
9237
+ }
9238
+ destroy() {
9239
+ this.disconnect();
9240
+ }
9241
+ /**
9242
+ * Pre-create render pipelines for every blend-mode × target-format
9243
+ * combination this renderer can produce, asynchronously and in
9244
+ * parallel. Called from the render manager's init path so by the time
9245
+ * the first frame draws, all pipelines exist in cache.
9246
+ *
9247
+ * Without prewarm, the first draw of any new (blendMode, format)
9248
+ * combination would fall back to the synchronous _getPipeline() path,
9249
+ * which blocks while the WebGPU implementation compiles WGSL and
9250
+ * sets up the pipeline state object — typically tens of milliseconds.
9251
+ */
9252
+ async prewarmPipelines(formats) {
9253
+ const device = this._device;
9254
+ if (!device || !this._shaderModule || !this._pipelineLayout) {
9255
+ return;
9256
+ }
9257
+ if (typeof device.createRenderPipelineAsync !== 'function') {
9258
+ return;
9259
+ }
9260
+ const blendModes = [
9261
+ BlendModes.Normal,
9262
+ BlendModes.Additive,
9263
+ BlendModes.Subtract,
9264
+ BlendModes.Multiply,
9265
+ BlendModes.Screen,
9266
+ ];
9267
+ const promises = [];
9268
+ for (const blendMode of blendModes) {
9269
+ for (const format of formats) {
9270
+ const pipelineKey = `${blendMode}:${format}`;
9271
+ if (this._pipelines.has(pipelineKey)) {
9272
+ continue;
9273
+ }
9274
+ const promise = device
9275
+ .createRenderPipelineAsync(this._buildPipelineDescriptor(blendMode, format))
9276
+ .then((pipeline) => {
9277
+ this._pipelines.set(pipelineKey, pipeline);
9278
+ });
9279
+ promises.push(promise);
8278
9280
  }
8279
- backend.stats.batches++;
8280
- backend.stats.drawCalls++;
8281
9281
  }
8282
- pass.end();
8283
- backend.submit(encoder.finish());
8284
- this._drawCallCount = 0;
9282
+ await Promise.all(promises);
8285
9283
  }
8286
- destroy() {
8287
- this.disconnect();
8288
- this._combinedTransform.destroy();
9284
+ _packInstance(sprite, texture, packedSlotFlags) {
9285
+ const offset = this._instanceCount * wordsPerInstance;
9286
+ const f32 = this._instanceFloat32;
9287
+ const u32 = this._instanceUint32;
9288
+ const bounds = sprite.getLocalBounds();
9289
+ f32[offset + 0] = bounds.left;
9290
+ f32[offset + 1] = bounds.top;
9291
+ f32[offset + 2] = bounds.right;
9292
+ f32[offset + 3] = bounds.bottom;
9293
+ const transform = sprite.getGlobalTransform();
9294
+ f32[offset + 4] = transform.a;
9295
+ f32[offset + 5] = transform.b;
9296
+ f32[offset + 6] = transform.x;
9297
+ f32[offset + 7] = transform.c;
9298
+ f32[offset + 8] = transform.d;
9299
+ f32[offset + 9] = transform.y;
9300
+ // uvBounds: u16x4 normalised, packed into two u32 slots. The CPU
9301
+ // applies the flipY swap so the shader stays orientation-agnostic.
9302
+ const frame = sprite.textureFrame;
9303
+ const texWidth = texture.width;
9304
+ const texHeight = texture.height;
9305
+ const uMin = ((frame.left / texWidth) * 0xFFFF) & 0xFFFF;
9306
+ const uMax = ((frame.right / texWidth) * 0xFFFF) & 0xFFFF;
9307
+ const vMinRaw = ((frame.top / texHeight) * 0xFFFF) & 0xFFFF;
9308
+ const vMaxRaw = ((frame.bottom / texHeight) * 0xFFFF) & 0xFFFF;
9309
+ const flipY = texture instanceof Texture && texture.flipY;
9310
+ const vMin = flipY ? vMaxRaw : vMinRaw;
9311
+ const vMax = flipY ? vMinRaw : vMaxRaw;
9312
+ u32[offset + 10] = uMin | (vMin << 16);
9313
+ u32[offset + 11] = uMax | (vMax << 16);
9314
+ u32[offset + 12] = sprite.tint.toRgba();
9315
+ u32[offset + 13] = packedSlotFlags;
8289
9316
  }
8290
- onConnect(backend) {
8291
- this._backend = backend;
8292
- this._device = this._backend.device;
8293
- this._shaderModule = this._device.createShaderModule({ code: primitiveShaderSource });
8294
- // Transform is applied per-vertex on the CPU, so no uniform binding
8295
- // is needed the shader outputs input.position directly.
8296
- this._pipelineLayout = this._device.createPipelineLayout({
8297
- bindGroupLayouts: [],
9317
+ _ensureInstanceCapacity(instanceCount) {
9318
+ if (!this._device || instanceCount <= this._instanceCapacity) {
9319
+ return;
9320
+ }
9321
+ let nextCapacity = Math.max(this._instanceCapacity, initialBatchCapacity);
9322
+ while (nextCapacity < instanceCount) {
9323
+ nextCapacity *= 2;
9324
+ }
9325
+ const oldData = this._instanceData;
9326
+ // Preserve any already-packed instances. _instanceCount is bounded by
9327
+ // the previous capacity, but oldData may be the initial 0-byte buffer
9328
+ // — clamp to its actual byteLength to avoid out-of-range typed-array
9329
+ // construction.
9330
+ const carryBytes = Math.min(this._instanceCount * instanceStrideBytes$1, oldData.byteLength);
9331
+ const instanceData = new ArrayBuffer(nextCapacity * instanceStrideBytes$1);
9332
+ if (carryBytes > 0) {
9333
+ new Uint8Array(instanceData).set(new Uint8Array(oldData, 0, carryBytes));
9334
+ }
9335
+ const instanceBuffer = this._device.createBuffer({
9336
+ size: instanceData.byteLength,
9337
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
8298
9338
  });
9339
+ this._instanceBuffer?.destroy();
9340
+ this._instanceCapacity = nextCapacity;
9341
+ this._instanceData = instanceData;
9342
+ this._instanceFloat32 = new Float32Array(instanceData);
9343
+ this._instanceUint32 = new Uint32Array(instanceData);
9344
+ this._instanceBuffer = instanceBuffer;
8299
9345
  }
8300
- onDisconnect() {
8301
- this.flush();
8302
- this._destroyBuffers();
8303
- this._pipelines.clear();
8304
- this._pipelineLayout = null;
8305
- this._shaderModule = null;
8306
- this._device = null;
8307
- this._backend = null;
8308
- this._drawCallCount = 0;
8309
- }
8310
- _writeShapeVertices(backend, shape, vertexStart) {
8311
- // Matrix.combine is `other * this` (see Matrix.rotate and
8312
- // SceneNode.getGlobalTransform, both of which chain via
8313
- // local.combine(parent.global) to yield parent.global * local).
8314
- //
8315
- // We need view * global applied to a local vertex, so start with
8316
- // global and combine with view — that gives
8317
- // _combinedTransform = view * global.
8318
- const matrix = this._combinedTransform
8319
- .copy(shape.getGlobalTransform())
8320
- .combine(backend.view.getTransform());
8321
- // Match the original uniform-based WGSL layout exactly.
8322
- //
8323
- // The shader packs the Matrix's 9 fields into a 4x4 mat (column-major
8324
- // in WGSL):
8325
- // col 0 = [a, c, 0, 0]
8326
- // col 1 = [b, d, 0, 0]
8327
- // col 2 = [0, 0, 1, 0]
8328
- // col 3 = [x, y, 0, z]
8329
- //
8330
- // Multiplied by vec4(px, py, 0, 1):
8331
- // out = col0*px + col1*py + col2*0 + col3*1
8332
- // out.x = a*px + b*py + x
8333
- // out.y = c*px + d*py + y
8334
- // out.z = 0
8335
- // out.w = z
8336
- //
8337
- // The Matrix class represents the affine matrix in the order
8338
- // [a b x]
8339
- // [c d y]
8340
- // [e f z]
8341
- // so a/b/c/d are rotation+scale (note: b on the TOP row, c on the
8342
- // LEFT column, not the other way around) and x/y/z the translation /
8343
- // w component. Matrix.toArray(false) confirms this layout.
8344
- const a = matrix.a;
8345
- const b = matrix.b;
8346
- const c = matrix.c;
8347
- const d = matrix.d;
8348
- const tx = matrix.x;
8349
- const ty = matrix.y;
8350
- const tw = matrix.z;
8351
- const color = shape.color.toRgba();
8352
- const vertices = shape.geometry.vertices;
8353
- const vertexCount = vertices.length / 2;
8354
- for (let i = 0; i < vertexCount; i++) {
8355
- const sourceIndex = i * 2;
8356
- const targetIndex = (vertexStart + i) * wordsPerVertex;
8357
- const px = vertices[sourceIndex];
8358
- const py = vertices[sourceIndex + 1];
8359
- this._float32View[targetIndex + 0] = a * px + b * py + tx;
8360
- this._float32View[targetIndex + 1] = c * px + d * py + ty;
8361
- this._float32View[targetIndex + 2] = 0;
8362
- this._float32View[targetIndex + 3] = tw;
8363
- this._uint32View[targetIndex + 4] = color;
9346
+ _resetSlots() {
9347
+ if (this._slotCount > 0) {
9348
+ for (let i = 0; i < this._slotCount; i++) {
9349
+ this._activeTextures[i] = null;
9350
+ }
9351
+ this._textureSlots.clear();
9352
+ this._slotCount = 0;
8364
9353
  }
8365
9354
  }
8366
- _ensureVertexCapacity(vertexCount) {
8367
- const requiredBytes = vertexCount * vertexStrideBytes$1;
8368
- if (requiredBytes > this._vertexData.byteLength) {
8369
- const byteLength = Math.max(requiredBytes, this._vertexData.byteLength === 0 ? vertexStrideBytes$1 : this._vertexData.byteLength * 2);
8370
- this._vertexData = new ArrayBuffer(byteLength);
8371
- this._float32View = new Float32Array(this._vertexData);
8372
- this._uint32View = new Uint32Array(this._vertexData);
9355
+ _createTextureBindGroup(device, backend) {
9356
+ // Slots beyond the active count get the slot-0 texture as a filler so
9357
+ // the bind-group layout always sees N valid texture views and samplers.
9358
+ // The fragment shader's switch only ever dispatches to the active slot
9359
+ // count, so unsampled fillers cost nothing visually.
9360
+ const fallbackTexture = this._activeTextures[0] ?? Texture.empty;
9361
+ const fallbackBinding = backend.getTextureBinding(fallbackTexture);
9362
+ const resolvedBindings = new Array(maxBatchTextures);
9363
+ for (let i = 0; i < maxBatchTextures; i++) {
9364
+ const texture = this._activeTextures[i] ?? fallbackTexture;
9365
+ resolvedBindings[i] = texture === fallbackTexture
9366
+ ? fallbackBinding
9367
+ : backend.getTextureBinding(texture);
8373
9368
  }
8374
- if (requiredBytes > this._vertexBufferCapacity) {
8375
- this._vertexBuffer?.destroy();
8376
- this._vertexBufferCapacity = Math.max(requiredBytes, this._vertexBufferCapacity === 0 ? vertexStrideBytes$1 : this._vertexBufferCapacity * 2);
8377
- this._vertexBuffer = this._device.createBuffer({
8378
- size: this._vertexBufferCapacity,
8379
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
9369
+ const entries = [];
9370
+ for (let i = 0; i < maxBatchTextures; i++) {
9371
+ entries.push({
9372
+ binding: i,
9373
+ resource: resolvedBindings[i].view,
8380
9374
  });
8381
9375
  }
8382
- }
8383
- _ensureIndexCapacity(indexCount) {
8384
- const requiredBytes = indexCount * Uint16Array.BYTES_PER_ELEMENT;
8385
- if (requiredBytes > this._indexBufferCapacity) {
8386
- this._indexBuffer?.destroy();
8387
- this._indexBufferCapacity = Math.max(requiredBytes, this._indexBufferCapacity === 0 ? Uint16Array.BYTES_PER_ELEMENT : this._indexBufferCapacity * 2);
8388
- this._indexBuffer = this._device.createBuffer({
8389
- size: this._indexBufferCapacity,
8390
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
9376
+ for (let i = 0; i < maxBatchTextures; i++) {
9377
+ entries.push({
9378
+ binding: maxBatchTextures + i,
9379
+ resource: resolvedBindings[i].sampler,
8391
9380
  });
8392
9381
  }
9382
+ return device.createBindGroup({
9383
+ layout: this._textureBindGroupLayout,
9384
+ entries,
9385
+ });
8393
9386
  }
8394
- _getPipeline(key) {
8395
- const pipelineKey = `${key.topology}:${key.usesStripIndex ? 1 : 0}:${key.blendMode}:${key.format}`;
9387
+ _getPipeline(blendMode, format) {
9388
+ const pipelineKey = `${blendMode}:${format}`;
8396
9389
  const existingPipeline = this._pipelines.get(pipelineKey);
8397
9390
  if (existingPipeline) {
8398
9391
  return existingPipeline;
8399
9392
  }
8400
- const pipeline = this._device.createRenderPipeline({
9393
+ if (!this._device || !this._shaderModule || !this._pipelineLayout || !this._backend) {
9394
+ throw new Error('Renderer has to be connected first!');
9395
+ }
9396
+ const pipeline = this._device.createRenderPipeline(this._buildPipelineDescriptor(blendMode, format));
9397
+ this._pipelines.set(pipelineKey, pipeline);
9398
+ return pipeline;
9399
+ }
9400
+ _buildPipelineDescriptor(blendMode, format) {
9401
+ if (!this._shaderModule || !this._pipelineLayout) {
9402
+ throw new Error('Renderer has to be connected first!');
9403
+ }
9404
+ return {
8401
9405
  layout: this._pipelineLayout,
8402
9406
  vertex: {
8403
9407
  module: this._shaderModule,
8404
9408
  entryPoint: 'vertexMain',
8405
9409
  buffers: [{
8406
- arrayStride: vertexStrideBytes$1,
9410
+ arrayStride: instanceStrideBytes$1,
9411
+ stepMode: 'instance',
8407
9412
  attributes: [{
8408
9413
  shaderLocation: 0,
8409
9414
  offset: 0,
@@ -8411,7 +9416,23 @@ class WebGpuPrimitiveRenderer extends AbstractWebGpuRenderer {
8411
9416
  }, {
8412
9417
  shaderLocation: 1,
8413
9418
  offset: 16,
9419
+ format: 'float32x3',
9420
+ }, {
9421
+ shaderLocation: 2,
9422
+ offset: 28,
9423
+ format: 'float32x3',
9424
+ }, {
9425
+ shaderLocation: 3,
9426
+ offset: 40,
9427
+ format: 'unorm16x4',
9428
+ }, {
9429
+ shaderLocation: 4,
9430
+ offset: 48,
8414
9431
  format: 'unorm8x4',
9432
+ }, {
9433
+ shaderLocation: 5,
9434
+ offset: 52,
9435
+ format: 'uint32',
8415
9436
  }],
8416
9437
  }],
8417
9438
  },
@@ -8419,491 +9440,233 @@ class WebGpuPrimitiveRenderer extends AbstractWebGpuRenderer {
8419
9440
  module: this._shaderModule,
8420
9441
  entryPoint: 'fragmentMain',
8421
9442
  targets: [{
8422
- format: key.format,
8423
- blend: getWebGpuBlendState(key.blendMode),
9443
+ format,
9444
+ blend: getWebGpuBlendState(blendMode),
8424
9445
  writeMask: GPUColorWrite.ALL,
8425
9446
  }],
8426
9447
  },
8427
9448
  primitive: {
8428
- topology: key.topology,
8429
- stripIndexFormat: key.usesStripIndex ? 'uint16' : undefined,
9449
+ topology: 'triangle-list',
8430
9450
  },
8431
- });
8432
- this._pipelines.set(pipelineKey, pipeline);
8433
- return pipeline;
8434
- }
8435
- _getTopology(drawMode) {
8436
- switch (drawMode) {
8437
- case RenderingPrimitives.Points:
8438
- return 'point-list';
8439
- case RenderingPrimitives.Lines:
8440
- return 'line-list';
8441
- case RenderingPrimitives.LineLoop:
8442
- case RenderingPrimitives.LineStrip:
8443
- return 'line-strip';
8444
- case RenderingPrimitives.Triangles:
8445
- case RenderingPrimitives.TriangleFan:
8446
- return 'triangle-list';
8447
- case RenderingPrimitives.TriangleStrip:
8448
- return 'triangle-strip';
8449
- default:
8450
- throw new Error(`WebGPU primitive renderer does not support draw mode "${drawMode}" yet.`);
8451
- }
8452
- }
8453
- _resolveDrawCall(shape) {
8454
- const vertices = shape.geometry.vertices;
8455
- const vertexCount = vertices.length / 2;
8456
- if (vertexCount === 0) {
8457
- return null;
8458
- }
8459
- switch (shape.drawMode) {
8460
- case RenderingPrimitives.LineLoop:
8461
- return this._resolveLineLoopDrawCall(shape.geometry.indices, vertexCount);
8462
- case RenderingPrimitives.TriangleFan:
8463
- return this._resolveTriangleFanDrawCall(shape.geometry.indices, vertexCount);
8464
- default: {
8465
- const indices = shape.geometry.indices;
8466
- const topology = this._getTopology(shape.drawMode);
8467
- const indexCount = indices.length;
8468
- const usesStripIndex = indexCount > 0 && (shape.drawMode === RenderingPrimitives.LineStrip
8469
- || shape.drawMode === RenderingPrimitives.TriangleStrip);
8470
- if (indexCount > 0) {
8471
- return {
8472
- topology,
8473
- usesStripIndex,
8474
- vertexCount,
8475
- indices,
8476
- indexCount,
8477
- };
8478
- }
8479
- return {
8480
- topology,
8481
- usesStripIndex,
8482
- vertexCount,
8483
- indices: null,
8484
- indexCount: 0,
8485
- };
8486
- }
8487
- }
8488
- }
8489
- _resolveLineLoopDrawCall(indices, vertexCount) {
8490
- const sourceIndices = indices.length > 0 ? indices : this._getSequentialIndices(vertexCount);
8491
- const sourceCount = sourceIndices.length;
8492
- if (sourceCount < 2) {
8493
- return null;
8494
- }
8495
- const loopIndexCount = sourceCount + 1;
8496
- const generatedIndices = this._ensureGeneratedIndexCapacity(loopIndexCount);
8497
- generatedIndices.set(sourceIndices.subarray(0, sourceCount), 0);
8498
- generatedIndices[sourceCount] = sourceIndices[0];
8499
- return {
8500
- topology: 'line-strip',
8501
- usesStripIndex: true,
8502
- vertexCount,
8503
- indices: generatedIndices,
8504
- indexCount: loopIndexCount,
8505
- };
8506
- }
8507
- _resolveTriangleFanDrawCall(indices, vertexCount) {
8508
- const sourceIndices = indices.length > 0 ? indices : this._getSequentialIndices(vertexCount);
8509
- const sourceCount = sourceIndices.length;
8510
- if (sourceCount < 3) {
8511
- return null;
8512
- }
8513
- const indexCount = (sourceCount - 2) * 3;
8514
- const generatedIndices = this._ensureGeneratedIndexCapacity(indexCount);
8515
- let targetIndex = 0;
8516
- for (let index = 1; index < sourceCount - 1; index++) {
8517
- generatedIndices[targetIndex++] = sourceIndices[0];
8518
- generatedIndices[targetIndex++] = sourceIndices[index];
8519
- generatedIndices[targetIndex++] = sourceIndices[index + 1];
8520
- }
8521
- return {
8522
- topology: 'triangle-list',
8523
- usesStripIndex: false,
8524
- vertexCount,
8525
- indices: generatedIndices,
8526
- indexCount,
8527
9451
  };
8528
9452
  }
8529
- _getSequentialIndices(vertexCount) {
8530
- if (vertexCount > this._sequentialIndexData.length) {
8531
- let nextLength = Math.max(1, this._sequentialIndexData.length);
8532
- while (nextLength < vertexCount) {
8533
- nextLength *= 2;
8534
- }
8535
- this._sequentialIndexData = new Uint16Array(nextLength);
8536
- }
8537
- for (let index = 0; index < vertexCount; index++) {
8538
- this._sequentialIndexData[index] = index;
8539
- }
8540
- return this._sequentialIndexData.subarray(0, vertexCount);
8541
- }
8542
- _ensureGeneratedIndexCapacity(indexCount) {
8543
- if (indexCount > this._generatedIndexData.length) {
8544
- let nextLength = Math.max(1, this._generatedIndexData.length);
8545
- while (nextLength < indexCount) {
8546
- nextLength *= 2;
8547
- }
8548
- this._generatedIndexData = new Uint16Array(nextLength);
8549
- }
8550
- return this._generatedIndexData.subarray(0, indexCount);
8551
- }
8552
- _destroyBuffers() {
8553
- this._vertexBuffer?.destroy();
8554
- this._indexBuffer?.destroy();
8555
- this._vertexBuffer = null;
8556
- this._indexBuffer = null;
8557
- this._vertexBufferCapacity = 0;
8558
- this._indexBufferCapacity = 0;
8559
- }
8560
- }
8561
-
8562
- /// <reference types="@webgpu/types" />
8563
- const spriteShaderSource = `
8564
- struct ProjectionUniforms {
8565
- matrix: mat4x4<f32>,
8566
- };
8567
-
8568
- @group(0) @binding(0)
8569
- var<uniform> projection: ProjectionUniforms;
8570
-
8571
- @group(1) @binding(0)
8572
- var spriteTexture0: texture_2d<f32>;
8573
- @group(1) @binding(1)
8574
- var spriteTexture1: texture_2d<f32>;
8575
- @group(1) @binding(2)
8576
- var spriteTexture2: texture_2d<f32>;
8577
- @group(1) @binding(3)
8578
- var spriteTexture3: texture_2d<f32>;
8579
- @group(1) @binding(4)
8580
- var spriteTexture4: texture_2d<f32>;
8581
- @group(1) @binding(5)
8582
- var spriteTexture5: texture_2d<f32>;
8583
- @group(1) @binding(6)
8584
- var spriteTexture6: texture_2d<f32>;
8585
- @group(1) @binding(7)
8586
- var spriteTexture7: texture_2d<f32>;
8587
-
8588
- @group(1) @binding(8)
8589
- var spriteSampler0: sampler;
8590
- @group(1) @binding(9)
8591
- var spriteSampler1: sampler;
8592
- @group(1) @binding(10)
8593
- var spriteSampler2: sampler;
8594
- @group(1) @binding(11)
8595
- var spriteSampler3: sampler;
8596
- @group(1) @binding(12)
8597
- var spriteSampler4: sampler;
8598
- @group(1) @binding(13)
8599
- var spriteSampler5: sampler;
8600
- @group(1) @binding(14)
8601
- var spriteSampler6: sampler;
8602
- @group(1) @binding(15)
8603
- var spriteSampler7: sampler;
8604
-
8605
- // Per-instance vertex layout (56 bytes per sprite). The four corners
8606
- // of the quad are derived from @builtin(vertex_index) 0..3 inside the
8607
- // vertex shader — there is no per-vertex stream.
8608
- struct VertexInput {
8609
- @location(0) localBounds: vec4<f32>, // left, top, right, bottom (local space)
8610
- @location(1) transformAB: vec3<f32>, // first row of 2D affine
8611
- @location(2) transformCD: vec3<f32>, // second row of 2D affine
8612
- @location(3) uvBounds: vec4<f32>, // uMin, vMin, uMax, vMax (CPU pre-swaps for flipY)
8613
- @location(4) color: vec4<f32>, // RGBA tint
8614
- @location(5) packedSlotFlags: u32, // bits 0..7 = slot, bit 8 = premultiply
8615
- };
8616
-
8617
- struct VertexOutput {
8618
- @builtin(position) position: vec4<f32>,
8619
- @location(0) texcoord: vec2<f32>,
8620
- @location(1) color: vec4<f32>,
8621
- @location(2) @interpolate(flat) premultiplySample: u32,
8622
- @location(3) @interpolate(flat) textureSlot: u32,
8623
- };
8624
-
8625
- @vertex
8626
- fn vertexMain(input: VertexInput, @builtin(vertex_index) vid: u32) -> VertexOutput {
8627
- var output: VertexOutput;
8628
-
8629
- // vid 0..3 → corners in TL, TR, BR, BL order (matches the static index
8630
- // buffer [0,1,2,0,2,3] used for indexed triangle-list drawing).
8631
- let cornerX = ((vid + 1u) >> 1u) & 1u;
8632
- let cornerY = vid >> 1u;
8633
-
8634
- let localX = select(input.localBounds.x, input.localBounds.z, cornerX == 1u);
8635
- let localY = select(input.localBounds.y, input.localBounds.w, cornerY == 1u);
8636
-
8637
- let worldX = input.transformAB.x * localX + input.transformAB.y * localY + input.transformAB.z;
8638
- let worldY = input.transformCD.x * localX + input.transformCD.y * localY + input.transformCD.z;
8639
-
8640
- output.position = projection.matrix * vec4<f32>(worldX, worldY, 0.0, 1.0);
8641
-
8642
- let u = select(input.uvBounds.x, input.uvBounds.z, cornerX == 1u);
8643
- let v = select(input.uvBounds.y, input.uvBounds.w, cornerY == 1u);
8644
- output.texcoord = vec2<f32>(u, v);
8645
-
8646
- output.color = vec4(input.color.rgb * input.color.a, input.color.a);
8647
- output.textureSlot = input.packedSlotFlags & 0xFFu;
8648
- output.premultiplySample = (input.packedSlotFlags >> 8u) & 1u;
8649
-
8650
- return output;
8651
9453
  }
8652
9454
 
8653
- fn sampleTexture(slot: u32, uv: vec2<f32>, ddx: vec2<f32>, ddy: vec2<f32>) -> vec4<f32> {
8654
- switch slot {
8655
- case 0u: {
8656
- return textureSampleGrad(spriteTexture0, spriteSampler0, uv, ddx, ddy);
8657
- }
8658
- case 1u: {
8659
- return textureSampleGrad(spriteTexture1, spriteSampler1, uv, ddx, ddy);
8660
- }
8661
- case 2u: {
8662
- return textureSampleGrad(spriteTexture2, spriteSampler2, uv, ddx, ddy);
8663
- }
8664
- case 3u: {
8665
- return textureSampleGrad(spriteTexture3, spriteSampler3, uv, ddx, ddy);
8666
- }
8667
- case 4u: {
8668
- return textureSampleGrad(spriteTexture4, spriteSampler4, uv, ddx, ddy);
8669
- }
8670
- case 5u: {
8671
- return textureSampleGrad(spriteTexture5, spriteSampler5, uv, ddx, ddy);
8672
- }
8673
- case 6u: {
8674
- return textureSampleGrad(spriteTexture6, spriteSampler6, uv, ddx, ddy);
8675
- }
8676
- default: {
8677
- return textureSampleGrad(spriteTexture7, spriteSampler7, uv, ddx, ddy);
8678
- }
8679
- }
9455
+ /// <reference types="@webgpu/types" />
9456
+ const meshShaderSource = `
9457
+ struct VertexInput {
9458
+ @location(0) position: vec2<f32>,
9459
+ @location(1) texcoord: vec2<f32>,
9460
+ @location(2) color: vec4<f32>,
9461
+ };
9462
+
9463
+ struct VertexOutput {
9464
+ @builtin(position) position: vec4<f32>,
9465
+ @location(0) texcoord: vec2<f32>,
9466
+ @location(1) color: vec4<f32>,
9467
+ @location(2) @interpolate(flat) premultiplySample: u32,
9468
+ };
9469
+
9470
+ struct TintUniform {
9471
+ tint: vec4<f32>,
9472
+ flags: vec4<f32>,
9473
+ };
9474
+
9475
+ @group(0) @binding(0) var<uniform> uniforms: TintUniform;
9476
+
9477
+ @group(1) @binding(0) var meshTexture: texture_2d<f32>;
9478
+ @group(1) @binding(1) var meshSampler: sampler;
9479
+
9480
+ @vertex
9481
+ fn vertexMain(input: VertexInput) -> VertexOutput {
9482
+ var output: VertexOutput;
9483
+ output.position = vec4<f32>(input.position, 0.0, 1.0);
9484
+ output.texcoord = input.texcoord;
9485
+ output.color = input.color;
9486
+ output.premultiplySample = u32(uniforms.flags.x);
9487
+ return output;
8680
9488
  }
8681
9489
 
8682
9490
  @fragment
8683
9491
  fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
8684
- // Compute screen-space derivatives in uniform control flow before the
8685
- // per-slot switch. WGSL requires textureSample (implicit LOD) to run in
8686
- // uniform control flow, which multi-texture batching breaks because the
8687
- // slot varies per fragment. textureSampleGrad takes explicit derivatives
8688
- // and is valid regardless of control-flow uniformity, while preserving
8689
- // mipmap-correct LOD when sprites use mipmapped textures.
8690
- let ddx = dpdx(input.texcoord);
8691
- let ddy = dpdy(input.texcoord);
8692
- let sample = sampleTexture(input.textureSlot, input.texcoord, ddx, ddy);
9492
+ let sample = textureSample(meshTexture, meshSampler, input.texcoord);
8693
9493
  let resolvedSample = select(sample, vec4(sample.rgb * sample.a, sample.a), input.premultiplySample == 1u);
8694
-
8695
- return resolvedSample * input.color;
9494
+ let modulated = resolvedSample * input.color * uniforms.tint;
9495
+ return vec4<f32>(modulated.rgb * modulated.a, modulated.a);
8696
9496
  }
8697
9497
  `;
8698
- const instanceStrideBytes$1 = 56;
8699
- const wordsPerInstance = instanceStrideBytes$1 / Uint32Array.BYTES_PER_ELEMENT;
8700
- const projectionByteLength = 64;
8701
- const initialBatchCapacity = 32;
8702
- const maxBatchTextures = 8;
8703
- const indicesPerSprite = 6;
8704
- // Static index buffer: two triangles forming a quad, vertex IDs 0..3 in
8705
- // TL/TR/BR/BL order so the WGSL `cornerX/cornerY` derivation matches.
8706
- const quadIndices = new Uint16Array([0, 1, 2, 0, 2, 3]);
8707
- class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
8708
- _projectionData = new Float32Array(projectionByteLength / Float32Array.BYTES_PER_ELEMENT);
9498
+ // Per-vertex layout (20 bytes): pos f32x2 + uv f32x2 + color u8x4-norm.
9499
+ // CPU bakes the (view * globalTransform) into position so the vertex
9500
+ // shader stays branchless and uniform-free except for the per-mesh tint.
9501
+ const vertexStrideBytes$1 = 20;
9502
+ const wordsPerVertex = vertexStrideBytes$1 / 4;
9503
+ const tintByteLength = 32; // vec4 tint + vec4 flags (only flags.x used)
9504
+ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
9505
+ _combinedTransform = new Matrix();
9506
+ _drawCalls = [];
9507
+ _pipelines = new Map();
9508
+ _textureBindGroups = new Map();
8709
9509
  _device = null;
8710
9510
  _shaderModule = null;
8711
9511
  _uniformBindGroupLayout = null;
8712
9512
  _textureBindGroupLayout = null;
8713
9513
  _pipelineLayout = null;
9514
+ _vertexBuffer = null;
9515
+ _indexBuffer = null;
8714
9516
  _uniformBuffer = null;
8715
9517
  _uniformBindGroup = null;
8716
- _indexBuffer = null;
8717
- _instanceBuffer = null;
8718
- _instanceCapacity = 0;
8719
- _instanceData = new ArrayBuffer(0);
8720
- _instanceFloat32 = new Float32Array(this._instanceData);
8721
- _instanceUint32 = new Uint32Array(this._instanceData);
8722
- _pipelines = new Map();
8723
- _activeTextures = new Array(maxBatchTextures).fill(null);
8724
- _textureSlots = new Map();
8725
- _slotCount = 0;
8726
- _instanceCount = 0;
8727
- _currentBlendMode = null;
8728
- onConnect(backend) {
8729
- if (this._device) {
8730
- return;
8731
- }
8732
- this._device = backend.device;
8733
- this._shaderModule = this._device.createShaderModule({ code: spriteShaderSource });
8734
- this._uniformBindGroupLayout = this._device.createBindGroupLayout({
8735
- entries: [{
8736
- binding: 0,
8737
- visibility: GPUShaderStage.VERTEX,
8738
- buffer: {
8739
- type: 'uniform',
8740
- },
8741
- }],
8742
- });
8743
- this._textureBindGroupLayout = this._device.createBindGroupLayout({
8744
- entries: [
8745
- ...Array.from({ length: maxBatchTextures }, (_, index) => ({
8746
- binding: index,
8747
- visibility: GPUShaderStage.FRAGMENT,
8748
- texture: {
8749
- sampleType: 'float',
8750
- },
8751
- })),
8752
- ...Array.from({ length: maxBatchTextures }, (_, index) => ({
8753
- binding: maxBatchTextures + index,
8754
- visibility: GPUShaderStage.FRAGMENT,
8755
- sampler: {
8756
- type: 'filtering',
8757
- },
8758
- })),
8759
- ],
8760
- });
8761
- this._pipelineLayout = this._device.createPipelineLayout({
8762
- bindGroupLayouts: [this._uniformBindGroupLayout, this._textureBindGroupLayout],
8763
- });
8764
- this._uniformBuffer = this._device.createBuffer({
8765
- size: projectionByteLength,
8766
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
8767
- });
8768
- this._uniformBindGroup = this._device.createBindGroup({
8769
- layout: this._uniformBindGroupLayout,
8770
- entries: [{
8771
- binding: 0,
8772
- resource: {
8773
- buffer: this._uniformBuffer,
8774
- },
8775
- }],
8776
- });
8777
- // Static index buffer for the quad. Allocated once at connect; its
8778
- // contents never change.
8779
- this._indexBuffer = this._device.createBuffer({
8780
- size: quadIndices.byteLength,
8781
- usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
8782
- });
8783
- this._device.queue.writeBuffer(this._indexBuffer, 0, quadIndices.buffer, quadIndices.byteOffset, quadIndices.byteLength);
8784
- }
8785
- onDisconnect() {
8786
- this._instanceBuffer?.destroy();
8787
- this._indexBuffer?.destroy();
8788
- this._uniformBuffer?.destroy();
8789
- this._pipelines.clear();
8790
- this._instanceBuffer = null;
8791
- this._indexBuffer = null;
8792
- this._uniformBindGroup = null;
8793
- this._uniformBuffer = null;
8794
- this._pipelineLayout = null;
8795
- this._textureBindGroupLayout = null;
8796
- this._uniformBindGroupLayout = null;
8797
- this._shaderModule = null;
8798
- this._device = null;
8799
- this._backend = null;
8800
- this._instanceCapacity = 0;
8801
- this._instanceData = new ArrayBuffer(0);
8802
- this._instanceFloat32 = new Float32Array(this._instanceData);
8803
- this._instanceUint32 = new Uint32Array(this._instanceData);
8804
- this._instanceCount = 0;
8805
- this._currentBlendMode = null;
8806
- this._resetSlots();
8807
- }
8808
- render(sprite) {
9518
+ _uniformAlignment = 256;
9519
+ _vertexBufferCapacity = 0;
9520
+ _indexBufferCapacity = 0;
9521
+ _uniformBufferCapacity = 0;
9522
+ _vertexData = new ArrayBuffer(0);
9523
+ _float32View = new Float32Array(this._vertexData);
9524
+ _uint32View = new Uint32Array(this._vertexData);
9525
+ _packedIndexData = new Uint16Array(0);
9526
+ _drawCallCount = 0;
9527
+ render(mesh) {
8809
9528
  const backend = this._backend;
8810
- const texture = sprite.texture;
8811
- // Same early-out conditions as the deferred renderer used to apply.
8812
- if (backend === null
8813
- || (!(texture instanceof Texture) && !(texture instanceof RenderTexture))
8814
- || texture.width === 0
8815
- || texture.height === 0
8816
- || (texture instanceof Texture && texture.source === null)) {
8817
- return;
9529
+ if (backend === null) {
9530
+ throw new Error('WebGpuMeshRenderer is not connected to a backend.');
8818
9531
  }
8819
- const blendMode = sprite.blendMode;
8820
- // Flush triggers: blend-mode change, instance buffer full at current
8821
- // capacity (we'll grow on next render), or texture-slot exhaustion.
8822
- const blendModeChanged = this._currentBlendMode !== null && blendMode !== this._currentBlendMode;
8823
- const slotExhausted = !this._textureSlots.has(texture) && this._slotCount >= maxBatchTextures;
8824
- if (blendModeChanged || slotExhausted) {
8825
- this.flush();
9532
+ const vertexCount = mesh.vertexCount;
9533
+ if (vertexCount === 0) {
9534
+ return;
8826
9535
  }
8827
- this._currentBlendMode = blendMode;
9536
+ const blendMode = mesh.blendMode;
8828
9537
  backend.setBlendMode(blendMode);
8829
- // Resolve / assign texture slot.
8830
- let slot = this._textureSlots.get(texture);
8831
- if (slot === undefined) {
8832
- slot = this._slotCount++;
8833
- this._textureSlots.set(texture, slot);
8834
- this._activeTextures[slot] = texture;
8835
- }
8836
- const premultiplySample = backend.shouldPremultiplyTextureSample(texture) ? 1 : 0;
8837
- const packedSlotFlags = slot | (premultiplySample << 8);
8838
- // Ensure capacity covers the new entry BEFORE packing — otherwise the
8839
- // typed-array writes in _packInstance silently fall off the end of a
8840
- // too-small buffer.
8841
- this._ensureInstanceCapacity(this._instanceCount + 1);
8842
- this._packInstance(sprite, texture, packedSlotFlags);
8843
- this._instanceCount++;
9538
+ const meshTexture = mesh.texture ?? Texture.white;
9539
+ // Texture.white is a 1x1 canvas-backed Texture; backend.shouldPremultiplyTextureSample
9540
+ // expects RenderTexture-or-Texture. Both branches are valid here.
9541
+ const premultiplySample = backend.shouldPremultiplyTextureSample(meshTexture);
9542
+ const indexCount = mesh.indexCount;
9543
+ // Plan offsets within the shared per-frame buffers; actual data
9544
+ // packing happens in flush() after all drawcalls are known so a
9545
+ // single writeBuffer per resource covers the whole frame.
9546
+ const drawCall = {
9547
+ mesh,
9548
+ blendMode,
9549
+ texture: meshTexture,
9550
+ premultiplySample,
9551
+ vertexByteOffset: 0,
9552
+ vertexCount,
9553
+ indexByteOffset: 0,
9554
+ indexCount,
9555
+ };
9556
+ // Use mutable record (interface readonly is for type safety against
9557
+ // callers; the renderer fills these slots in flush()).
9558
+ this._drawCalls[this._drawCallCount++] = drawCall;
8844
9559
  }
8845
9560
  flush() {
8846
9561
  const backend = this._backend;
8847
9562
  const device = this._device;
8848
- const uniformBuffer = this._uniformBuffer;
8849
- const uniformBindGroup = this._uniformBindGroup;
8850
- if (!backend || !device || !uniformBuffer || !uniformBindGroup) {
9563
+ if (!backend || !device) {
8851
9564
  return;
8852
9565
  }
8853
- if (this._instanceCount === 0 && !backend.clearRequested) {
9566
+ if (this._drawCallCount === 0 && !backend.clearRequested) {
8854
9567
  return;
8855
9568
  }
8856
- const viewMatrix = backend.view.getTransform();
8857
- this._projectionData.set([
8858
- viewMatrix.a, viewMatrix.c, 0, 0,
8859
- viewMatrix.b, viewMatrix.d, 0, 0,
8860
- 0, 0, 1, 0,
8861
- viewMatrix.x, viewMatrix.y, 0, viewMatrix.z,
8862
- ]);
8863
- device.queue.writeBuffer(uniformBuffer, 0, this._projectionData.buffer, this._projectionData.byteOffset, this._projectionData.byteLength);
9569
+ const scissor = backend.getScissorRect();
9570
+ const maskClipsAll = scissor !== null && (scissor.width <= 0 || scissor.height <= 0);
9571
+ if (this._drawCallCount === 0 || maskClipsAll) {
9572
+ // Honor a pending clear with an empty pass so createColorAttachment
9573
+ // consumes the clear-state once.
9574
+ if (backend.clearRequested) {
9575
+ const encoder = device.createCommandEncoder();
9576
+ const pass = encoder.beginRenderPass({
9577
+ colorAttachments: [backend.createColorAttachment()],
9578
+ });
9579
+ backend.stats.renderPasses++;
9580
+ pass.end();
9581
+ backend.submit(encoder.finish());
9582
+ }
9583
+ this._drawCallCount = 0;
9584
+ return;
9585
+ }
9586
+ // Phase 1: compute layout offsets for the whole frame.
9587
+ let totalVertices = 0;
9588
+ let totalIndices = 0;
9589
+ for (let i = 0; i < this._drawCallCount; i++) {
9590
+ const dc = this._drawCalls[i];
9591
+ dc.vertexByteOffset = totalVertices * vertexStrideBytes$1;
9592
+ dc.indexByteOffset = totalIndices * Uint16Array.BYTES_PER_ELEMENT;
9593
+ totalVertices += dc.vertexCount;
9594
+ totalIndices += dc.indexCount;
9595
+ }
9596
+ // Phase 2: ensure capacities for the totals.
9597
+ this._ensureVertexCapacity(totalVertices);
9598
+ this._ensureIndexCapacity(totalIndices);
9599
+ this._ensureUniformCapacity(this._drawCallCount);
9600
+ // Phase 3: pack vertex + index + uniform CPU-side data.
9601
+ const uniformBytes = this._drawCallCount * this._uniformAlignment;
9602
+ const uniformData = new ArrayBuffer(uniformBytes);
9603
+ const uniformF32 = new Float32Array(uniformData);
9604
+ for (let i = 0; i < this._drawCallCount; i++) {
9605
+ const dc = this._drawCalls[i];
9606
+ this._writeMeshVertices(backend, dc.mesh, dc.vertexByteOffset / vertexStrideBytes$1);
9607
+ if (dc.mesh.indices !== null) {
9608
+ this._packedIndexData.set(dc.mesh.indices, dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT);
9609
+ }
9610
+ else {
9611
+ const start = dc.indexByteOffset / Uint16Array.BYTES_PER_ELEMENT;
9612
+ for (let j = 0; j < dc.indexCount; j++) {
9613
+ this._packedIndexData[start + j] = j;
9614
+ }
9615
+ }
9616
+ const uniformOffsetWords = (i * this._uniformAlignment) / Float32Array.BYTES_PER_ELEMENT;
9617
+ const tint = dc.mesh.tint;
9618
+ uniformF32[uniformOffsetWords + 0] = tint.red;
9619
+ uniformF32[uniformOffsetWords + 1] = tint.green;
9620
+ uniformF32[uniformOffsetWords + 2] = tint.blue;
9621
+ uniformF32[uniformOffsetWords + 3] = tint.alpha;
9622
+ uniformF32[uniformOffsetWords + 4] = dc.premultiplySample ? 1 : 0;
9623
+ uniformF32[uniformOffsetWords + 5] = 0;
9624
+ uniformF32[uniformOffsetWords + 6] = 0;
9625
+ uniformF32[uniformOffsetWords + 7] = 0;
9626
+ }
9627
+ // Phase 4: single writeBuffer per resource for the whole frame.
9628
+ device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, totalVertices * vertexStrideBytes$1);
9629
+ device.queue.writeBuffer(this._indexBuffer, 0, this._packedIndexData.buffer, this._packedIndexData.byteOffset, totalIndices * Uint16Array.BYTES_PER_ELEMENT);
9630
+ device.queue.writeBuffer(this._uniformBuffer, 0, uniformData, 0, uniformBytes);
9631
+ // Phase 5: single render pass with one drawIndexed per mesh.
8864
9632
  const encoder = device.createCommandEncoder();
8865
9633
  const pass = encoder.beginRenderPass({
8866
9634
  colorAttachments: [backend.createColorAttachment()],
8867
9635
  });
8868
9636
  backend.stats.renderPasses++;
8869
- const scissor = backend.getScissorRect();
8870
- const maskClipsAll = scissor !== null && (scissor.width <= 0 || scissor.height <= 0);
8871
- if (scissor !== null && !maskClipsAll) {
9637
+ if (scissor !== null) {
8872
9638
  pass.setScissorRect(scissor.x, scissor.y, scissor.width, scissor.height);
8873
9639
  }
8874
- if (this._instanceCount > 0 && !maskClipsAll && this._instanceBuffer !== null && this._indexBuffer !== null && this._currentBlendMode !== null) {
8875
- device.queue.writeBuffer(this._instanceBuffer, 0, this._instanceData, 0, this._instanceCount * instanceStrideBytes$1);
8876
- const pipeline = this._getPipeline(this._currentBlendMode, backend.renderTargetFormat);
8877
- const textureBindGroup = this._createTextureBindGroup(device, backend);
8878
- pass.setPipeline(pipeline);
8879
- pass.setBindGroup(0, uniformBindGroup);
8880
- pass.setBindGroup(1, textureBindGroup);
8881
- pass.setVertexBuffer(0, this._instanceBuffer);
8882
- pass.setIndexBuffer(this._indexBuffer, 'uint16');
8883
- pass.drawIndexed(indicesPerSprite, this._instanceCount, 0, 0, 0);
9640
+ let lastBlendMode = null;
9641
+ let lastFormat = null;
9642
+ let lastTexture = null;
9643
+ const renderTargetFormat = backend.renderTargetFormat;
9644
+ for (let i = 0; i < this._drawCallCount; i++) {
9645
+ const dc = this._drawCalls[i];
9646
+ if (dc.blendMode !== lastBlendMode || renderTargetFormat !== lastFormat) {
9647
+ lastBlendMode = dc.blendMode;
9648
+ lastFormat = renderTargetFormat;
9649
+ pass.setPipeline(this._getPipeline({ blendMode: dc.blendMode, format: renderTargetFormat }));
9650
+ }
9651
+ pass.setBindGroup(0, this._uniformBindGroup, [i * this._uniformAlignment]);
9652
+ if (dc.texture !== lastTexture) {
9653
+ lastTexture = dc.texture;
9654
+ pass.setBindGroup(1, this._getTextureBindGroup(backend, dc.texture));
9655
+ }
9656
+ pass.setVertexBuffer(0, this._vertexBuffer, dc.vertexByteOffset);
9657
+ pass.setIndexBuffer(this._indexBuffer, 'uint16', dc.indexByteOffset);
9658
+ pass.drawIndexed(dc.indexCount);
8884
9659
  backend.stats.batches++;
8885
9660
  backend.stats.drawCalls++;
8886
9661
  }
8887
- pass.end();
8888
- backend.submit(encoder.finish());
8889
- this._instanceCount = 0;
8890
- this._resetSlots();
8891
- this._currentBlendMode = null;
8892
- }
8893
- destroy() {
8894
- this.disconnect();
8895
- }
8896
- /**
8897
- * Pre-create render pipelines for every blend-mode × target-format
8898
- * combination this renderer can produce, asynchronously and in
8899
- * parallel. Called from the render manager's init path so by the time
8900
- * the first frame draws, all pipelines exist in cache.
8901
- *
8902
- * Without prewarm, the first draw of any new (blendMode, format)
8903
- * combination would fall back to the synchronous _getPipeline() path,
8904
- * which blocks while the WebGPU implementation compiles WGSL and
8905
- * sets up the pipeline state object — typically tens of milliseconds.
8906
- */
9662
+ pass.end();
9663
+ backend.submit(encoder.finish());
9664
+ this._drawCallCount = 0;
9665
+ }
9666
+ destroy() {
9667
+ this.disconnect();
9668
+ this._combinedTransform.destroy();
9669
+ }
8907
9670
  async prewarmPipelines(formats) {
8908
9671
  const device = this._device;
8909
9672
  if (!device || !this._shaderModule || !this._pipelineLayout) {
@@ -8922,173 +9685,121 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
8922
9685
  const promises = [];
8923
9686
  for (const blendMode of blendModes) {
8924
9687
  for (const format of formats) {
8925
- const pipelineKey = `${blendMode}:${format}`;
8926
- if (this._pipelines.has(pipelineKey)) {
9688
+ const key = `${blendMode}:${format}`;
9689
+ if (this._pipelines.has(key))
8927
9690
  continue;
8928
- }
8929
- const promise = device
8930
- .createRenderPipelineAsync(this._buildPipelineDescriptor(blendMode, format))
9691
+ promises.push(device.createRenderPipelineAsync(this._buildPipelineDescriptor(blendMode, format))
8931
9692
  .then((pipeline) => {
8932
- this._pipelines.set(pipelineKey, pipeline);
8933
- });
8934
- promises.push(promise);
9693
+ this._pipelines.set(key, pipeline);
9694
+ }));
8935
9695
  }
8936
9696
  }
8937
9697
  await Promise.all(promises);
8938
9698
  }
8939
- _packInstance(sprite, texture, packedSlotFlags) {
8940
- const offset = this._instanceCount * wordsPerInstance;
8941
- const f32 = this._instanceFloat32;
8942
- const u32 = this._instanceUint32;
8943
- const bounds = sprite.getLocalBounds();
8944
- f32[offset + 0] = bounds.left;
8945
- f32[offset + 1] = bounds.top;
8946
- f32[offset + 2] = bounds.right;
8947
- f32[offset + 3] = bounds.bottom;
8948
- const transform = sprite.getGlobalTransform();
8949
- f32[offset + 4] = transform.a;
8950
- f32[offset + 5] = transform.b;
8951
- f32[offset + 6] = transform.x;
8952
- f32[offset + 7] = transform.c;
8953
- f32[offset + 8] = transform.d;
8954
- f32[offset + 9] = transform.y;
8955
- // uvBounds: u16x4 normalised, packed into two u32 slots. The CPU
8956
- // applies the flipY swap so the shader stays orientation-agnostic.
8957
- const frame = sprite.textureFrame;
8958
- const texWidth = texture.width;
8959
- const texHeight = texture.height;
8960
- const uMin = ((frame.left / texWidth) * 0xFFFF) & 0xFFFF;
8961
- const uMax = ((frame.right / texWidth) * 0xFFFF) & 0xFFFF;
8962
- const vMinRaw = ((frame.top / texHeight) * 0xFFFF) & 0xFFFF;
8963
- const vMaxRaw = ((frame.bottom / texHeight) * 0xFFFF) & 0xFFFF;
8964
- const flipY = texture instanceof Texture && texture.flipY;
8965
- const vMin = flipY ? vMaxRaw : vMinRaw;
8966
- const vMax = flipY ? vMinRaw : vMaxRaw;
8967
- u32[offset + 10] = uMin | (vMin << 16);
8968
- u32[offset + 11] = uMax | (vMax << 16);
8969
- u32[offset + 12] = sprite.tint.toRgba();
8970
- u32[offset + 13] = packedSlotFlags;
8971
- }
8972
- _ensureInstanceCapacity(instanceCount) {
8973
- if (!this._device || instanceCount <= this._instanceCapacity) {
9699
+ onConnect(backend) {
9700
+ if (this._device) {
8974
9701
  return;
8975
9702
  }
8976
- let nextCapacity = Math.max(this._instanceCapacity, initialBatchCapacity);
8977
- while (nextCapacity < instanceCount) {
8978
- nextCapacity *= 2;
8979
- }
8980
- const oldData = this._instanceData;
8981
- // Preserve any already-packed instances. _instanceCount is bounded by
8982
- // the previous capacity, but oldData may be the initial 0-byte buffer
8983
- // — clamp to its actual byteLength to avoid out-of-range typed-array
8984
- // construction.
8985
- const carryBytes = Math.min(this._instanceCount * instanceStrideBytes$1, oldData.byteLength);
8986
- const instanceData = new ArrayBuffer(nextCapacity * instanceStrideBytes$1);
8987
- if (carryBytes > 0) {
8988
- new Uint8Array(instanceData).set(new Uint8Array(oldData, 0, carryBytes));
8989
- }
8990
- const instanceBuffer = this._device.createBuffer({
8991
- size: instanceData.byteLength,
8992
- usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
9703
+ this._device = backend.device;
9704
+ this._shaderModule = this._device.createShaderModule({ code: meshShaderSource });
9705
+ this._uniformBindGroupLayout = this._device.createBindGroupLayout({
9706
+ entries: [{
9707
+ binding: 0,
9708
+ visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
9709
+ buffer: { type: 'uniform', hasDynamicOffset: true },
9710
+ }],
9711
+ });
9712
+ this._textureBindGroupLayout = this._device.createBindGroupLayout({
9713
+ entries: [
9714
+ {
9715
+ binding: 0,
9716
+ visibility: GPUShaderStage.FRAGMENT,
9717
+ texture: { sampleType: 'float' },
9718
+ },
9719
+ {
9720
+ binding: 1,
9721
+ visibility: GPUShaderStage.FRAGMENT,
9722
+ sampler: { type: 'filtering' },
9723
+ },
9724
+ ],
9725
+ });
9726
+ this._pipelineLayout = this._device.createPipelineLayout({
9727
+ bindGroupLayouts: [this._uniformBindGroupLayout, this._textureBindGroupLayout],
8993
9728
  });
8994
- this._instanceBuffer?.destroy();
8995
- this._instanceCapacity = nextCapacity;
8996
- this._instanceData = instanceData;
8997
- this._instanceFloat32 = new Float32Array(instanceData);
8998
- this._instanceUint32 = new Uint32Array(instanceData);
8999
- this._instanceBuffer = instanceBuffer;
9000
9729
  }
9001
- _resetSlots() {
9002
- if (this._slotCount > 0) {
9003
- for (let i = 0; i < this._slotCount; i++) {
9004
- this._activeTextures[i] = null;
9005
- }
9006
- this._textureSlots.clear();
9007
- this._slotCount = 0;
9008
- }
9730
+ onDisconnect() {
9731
+ this.flush();
9732
+ this._vertexBuffer?.destroy();
9733
+ this._indexBuffer?.destroy();
9734
+ this._uniformBuffer?.destroy();
9735
+ this._pipelines.clear();
9736
+ this._textureBindGroups.clear();
9737
+ this._vertexBuffer = null;
9738
+ this._indexBuffer = null;
9739
+ this._uniformBuffer = null;
9740
+ this._uniformBindGroup = null;
9741
+ this._pipelineLayout = null;
9742
+ this._textureBindGroupLayout = null;
9743
+ this._uniformBindGroupLayout = null;
9744
+ this._shaderModule = null;
9745
+ this._device = null;
9746
+ this._backend = null;
9747
+ this._drawCallCount = 0;
9748
+ this._vertexBufferCapacity = 0;
9749
+ this._indexBufferCapacity = 0;
9750
+ this._uniformBufferCapacity = 0;
9009
9751
  }
9010
- _createTextureBindGroup(device, backend) {
9011
- // Slots beyond the active count get the slot-0 texture as a filler so
9012
- // the bind-group layout always sees N valid texture views and samplers.
9013
- // The fragment shader's switch only ever dispatches to the active slot
9014
- // count, so unsampled fillers cost nothing visually.
9015
- const fallbackTexture = this._activeTextures[0] ?? Texture.empty;
9016
- const fallbackBinding = backend.getTextureBinding(fallbackTexture);
9017
- const resolvedBindings = new Array(maxBatchTextures);
9018
- for (let i = 0; i < maxBatchTextures; i++) {
9019
- const texture = this._activeTextures[i] ?? fallbackTexture;
9020
- resolvedBindings[i] = texture === fallbackTexture
9021
- ? fallbackBinding
9022
- : backend.getTextureBinding(texture);
9023
- }
9024
- const entries = [];
9025
- for (let i = 0; i < maxBatchTextures; i++) {
9026
- entries.push({
9027
- binding: i,
9028
- resource: resolvedBindings[i].view,
9029
- });
9030
- }
9031
- for (let i = 0; i < maxBatchTextures; i++) {
9032
- entries.push({
9033
- binding: maxBatchTextures + i,
9034
- resource: resolvedBindings[i].sampler,
9035
- });
9752
+ _writeMeshVertices(backend, mesh, vertexStart) {
9753
+ // Bake (view * globalTransform) into vertex positions on the CPU,
9754
+ // matching the primitive renderer's no-uniforms approach.
9755
+ const matrix = this._combinedTransform
9756
+ .copy(mesh.getGlobalTransform())
9757
+ .combine(backend.view.getTransform());
9758
+ const a = matrix.a;
9759
+ const b = matrix.b;
9760
+ const c = matrix.c;
9761
+ const d = matrix.d;
9762
+ const tx = matrix.x;
9763
+ const ty = matrix.y;
9764
+ const vertices = mesh.vertices;
9765
+ const uvs = mesh.uvs;
9766
+ const colors = mesh.colors;
9767
+ const vertexCount = mesh.vertexCount;
9768
+ for (let i = 0; i < vertexCount; i++) {
9769
+ const sourceIndex = i * 2;
9770
+ const targetIndex = (vertexStart + i) * wordsPerVertex;
9771
+ const px = vertices[sourceIndex];
9772
+ const py = vertices[sourceIndex + 1];
9773
+ this._float32View[targetIndex + 0] = a * px + b * py + tx;
9774
+ this._float32View[targetIndex + 1] = c * px + d * py + ty;
9775
+ this._float32View[targetIndex + 2] = uvs !== null ? uvs[sourceIndex] : 0;
9776
+ this._float32View[targetIndex + 3] = uvs !== null ? uvs[sourceIndex + 1] : 0;
9777
+ this._uint32View[targetIndex + 4] = colors !== null ? colors[i] : 0xFFFFFFFF;
9036
9778
  }
9037
- return device.createBindGroup({
9038
- layout: this._textureBindGroupLayout,
9039
- entries,
9040
- });
9041
9779
  }
9042
- _getPipeline(blendMode, format) {
9043
- const pipelineKey = `${blendMode}:${format}`;
9044
- const existingPipeline = this._pipelines.get(pipelineKey);
9045
- if (existingPipeline) {
9046
- return existingPipeline;
9047
- }
9048
- if (!this._device || !this._shaderModule || !this._pipelineLayout || !this._backend) {
9049
- throw new Error('Renderer has to be connected first!');
9780
+ _getPipeline(key) {
9781
+ const cacheKey = `${key.blendMode}:${key.format}`;
9782
+ let pipeline = this._pipelines.get(cacheKey);
9783
+ if (!pipeline) {
9784
+ pipeline = this._device.createRenderPipeline(this._buildPipelineDescriptor(key.blendMode, key.format));
9785
+ this._pipelines.set(cacheKey, pipeline);
9050
9786
  }
9051
- const pipeline = this._device.createRenderPipeline(this._buildPipelineDescriptor(blendMode, format));
9052
- this._pipelines.set(pipelineKey, pipeline);
9053
9787
  return pipeline;
9054
9788
  }
9055
9789
  _buildPipelineDescriptor(blendMode, format) {
9056
- if (!this._shaderModule || !this._pipelineLayout) {
9057
- throw new Error('Renderer has to be connected first!');
9058
- }
9059
9790
  return {
9060
9791
  layout: this._pipelineLayout,
9061
9792
  vertex: {
9062
9793
  module: this._shaderModule,
9063
9794
  entryPoint: 'vertexMain',
9064
9795
  buffers: [{
9065
- arrayStride: instanceStrideBytes$1,
9066
- stepMode: 'instance',
9067
- attributes: [{
9068
- shaderLocation: 0,
9069
- offset: 0,
9070
- format: 'float32x4',
9071
- }, {
9072
- shaderLocation: 1,
9073
- offset: 16,
9074
- format: 'float32x3',
9075
- }, {
9076
- shaderLocation: 2,
9077
- offset: 28,
9078
- format: 'float32x3',
9079
- }, {
9080
- shaderLocation: 3,
9081
- offset: 40,
9082
- format: 'unorm16x4',
9083
- }, {
9084
- shaderLocation: 4,
9085
- offset: 48,
9086
- format: 'unorm8x4',
9087
- }, {
9088
- shaderLocation: 5,
9089
- offset: 52,
9090
- format: 'uint32',
9091
- }],
9796
+ arrayStride: vertexStrideBytes$1,
9797
+ stepMode: 'vertex',
9798
+ attributes: [
9799
+ { shaderLocation: 0, offset: 0, format: 'float32x2' },
9800
+ { shaderLocation: 1, offset: 8, format: 'float32x2' },
9801
+ { shaderLocation: 2, offset: 16, format: 'unorm8x4' },
9802
+ ],
9092
9803
  }],
9093
9804
  },
9094
9805
  fragment: {
@@ -9102,9 +9813,74 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
9102
9813
  },
9103
9814
  primitive: {
9104
9815
  topology: 'triangle-list',
9816
+ cullMode: 'none',
9105
9817
  },
9106
9818
  };
9107
9819
  }
9820
+ _getTextureBindGroup(backend, texture) {
9821
+ let group = this._textureBindGroups.get(texture);
9822
+ if (!group) {
9823
+ const binding = backend.getTextureBinding(texture);
9824
+ group = this._device.createBindGroup({
9825
+ layout: this._textureBindGroupLayout,
9826
+ entries: [
9827
+ { binding: 0, resource: binding.view },
9828
+ { binding: 1, resource: binding.sampler },
9829
+ ],
9830
+ });
9831
+ this._textureBindGroups.set(texture, group);
9832
+ }
9833
+ return group;
9834
+ }
9835
+ _ensureVertexCapacity(vertexCount) {
9836
+ const requiredBytes = vertexCount * vertexStrideBytes$1;
9837
+ if (requiredBytes > this._vertexData.byteLength) {
9838
+ const byteLength = Math.max(requiredBytes, this._vertexData.byteLength === 0 ? vertexStrideBytes$1 : this._vertexData.byteLength * 2);
9839
+ this._vertexData = new ArrayBuffer(byteLength);
9840
+ this._float32View = new Float32Array(this._vertexData);
9841
+ this._uint32View = new Uint32Array(this._vertexData);
9842
+ }
9843
+ if (requiredBytes > this._vertexBufferCapacity) {
9844
+ this._vertexBuffer?.destroy();
9845
+ this._vertexBufferCapacity = Math.max(requiredBytes, this._vertexBufferCapacity === 0 ? vertexStrideBytes$1 : this._vertexBufferCapacity * 2);
9846
+ this._vertexBuffer = this._device.createBuffer({
9847
+ size: this._vertexBufferCapacity,
9848
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
9849
+ });
9850
+ }
9851
+ }
9852
+ _ensureIndexCapacity(indexCount) {
9853
+ const requiredBytes = indexCount * Uint16Array.BYTES_PER_ELEMENT;
9854
+ if (this._packedIndexData.length < indexCount) {
9855
+ this._packedIndexData = new Uint16Array(Math.max(indexCount, this._packedIndexData.length === 0 ? 1 : this._packedIndexData.length * 2));
9856
+ }
9857
+ if (requiredBytes > this._indexBufferCapacity) {
9858
+ this._indexBuffer?.destroy();
9859
+ this._indexBufferCapacity = Math.max(requiredBytes, this._indexBufferCapacity === 0 ? Uint16Array.BYTES_PER_ELEMENT : this._indexBufferCapacity * 2);
9860
+ this._indexBuffer = this._device.createBuffer({
9861
+ size: this._indexBufferCapacity,
9862
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
9863
+ });
9864
+ }
9865
+ }
9866
+ _ensureUniformCapacity(drawCallCount) {
9867
+ const requiredBytes = drawCallCount * this._uniformAlignment;
9868
+ if (requiredBytes > this._uniformBufferCapacity) {
9869
+ this._uniformBuffer?.destroy();
9870
+ this._uniformBufferCapacity = Math.max(requiredBytes, this._uniformBufferCapacity === 0 ? this._uniformAlignment : this._uniformBufferCapacity * 2);
9871
+ this._uniformBuffer = this._device.createBuffer({
9872
+ size: this._uniformBufferCapacity,
9873
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
9874
+ });
9875
+ this._uniformBindGroup = this._device.createBindGroup({
9876
+ layout: this._uniformBindGroupLayout,
9877
+ entries: [{
9878
+ binding: 0,
9879
+ resource: { buffer: this._uniformBuffer, size: tintByteLength },
9880
+ }],
9881
+ });
9882
+ }
9883
+ }
9108
9884
  }
9109
9885
 
9110
9886
  /// <reference types="@webgpu/types" />
@@ -9844,6 +10620,7 @@ class WebGpuBackend {
9844
10620
  }
9845
10621
  this.rendererRegistry.registerRenderer(DrawableShape, new WebGpuPrimitiveRenderer());
9846
10622
  this.rendererRegistry.registerRenderer(Sprite, new WebGpuSpriteRenderer());
10623
+ this.rendererRegistry.registerRenderer(Mesh, new WebGpuMeshRenderer());
9847
10624
  this.rendererRegistry.registerRenderer(ParticleSystem, new WebGpuParticleRenderer());
9848
10625
  this.resize(width, height);
9849
10626
  }
@@ -18208,5 +18985,5 @@ const createRapierPhysicsWorld = async (options = {}) => {
18208
18985
  return await RapierPhysicsWorld.create(options);
18209
18986
  };
18210
18987
 
18211
- export { AbstractAssetFactory, AbstractMedia, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AnimatedSprite, Application, ApplicationStatus, ArcadeStickGamepadMapping, AudioAnalyser, BinaryFactory, BlendModes, BlurFilter, Bounds, BufferTypes, BufferUsage, BundleLoadError, CacheFirstStrategy, CallbackRenderPass, ChannelOffset, ChannelSize, Circle, CircleGeometry, Clock, CollisionType, Color, ColorAffector, ColorFilter, Container, Drawable, DrawableShape, Ellipse, FactoryRegistry, Filter, Flags, FontFactory, ForceAffector, GameCubeGamepadMapping, Gamepad, GamepadChannel, GamepadControl, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, Geometry, Graphics, ImageFactory, IndexedDbDatabase, IndexedDbStore, Input, InputManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, Keyboard, Line, Loader, Matrix, Music, MusicFactory, NetworkOnlyStrategy, ObservableSize, ObservableVector, Particle, ParticleOptions, ParticleSystem, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, Random, RapierPhysicsBinding, RapierPhysicsWorld, Rectangle, RenderBackendType, RenderNode, RenderTarget, RenderTargetPass, RenderTexture, RendererRegistry, RenderingPrimitives, Sampler, ScaleAffector, ScaleModes, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderUniform, Signal, Size, Sound, SoundFactory, Sprite, SpriteFlags, Spritesheet, SteamControllerGamepadMapping, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, Time, Timer, TorqueAffector, UniversalEmitter, Vector, Video, VideoFactory, View, ViewFlags, VoronoiRegion, VttAsset, VttFactory, WasmFactory, WebGl2Backend, WebGl2ParticleRenderer, WebGl2PrimitiveRenderer, WebGl2RenderBuffer, WebGl2ShaderBlock, WebGl2SpriteRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuParticleRenderer, WebGpuPrimitiveRenderer, WebGpuSpriteRenderer, WrapModes, XboxGamepadMapping, bezierCurveTo, buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRapierPhysicsWorld, createRenderStats, createWebGl2ShaderProgram, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, emptyArrayBuffer, getAudioContext, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, 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, lerp, matchesIds, milliseconds, minutes, noop$1 as noop, normalizeIds, onAudioContextReady, parseGamepadDescriptor, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, removeArrayItems, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign$1 as sign, stopEvent, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, tau, trimRotation, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames };
18988
+ export { AbstractAssetFactory, AbstractMedia, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AnimatedSprite, Application, ApplicationStatus, ArcadeStickGamepadMapping, AudioAnalyser, BinaryFactory, BlendModes, BlurFilter, Bounds, BufferTypes, BufferUsage, BundleLoadError, CacheFirstStrategy, CallbackRenderPass, ChannelOffset, ChannelSize, Circle, CircleGeometry, Clock, CollisionType, Color, ColorAffector, ColorFilter, Container, Drawable, DrawableShape, Ellipse, FactoryRegistry, Filter, Flags, FontFactory, ForceAffector, GameCubeGamepadMapping, Gamepad, GamepadChannel, GamepadControl, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, Geometry, Graphics, ImageFactory, IndexedDbDatabase, IndexedDbStore, Input, InputManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, Keyboard, Line, Loader, Matrix, Mesh, Music, MusicFactory, NetworkOnlyStrategy, ObservableSize, ObservableVector, Particle, ParticleOptions, ParticleSystem, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, Random, RapierPhysicsBinding, RapierPhysicsWorld, Rectangle, RenderBackendType, RenderNode, RenderTarget, RenderTargetPass, RenderTexture, RendererRegistry, RenderingPrimitives, Sampler, ScaleAffector, ScaleModes, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderUniform, Signal, Size, Sound, SoundFactory, Sprite, SpriteFlags, Spritesheet, SteamControllerGamepadMapping, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, Time, Timer, TorqueAffector, UniversalEmitter, Vector, Video, VideoFactory, View, ViewFlags, VoronoiRegion, VttAsset, VttFactory, WasmFactory, WebGl2Backend, WebGl2MeshRenderer, WebGl2ParticleRenderer, WebGl2PrimitiveRenderer, WebGl2RenderBuffer, WebGl2ShaderBlock, WebGl2SpriteRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuMeshRenderer, WebGpuParticleRenderer, WebGpuPrimitiveRenderer, WebGpuSpriteRenderer, WrapModes, XboxGamepadMapping, bezierCurveTo, buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRapierPhysicsWorld, createRenderStats, createWebGl2ShaderProgram, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, emptyArrayBuffer, getAudioContext, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, 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, lerp, matchesIds, milliseconds, minutes, noop$1 as noop, normalizeIds, onAudioContextReady, parseGamepadDescriptor, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, removeArrayItems, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign$1 as sign, stopEvent, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, tau, trimRotation, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames };
18212
18989
  //# sourceMappingURL=exo.esm.js.map