@codexo/exojs 2.1.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +1 -3
  3. package/dist/esm/particles/emitters/ParticleOptions.d.ts +11 -0
  4. package/dist/esm/particles/emitters/ParticleOptions.js +11 -0
  5. package/dist/esm/particles/emitters/ParticleOptions.js.map +1 -1
  6. package/dist/esm/rendering/Container.d.ts +1 -1
  7. package/dist/esm/rendering/Container.js +5 -2
  8. package/dist/esm/rendering/Container.js.map +1 -1
  9. package/dist/esm/rendering/webgl2/WebGl2ShaderRuntime.js +7 -0
  10. package/dist/esm/rendering/webgl2/WebGl2ShaderRuntime.js.map +1 -1
  11. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js +66 -43
  12. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js.map +1 -1
  13. package/dist/esm/rendering/webgpu/WebGpuPrimitiveRenderer.d.ts +2 -6
  14. package/dist/esm/rendering/webgpu/WebGpuPrimitiveRenderer.js +160 -93
  15. package/dist/esm/rendering/webgpu/WebGpuPrimitiveRenderer.js.map +1 -1
  16. package/dist/esm/rendering/webgpu/WebGpuRenderManager.js +50 -39
  17. package/dist/esm/rendering/webgpu/WebGpuRenderManager.js.map +1 -1
  18. package/dist/esm/rendering/webgpu/WebGpuSpriteRenderer.js +75 -32
  19. package/dist/esm/rendering/webgpu/WebGpuSpriteRenderer.js.map +1 -1
  20. package/dist/exo.d.ts +14 -7
  21. package/dist/exo.esm.js +374 -209
  22. package/dist/exo.esm.js.map +1 -1
  23. package/dist/exo.esm.min.js +1 -1
  24. package/dist/exo.esm.min.js.map +1 -1
  25. package/dist/exo.global.js +374 -209
  26. package/dist/exo.global.js.map +1 -1
  27. package/dist/exo.global.min.js +1 -1
  28. package/dist/exo.global.min.js.map +1 -1
  29. package/package.json +3 -4
package/dist/exo.esm.js CHANGED
@@ -5291,6 +5291,13 @@ function createWebGl2ShaderRuntime(gl) {
5291
5291
  gl.useProgram(null);
5292
5292
  },
5293
5293
  sync: () => {
5294
+ // Bind the program before syncing uniforms. WebGl2RenderManager
5295
+ // does not call bindShader() on the active renderer's shader
5296
+ // during normal draw flow, so sync() is the first entry point
5297
+ // that must establish program binding — otherwise uniform*
5298
+ // targets the wrong (or no) program and the subsequent draw
5299
+ // call fails with "no valid shader program in use".
5300
+ gl.useProgram(program);
5294
5301
  syncUniforms();
5295
5302
  },
5296
5303
  destroy: (shader) => {
@@ -7423,15 +7430,8 @@ function getWebGpuBlendState(blendMode) {
7423
7430
 
7424
7431
  /// <reference types="@webgpu/types" />
7425
7432
  const primitiveShaderSource = `
7426
- struct TransformUniforms {
7427
- matrix: mat4x4<f32>,
7428
- };
7429
-
7430
- @group(0) @binding(0)
7431
- var<uniform> uniforms: TransformUniforms;
7432
-
7433
7433
  struct VertexInput {
7434
- @location(0) position: vec2<f32>,
7434
+ @location(0) position: vec4<f32>,
7435
7435
  @location(1) color: vec4<f32>,
7436
7436
  };
7437
7437
 
@@ -7444,7 +7444,7 @@ struct VertexOutput {
7444
7444
  fn vertexMain(input: VertexInput) -> VertexOutput {
7445
7445
  var output: VertexOutput;
7446
7446
 
7447
- output.position = uniforms.matrix * vec4<f32>(input.position, 0.0, 1.0);
7447
+ output.position = input.position;
7448
7448
  output.color = vec4<f32>(input.color.rgb * input.color.a, input.color.a);
7449
7449
 
7450
7450
  return output;
@@ -7455,23 +7455,24 @@ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
7455
7455
  return input.color;
7456
7456
  }
7457
7457
  `;
7458
- const vertexStrideBytes$1 = 12;
7459
- const transformByteLength = 64;
7458
+ // 4 floats (pre-transformed clip-space position) + 1 u32 (color) = 20 bytes.
7459
+ // The CPU applies (view * shape.globalTransform) to each vertex before writing
7460
+ // it into the vertex buffer, so the shader outputs the position as-is. This
7461
+ // matches the sprite renderer's approach and eliminates the need for a per-
7462
+ // drawcall uniform binding.
7463
+ const vertexStrideBytes$1 = 20;
7464
+ const wordsPerVertex$1 = vertexStrideBytes$1 / Uint32Array.BYTES_PER_ELEMENT;
7460
7465
  class WebGpuPrimitiveRenderer extends AbstractWebGpuRenderer {
7461
7466
  constructor() {
7462
7467
  super(...arguments);
7463
7468
  this._combinedTransform = new Matrix();
7464
7469
  this._drawCalls = [];
7465
7470
  this._drawCallCount = 0;
7466
- this._transformData = new Float32Array(transformByteLength / Float32Array.BYTES_PER_ELEMENT);
7467
7471
  this._pipelines = new Map();
7468
7472
  this._renderManager = null;
7469
7473
  this._device = null;
7470
7474
  this._shaderModule = null;
7471
- this._bindGroupLayout = null;
7472
7475
  this._pipelineLayout = null;
7473
- this._uniformBuffer = null;
7474
- this._bindGroup = null;
7475
7476
  this._vertexBuffer = null;
7476
7477
  this._indexBuffer = null;
7477
7478
  this._vertexBufferCapacity = 0;
@@ -7479,6 +7480,7 @@ class WebGpuPrimitiveRenderer extends AbstractWebGpuRenderer {
7479
7480
  this._vertexData = new ArrayBuffer(0);
7480
7481
  this._float32View = new Float32Array(this._vertexData);
7481
7482
  this._uint32View = new Uint32Array(this._vertexData);
7483
+ this._packedIndexData = new Uint16Array(0);
7482
7484
  this._generatedIndexData = new Uint16Array(0);
7483
7485
  this._sequentialIndexData = new Uint16Array(0);
7484
7486
  }
@@ -7516,60 +7518,121 @@ class WebGpuPrimitiveRenderer extends AbstractWebGpuRenderer {
7516
7518
  flush() {
7517
7519
  const runtime = this._renderManager;
7518
7520
  const device = this._device;
7519
- const bindGroup = this._bindGroup;
7520
- const uniformBuffer = this._uniformBuffer;
7521
- if (!runtime || !device || !bindGroup || !uniformBuffer) {
7521
+ if (!runtime || !device) {
7522
7522
  return;
7523
7523
  }
7524
7524
  if (this._drawCallCount === 0 && !runtime.clearRequested) {
7525
7525
  return;
7526
7526
  }
7527
- const encoder = device.createCommandEncoder();
7528
- const pass = encoder.beginRenderPass({
7529
- colorAttachments: [runtime.createColorAttachment()],
7530
- });
7531
- runtime.stats.renderPasses++;
7532
7527
  const scissor = runtime.getScissorRect();
7533
7528
  const maskClipsAll = scissor !== null && (scissor.width <= 0 || scissor.height <= 0);
7534
- if (scissor !== null && !maskClipsAll) {
7535
- pass.setScissorRect(scissor.x, scissor.y, scissor.width, scissor.height);
7536
- }
7537
- if (!maskClipsAll) {
7529
+ // Phase 1: resolve drawcalls and record each one's offsets into the
7530
+ // shared packed buffers. Transform gets baked into the vertex data
7531
+ // during phase 2 so no per-drawcall uniform binding is needed.
7532
+ const plan = [];
7533
+ const resolvedDrawCalls = [];
7534
+ let totalVertices = 0;
7535
+ let totalIndices = 0;
7536
+ if (this._drawCallCount > 0 && !maskClipsAll) {
7538
7537
  for (let drawCallIndex = 0; drawCallIndex < this._drawCallCount; drawCallIndex++) {
7539
7538
  const drawCall = this._drawCalls[drawCallIndex];
7540
7539
  const shape = drawCall.shape;
7541
- const vertices = shape.geometry.vertices;
7542
- const resolvedDrawCall = this._resolveDrawCall(shape);
7543
- if (resolvedDrawCall === null) {
7540
+ const resolved = this._resolveDrawCall(shape);
7541
+ resolvedDrawCalls.push(resolved);
7542
+ if (resolved === null) {
7544
7543
  continue;
7545
7544
  }
7546
7545
  const pipeline = this._getPipeline({
7547
- topology: resolvedDrawCall.topology,
7548
- usesStripIndex: resolvedDrawCall.usesStripIndex,
7546
+ topology: resolved.topology,
7547
+ usesStripIndex: resolved.usesStripIndex,
7549
7548
  blendMode: drawCall.blendMode,
7550
7549
  format: runtime.renderTargetFormat,
7551
7550
  });
7552
- this._ensureVertexCapacity(resolvedDrawCall.vertexCount);
7553
- this._writeVertexData(vertices, shape.color.toRgba());
7554
- this._writeTransformData(runtime, shape);
7555
- device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, resolvedDrawCall.vertexCount * vertexStrideBytes$1);
7556
- device.queue.writeBuffer(uniformBuffer, 0, this._transformData.buffer, this._transformData.byteOffset, this._transformData.byteLength);
7557
- pass.setPipeline(pipeline);
7558
- pass.setBindGroup(0, bindGroup);
7559
- pass.setVertexBuffer(0, this._vertexBuffer);
7560
- if (resolvedDrawCall.indices !== null && resolvedDrawCall.indexCount > 0) {
7561
- this._ensureIndexCapacity(resolvedDrawCall.indexCount);
7562
- device.queue.writeBuffer(this._indexBuffer, 0, resolvedDrawCall.indices.buffer, resolvedDrawCall.indices.byteOffset, resolvedDrawCall.indexCount * Uint16Array.BYTES_PER_ELEMENT);
7563
- pass.setIndexBuffer(this._indexBuffer, 'uint16');
7564
- pass.drawIndexed(resolvedDrawCall.indexCount);
7551
+ plan.push({
7552
+ pipeline,
7553
+ vertexByteOffset: totalVertices * vertexStrideBytes$1,
7554
+ vertexCount: resolved.vertexCount,
7555
+ indexByteOffset: totalIndices * Uint16Array.BYTES_PER_ELEMENT,
7556
+ indexCount: resolved.indexCount,
7557
+ });
7558
+ totalVertices += resolved.vertexCount;
7559
+ totalIndices += resolved.indexCount;
7560
+ }
7561
+ }
7562
+ // If nothing will actually render, still honor a pending clear with
7563
+ // a single empty pass so createColorAttachment consumes the clear
7564
+ // state exactly once.
7565
+ if (plan.length === 0) {
7566
+ if (runtime.clearRequested) {
7567
+ const encoder = device.createCommandEncoder();
7568
+ const pass = encoder.beginRenderPass({
7569
+ colorAttachments: [runtime.createColorAttachment()],
7570
+ });
7571
+ runtime.stats.renderPasses++;
7572
+ pass.end();
7573
+ runtime.submit(encoder.finish());
7574
+ }
7575
+ this._drawCallCount = 0;
7576
+ return;
7577
+ }
7578
+ // Phase 2: size GPU buffers for the whole-frame totals, then pack
7579
+ // every drawcall's CPU-side data. _writeShapeVertices applies
7580
+ // (view * shape.globalTransform) per-vertex so the shader simply
7581
+ // outputs input.position unchanged.
7582
+ this._ensureVertexCapacity(totalVertices);
7583
+ if (totalIndices > 0) {
7584
+ this._ensureIndexCapacity(totalIndices);
7585
+ if (this._packedIndexData.length < totalIndices) {
7586
+ this._packedIndexData = new Uint16Array(Math.max(totalIndices, this._packedIndexData.length === 0 ? 1 : this._packedIndexData.length * 2));
7587
+ }
7588
+ }
7589
+ {
7590
+ let vOffset = 0;
7591
+ let iOffset = 0;
7592
+ for (let i = 0; i < this._drawCallCount; i++) {
7593
+ const resolved = resolvedDrawCalls[i];
7594
+ if (resolved === null) {
7595
+ continue;
7565
7596
  }
7566
- else {
7567
- pass.draw(resolvedDrawCall.vertexCount);
7597
+ const drawCall = this._drawCalls[i];
7598
+ const shape = drawCall.shape;
7599
+ this._writeShapeVertices(runtime, shape, vOffset);
7600
+ if (resolved.indices !== null && resolved.indexCount > 0) {
7601
+ this._packedIndexData.set(resolved.indices.subarray(0, resolved.indexCount), iOffset);
7602
+ iOffset += resolved.indexCount;
7568
7603
  }
7569
- runtime.stats.batches++;
7570
- runtime.stats.drawCalls++;
7604
+ vOffset += resolved.vertexCount;
7571
7605
  }
7572
7606
  }
7607
+ // Phase 3: single writeBuffer per GPU buffer covers the whole frame.
7608
+ device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, totalVertices * vertexStrideBytes$1);
7609
+ if (totalIndices > 0) {
7610
+ device.queue.writeBuffer(this._indexBuffer, 0, this._packedIndexData.buffer, this._packedIndexData.byteOffset, totalIndices * Uint16Array.BYTES_PER_ELEMENT);
7611
+ }
7612
+ // Phase 4: single render pass. Per-draw state is just pipeline and
7613
+ // vertex/index subrange offsets — the transform has already been
7614
+ // baked into the vertex data.
7615
+ const encoder = device.createCommandEncoder();
7616
+ const pass = encoder.beginRenderPass({
7617
+ colorAttachments: [runtime.createColorAttachment()],
7618
+ });
7619
+ runtime.stats.renderPasses++;
7620
+ if (scissor !== null) {
7621
+ pass.setScissorRect(scissor.x, scissor.y, scissor.width, scissor.height);
7622
+ }
7623
+ for (const planned of plan) {
7624
+ pass.setPipeline(planned.pipeline);
7625
+ pass.setVertexBuffer(0, this._vertexBuffer, planned.vertexByteOffset);
7626
+ if (planned.indexCount > 0) {
7627
+ pass.setIndexBuffer(this._indexBuffer, 'uint16', planned.indexByteOffset);
7628
+ pass.drawIndexed(planned.indexCount);
7629
+ }
7630
+ else {
7631
+ pass.draw(planned.vertexCount);
7632
+ }
7633
+ runtime.stats.batches++;
7634
+ runtime.stats.drawCalls++;
7635
+ }
7573
7636
  pass.end();
7574
7637
  runtime.submit(encoder.finish());
7575
7638
  this._drawCallCount = 0;
@@ -7582,65 +7645,76 @@ class WebGpuPrimitiveRenderer extends AbstractWebGpuRenderer {
7582
7645
  this._renderManager = runtime;
7583
7646
  this._device = this._renderManager.device;
7584
7647
  this._shaderModule = this._device.createShaderModule({ code: primitiveShaderSource });
7585
- this._bindGroupLayout = this._device.createBindGroupLayout({
7586
- entries: [{
7587
- binding: 0,
7588
- visibility: GPUShaderStage.VERTEX,
7589
- buffer: {
7590
- type: 'uniform',
7591
- },
7592
- }],
7593
- });
7648
+ // Transform is applied per-vertex on the CPU, so no uniform binding
7649
+ // is needed — the shader outputs input.position directly.
7594
7650
  this._pipelineLayout = this._device.createPipelineLayout({
7595
- bindGroupLayouts: [this._bindGroupLayout],
7596
- });
7597
- this._uniformBuffer = this._device.createBuffer({
7598
- size: transformByteLength,
7599
- usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
7600
- });
7601
- this._bindGroup = this._device.createBindGroup({
7602
- layout: this._bindGroupLayout,
7603
- entries: [{
7604
- binding: 0,
7605
- resource: {
7606
- buffer: this._uniformBuffer,
7607
- },
7608
- }],
7651
+ bindGroupLayouts: [],
7609
7652
  });
7610
7653
  }
7611
7654
  onDisconnect() {
7612
7655
  this.flush();
7613
7656
  this._destroyBuffers();
7614
7657
  this._pipelines.clear();
7615
- this._uniformBuffer?.destroy();
7616
- this._uniformBuffer = null;
7617
- this._bindGroup = null;
7618
- this._bindGroupLayout = null;
7619
7658
  this._pipelineLayout = null;
7620
7659
  this._shaderModule = null;
7621
7660
  this._device = null;
7622
7661
  this._renderManager = null;
7623
7662
  this._drawCallCount = 0;
7624
7663
  }
7625
- _writeTransformData(runtime, shape) {
7664
+ _writeShapeVertices(runtime, shape, vertexStart) {
7665
+ // Matrix.combine is `other * this` (see Matrix.rotate and
7666
+ // SceneNode.getGlobalTransform, both of which chain via
7667
+ // local.combine(parent.global) to yield parent.global * local).
7668
+ //
7669
+ // We need view * global applied to a local vertex, so start with
7670
+ // global and combine with view — that gives
7671
+ // _combinedTransform = view * global.
7626
7672
  const matrix = this._combinedTransform
7627
- .copy(runtime.view.getTransform())
7628
- .combine(shape.getGlobalTransform());
7629
- this._transformData.set([
7630
- matrix.a, matrix.c, 0, 0,
7631
- matrix.b, matrix.d, 0, 0,
7632
- 0, 0, 1, 0,
7633
- matrix.x, matrix.y, 0, matrix.z,
7634
- ]);
7635
- }
7636
- _writeVertexData(vertices, color) {
7673
+ .copy(shape.getGlobalTransform())
7674
+ .combine(runtime.view.getTransform());
7675
+ // Match the original uniform-based WGSL layout exactly.
7676
+ //
7677
+ // The shader packs the Matrix's 9 fields into a 4x4 mat (column-major
7678
+ // in WGSL):
7679
+ // col 0 = [a, c, 0, 0]
7680
+ // col 1 = [b, d, 0, 0]
7681
+ // col 2 = [0, 0, 1, 0]
7682
+ // col 3 = [x, y, 0, z]
7683
+ //
7684
+ // Multiplied by vec4(px, py, 0, 1):
7685
+ // out = col0*px + col1*py + col2*0 + col3*1
7686
+ // out.x = a*px + b*py + x
7687
+ // out.y = c*px + d*py + y
7688
+ // out.z = 0
7689
+ // out.w = z
7690
+ //
7691
+ // The Matrix class represents the affine matrix in the order
7692
+ // [a b x]
7693
+ // [c d y]
7694
+ // [e f z]
7695
+ // so a/b/c/d are rotation+scale (note: b on the TOP row, c on the
7696
+ // LEFT column, not the other way around) and x/y/z the translation /
7697
+ // w component. Matrix.toArray(false) confirms this layout.
7698
+ const a = matrix.a;
7699
+ const b = matrix.b;
7700
+ const c = matrix.c;
7701
+ const d = matrix.d;
7702
+ const tx = matrix.x;
7703
+ const ty = matrix.y;
7704
+ const tw = matrix.z;
7705
+ const color = shape.color.toRgba();
7706
+ const vertices = shape.geometry.vertices;
7637
7707
  const vertexCount = vertices.length / 2;
7638
7708
  for (let i = 0; i < vertexCount; i++) {
7639
7709
  const sourceIndex = i * 2;
7640
- const targetIndex = i * 3;
7641
- this._float32View[targetIndex] = vertices[sourceIndex];
7642
- this._float32View[targetIndex + 1] = vertices[sourceIndex + 1];
7643
- this._uint32View[targetIndex + 2] = color;
7710
+ const targetIndex = (vertexStart + i) * wordsPerVertex$1;
7711
+ const px = vertices[sourceIndex];
7712
+ const py = vertices[sourceIndex + 1];
7713
+ this._float32View[targetIndex + 0] = a * px + b * py + tx;
7714
+ this._float32View[targetIndex + 1] = c * px + d * py + ty;
7715
+ this._float32View[targetIndex + 2] = 0;
7716
+ this._float32View[targetIndex + 3] = tw;
7717
+ this._uint32View[targetIndex + 4] = color;
7644
7718
  }
7645
7719
  }
7646
7720
  _ensureVertexCapacity(vertexCount) {
@@ -7687,10 +7761,10 @@ class WebGpuPrimitiveRenderer extends AbstractWebGpuRenderer {
7687
7761
  attributes: [{
7688
7762
  shaderLocation: 0,
7689
7763
  offset: 0,
7690
- format: 'float32x2',
7764
+ format: 'float32x4',
7691
7765
  }, {
7692
7766
  shaderLocation: 1,
7693
- offset: 8,
7767
+ offset: 16,
7694
7768
  format: 'unorm8x4',
7695
7769
  }],
7696
7770
  }],
@@ -7844,7 +7918,7 @@ const spriteShaderSource = `
7844
7918
  struct ProjectionUniforms {
7845
7919
  matrix: mat4x4<f32>,
7846
7920
  };
7847
-
7921
+
7848
7922
  @group(0) @binding(0)
7849
7923
  var<uniform> projection: ProjectionUniforms;
7850
7924
 
@@ -7881,7 +7955,7 @@ var spriteSampler5: sampler;
7881
7955
  var spriteSampler6: sampler;
7882
7956
  @group(1) @binding(15)
7883
7957
  var spriteSampler7: sampler;
7884
-
7958
+
7885
7959
  struct VertexInput {
7886
7960
  @location(0) position: vec2<f32>,
7887
7961
  @location(1) texcoord: vec2<f32>,
@@ -7889,7 +7963,7 @@ struct VertexInput {
7889
7963
  @location(3) premultiplySample: u32,
7890
7964
  @location(4) textureSlot: u32,
7891
7965
  };
7892
-
7966
+
7893
7967
  struct VertexOutput {
7894
7968
  @builtin(position) position: vec4<f32>,
7895
7969
  @location(0) texcoord: vec2<f32>,
@@ -7897,12 +7971,12 @@ struct VertexOutput {
7897
7971
  @location(2) @interpolate(flat) premultiplySample: u32,
7898
7972
  @location(3) @interpolate(flat) textureSlot: u32,
7899
7973
  };
7900
-
7901
- @vertex
7902
- fn vertexMain(input: VertexInput) -> VertexOutput {
7903
- var output: VertexOutput;
7904
-
7905
- output.position = projection.matrix * vec4<f32>(input.position, 0.0, 1.0);
7974
+
7975
+ @vertex
7976
+ fn vertexMain(input: VertexInput) -> VertexOutput {
7977
+ var output: VertexOutput;
7978
+
7979
+ output.position = projection.matrix * vec4<f32>(input.position, 0.0, 1.0);
7906
7980
  output.texcoord = input.texcoord;
7907
7981
  output.color = vec4(input.color.rgb * input.color.a, input.color.a);
7908
7982
  output.premultiplySample = input.premultiplySample;
@@ -7911,38 +7985,46 @@ fn vertexMain(input: VertexInput) -> VertexOutput {
7911
7985
  return output;
7912
7986
  }
7913
7987
 
7914
- fn sampleTexture(slot: u32, uv: vec2<f32>) -> vec4<f32> {
7988
+ fn sampleTexture(slot: u32, uv: vec2<f32>, ddx: vec2<f32>, ddy: vec2<f32>) -> vec4<f32> {
7915
7989
  switch slot {
7916
7990
  case 0u: {
7917
- return textureSample(spriteTexture0, spriteSampler0, uv);
7991
+ return textureSampleGrad(spriteTexture0, spriteSampler0, uv, ddx, ddy);
7918
7992
  }
7919
7993
  case 1u: {
7920
- return textureSample(spriteTexture1, spriteSampler1, uv);
7994
+ return textureSampleGrad(spriteTexture1, spriteSampler1, uv, ddx, ddy);
7921
7995
  }
7922
7996
  case 2u: {
7923
- return textureSample(spriteTexture2, spriteSampler2, uv);
7997
+ return textureSampleGrad(spriteTexture2, spriteSampler2, uv, ddx, ddy);
7924
7998
  }
7925
7999
  case 3u: {
7926
- return textureSample(spriteTexture3, spriteSampler3, uv);
8000
+ return textureSampleGrad(spriteTexture3, spriteSampler3, uv, ddx, ddy);
7927
8001
  }
7928
8002
  case 4u: {
7929
- return textureSample(spriteTexture4, spriteSampler4, uv);
8003
+ return textureSampleGrad(spriteTexture4, spriteSampler4, uv, ddx, ddy);
7930
8004
  }
7931
8005
  case 5u: {
7932
- return textureSample(spriteTexture5, spriteSampler5, uv);
8006
+ return textureSampleGrad(spriteTexture5, spriteSampler5, uv, ddx, ddy);
7933
8007
  }
7934
8008
  case 6u: {
7935
- return textureSample(spriteTexture6, spriteSampler6, uv);
8009
+ return textureSampleGrad(spriteTexture6, spriteSampler6, uv, ddx, ddy);
7936
8010
  }
7937
8011
  default: {
7938
- return textureSample(spriteTexture7, spriteSampler7, uv);
8012
+ return textureSampleGrad(spriteTexture7, spriteSampler7, uv, ddx, ddy);
7939
8013
  }
7940
8014
  }
7941
8015
  }
7942
8016
 
7943
8017
  @fragment
7944
8018
  fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
7945
- let sample = sampleTexture(input.textureSlot, input.texcoord);
8019
+ // Compute screen-space derivatives in uniform control flow before the
8020
+ // per-slot switch. WGSL requires textureSample (implicit LOD) to run in
8021
+ // uniform control flow, which multi-texture batching breaks because the
8022
+ // slot varies per fragment. textureSampleGrad takes explicit derivatives
8023
+ // and is valid regardless of control-flow uniformity, while preserving
8024
+ // mipmap-correct LOD when sprites use mipmapped textures.
8025
+ let ddx = dpdx(input.texcoord);
8026
+ let ddy = dpdy(input.texcoord);
8027
+ let sample = sampleTexture(input.textureSlot, input.texcoord, ddx, ddy);
7946
8028
  let resolvedSample = select(sample, vec4(sample.rgb * sample.a, sample.a), input.premultiplySample == 1u);
7947
8029
 
7948
8030
  return resolvedSample * input.color;
@@ -8092,6 +8174,44 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
8092
8174
  if (this._drawCallCount === 0 && !renderManager.clearRequested) {
8093
8175
  return;
8094
8176
  }
8177
+ // Grow vertex/index buffers up front for the TOTAL sprite count. Two
8178
+ // reasons this must happen before the render pass begins:
8179
+ // 1. _ensureBatchCapacity destroys old buffers and creates new ones
8180
+ // when capacity grows, so running it after setVertexBuffer /
8181
+ // setIndexBuffer would leave the pass bound to destroyed buffers.
8182
+ // 2. All batches are packed into the vertex buffer at distinct
8183
+ // sprite offsets, so the buffer must hold every sprite in the
8184
+ // flush, not just one batch worth.
8185
+ if (this._drawCallCount > 0) {
8186
+ this._ensureBatchCapacity(this._drawCallCount);
8187
+ }
8188
+ // Walk the batches once, packing each batch's vertex data into the
8189
+ // CPU-side buffer at its own sprite-aligned offset. Each batch's
8190
+ // metadata is recorded for the draw loop below.
8191
+ //
8192
+ // This replaces an earlier per-batch queue.writeBuffer(..., offset: 0)
8193
+ // pattern where every writeBuffer targeted the same GPU offset. All
8194
+ // writeBuffers in a frame execute before queue.submit(commandBuffer),
8195
+ // so only the last batch's vertex data survived — which meant any
8196
+ // flush containing more than one batch rendered every batch using
8197
+ // the LAST batch's vertices (background vanished, sprites duplicated
8198
+ // at wrong sizes, etc. whenever blend mode / texture slot / pipeline
8199
+ // caused a split into multiple batches).
8200
+ const batchPlan = [];
8201
+ let packedSpriteCount = 0;
8202
+ for (let start = 0; start < this._drawCallCount;) {
8203
+ const batch = this._getBatchRange(start);
8204
+ const spriteCount = batch.end - batch.start;
8205
+ this._writeBatchVertexData(batch, packedSpriteCount);
8206
+ batchPlan.push({
8207
+ firstSprite: packedSpriteCount,
8208
+ spriteCount,
8209
+ blendMode: batch.blendMode,
8210
+ textures: batch.textures,
8211
+ });
8212
+ packedSpriteCount += spriteCount;
8213
+ start = batch.end;
8214
+ }
8095
8215
  const viewMatrix = renderManager.view.getTransform();
8096
8216
  this._projectionData.set([
8097
8217
  viewMatrix.a, viewMatrix.c, 0, 0,
@@ -8111,23 +8231,20 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
8111
8231
  pass.setScissorRect(scissor.x, scissor.y, scissor.width, scissor.height);
8112
8232
  }
8113
8233
  if (this._drawCallCount > 0 && !maskClipsAll) {
8234
+ // Single upload for the whole packed vertex buffer — every batch
8235
+ // reads from its own sprite range via drawIndexed's firstIndex.
8236
+ device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, packedSpriteCount * spriteVertexCount * vertexStrideBytes);
8114
8237
  pass.setBindGroup(0, uniformBindGroup);
8115
8238
  pass.setVertexBuffer(0, this._vertexBuffer);
8116
8239
  pass.setIndexBuffer(this._indexBuffer, 'uint32');
8117
- for (let start = 0; start < this._drawCallCount;) {
8118
- const batch = this._getBatchRange(start);
8119
- const pipeline = this._getPipeline(batch.blendMode, renderManager.renderTargetFormat);
8120
- const spriteCount = batch.end - batch.start;
8121
- this._ensureBatchCapacity(spriteCount);
8122
- this._writeBatchVertexData(batch);
8123
- device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, spriteCount * spriteVertexCount * vertexStrideBytes);
8124
- const textureBindGroup = this._createTextureBindGroup(device, renderManager, batch.textures);
8240
+ for (const plan of batchPlan) {
8241
+ const pipeline = this._getPipeline(plan.blendMode, renderManager.renderTargetFormat);
8242
+ const textureBindGroup = this._createTextureBindGroup(device, renderManager, plan.textures);
8125
8243
  pass.setPipeline(pipeline);
8126
8244
  pass.setBindGroup(1, textureBindGroup);
8127
- pass.drawIndexed(batch.spriteCount * spriteIndexCount, 1, 0, 0, 0);
8245
+ pass.drawIndexed(plan.spriteCount * spriteIndexCount, 1, plan.firstSprite * spriteIndexCount, 0, 0);
8128
8246
  renderManager.stats.batches++;
8129
8247
  renderManager.stats.drawCalls++;
8130
- start = batch.end;
8131
8248
  }
8132
8249
  }
8133
8250
  pass.end();
@@ -8152,7 +8269,7 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
8152
8269
  });
8153
8270
  const indexData = new Uint32Array(nextCapacity * spriteIndexCount);
8154
8271
  const indexBuffer = this._device.createBuffer({
8155
- size: indexData.byteLength * Uint32Array.BYTES_PER_ELEMENT,
8272
+ size: indexData.byteLength,
8156
8273
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
8157
8274
  });
8158
8275
  for (let spriteIndex = 0; spriteIndex < nextCapacity; spriteIndex++) {
@@ -8175,12 +8292,12 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
8175
8292
  this._vertexBuffer = vertexBuffer;
8176
8293
  this._indexBuffer = indexBuffer;
8177
8294
  }
8178
- _writeBatchVertexData(batch) {
8295
+ _writeBatchVertexData(batch, firstSprite) {
8179
8296
  const renderManager = this._renderManager;
8180
8297
  if (!renderManager) {
8181
8298
  return;
8182
8299
  }
8183
- let vertexOffset = 0;
8300
+ let vertexOffset = firstSprite * spriteVertexCount * wordsPerVertex;
8184
8301
  for (let drawCallIndex = batch.start; drawCallIndex < batch.end; drawCallIndex++) {
8185
8302
  const drawCall = this._drawCalls[drawCallIndex];
8186
8303
  const textureSlot = batch.textureSlots.get(drawCall.texture) ?? 0;
@@ -8462,54 +8579,77 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
8462
8579
  if (this._drawCallCount === 0 && !runtime.clearRequested) {
8463
8580
  return;
8464
8581
  }
8465
- const encoder = device.createCommandEncoder();
8466
- const pass = encoder.beginRenderPass({
8467
- colorAttachments: [runtime.createColorAttachment()],
8468
- });
8469
- runtime.stats.renderPasses++;
8470
8582
  const scissor = runtime.getScissorRect();
8471
8583
  const maskClipsAll = scissor !== null && (scissor.width <= 0 || scissor.height <= 0);
8472
- if (scissor !== null && !maskClipsAll) {
8473
- pass.setScissorRect(scissor.x, scissor.y, scissor.width, scissor.height);
8474
- }
8475
- if (!maskClipsAll) {
8476
- pass.setBindGroup(0, uniformBindGroup);
8477
- for (let drawCallIndex = 0; drawCallIndex < this._drawCallCount; drawCallIndex++) {
8478
- const drawCall = this._drawCalls[drawCallIndex];
8479
- const system = drawCall.system;
8480
- const particleCount = system.particles.length;
8481
- if (particleCount === 0) {
8482
- continue;
8483
- }
8484
- const pipeline = this._getPipeline(drawCall.blendMode, runtime.renderTargetFormat);
8485
- const textureBinding = runtime.getTextureBinding(drawCall.texture);
8486
- const textureBindGroup = device.createBindGroup({
8487
- layout: this._textureBindGroupLayout,
8488
- entries: [{
8489
- binding: 0,
8490
- resource: textureBinding.view,
8491
- }, {
8492
- binding: 1,
8493
- resource: textureBinding.sampler,
8494
- }],
8584
+ // If no drawcalls will actually render (none queued, or the scissor
8585
+ // clips everything), but a clear is pending, open a single empty
8586
+ // pass so createColorAttachment consumes the clear state.
8587
+ if (this._drawCallCount === 0 || maskClipsAll) {
8588
+ if (runtime.clearRequested) {
8589
+ const encoder = device.createCommandEncoder();
8590
+ const pass = encoder.beginRenderPass({
8591
+ colorAttachments: [runtime.createColorAttachment()],
8495
8592
  });
8496
- this._ensureCapacity(particleCount);
8497
- this._writeInstanceData(system.vertices, system.texCoords, system.particles);
8498
- this._writeUniformData(runtime, system, drawCall.texture);
8499
- device.queue.writeBuffer(this._instanceBuffer, 0, this._instanceData, 0, particleCount * instanceStrideBytes);
8500
- device.queue.writeBuffer(uniformBuffer, 0, this._uniformData.buffer, this._uniformData.byteOffset, this._uniformData.byteLength);
8501
- pass.setPipeline(pipeline);
8502
- pass.setBindGroup(1, textureBindGroup);
8503
- pass.setVertexBuffer(0, staticVertexBuffer);
8504
- pass.setVertexBuffer(1, this._instanceBuffer);
8505
- pass.setIndexBuffer(indexBuffer, 'uint16');
8506
- pass.drawIndexed(indicesPerParticle, particleCount, 0, 0, 0);
8507
- runtime.stats.batches++;
8508
- runtime.stats.drawCalls++;
8593
+ runtime.stats.renderPasses++;
8594
+ pass.end();
8595
+ runtime.submit(encoder.finish());
8509
8596
  }
8597
+ this._drawCallCount = 0;
8598
+ return;
8599
+ }
8600
+ // One command encoder / pass per drawcall. Each particle system's
8601
+ // queue.writeBuffer calls target offset 0 of the instance and uniform
8602
+ // buffers — a single pass with multiple systems would see all
8603
+ // writeBuffers serialize before submit, leaving only the last
8604
+ // system's data in those buffers and making every earlier draw read
8605
+ // the wrong data. Also: _ensureCapacity may destroy and recreate the
8606
+ // instance buffer on growth; keeping one drawcall per pass means
8607
+ // that destroy happens strictly between submits, so no pass holds a
8608
+ // reference to a buffer that has since been destroyed.
8609
+ for (let drawCallIndex = 0; drawCallIndex < this._drawCallCount; drawCallIndex++) {
8610
+ const drawCall = this._drawCalls[drawCallIndex];
8611
+ const system = drawCall.system;
8612
+ const particleCount = system.particles.length;
8613
+ if (particleCount === 0) {
8614
+ continue;
8615
+ }
8616
+ const pipeline = this._getPipeline(drawCall.blendMode, runtime.renderTargetFormat);
8617
+ const textureBinding = runtime.getTextureBinding(drawCall.texture);
8618
+ const textureBindGroup = device.createBindGroup({
8619
+ layout: this._textureBindGroupLayout,
8620
+ entries: [{
8621
+ binding: 0,
8622
+ resource: textureBinding.view,
8623
+ }, {
8624
+ binding: 1,
8625
+ resource: textureBinding.sampler,
8626
+ }],
8627
+ });
8628
+ this._ensureCapacity(particleCount);
8629
+ this._writeInstanceData(system.vertices, system.texCoords, system.particles);
8630
+ this._writeUniformData(runtime, system, drawCall.texture);
8631
+ device.queue.writeBuffer(this._instanceBuffer, 0, this._instanceData, 0, particleCount * instanceStrideBytes);
8632
+ device.queue.writeBuffer(uniformBuffer, 0, this._uniformData.buffer, this._uniformData.byteOffset, this._uniformData.byteLength);
8633
+ const encoder = device.createCommandEncoder();
8634
+ const pass = encoder.beginRenderPass({
8635
+ colorAttachments: [runtime.createColorAttachment()],
8636
+ });
8637
+ runtime.stats.renderPasses++;
8638
+ if (scissor !== null) {
8639
+ pass.setScissorRect(scissor.x, scissor.y, scissor.width, scissor.height);
8640
+ }
8641
+ pass.setBindGroup(0, uniformBindGroup);
8642
+ pass.setPipeline(pipeline);
8643
+ pass.setBindGroup(1, textureBindGroup);
8644
+ pass.setVertexBuffer(0, staticVertexBuffer);
8645
+ pass.setVertexBuffer(1, this._instanceBuffer);
8646
+ pass.setIndexBuffer(indexBuffer, 'uint16');
8647
+ pass.drawIndexed(indicesPerParticle, particleCount, 0, 0, 0);
8648
+ runtime.stats.batches++;
8649
+ runtime.stats.drawCalls++;
8650
+ pass.end();
8651
+ runtime.submit(encoder.finish());
8510
8652
  }
8511
- pass.end();
8512
- runtime.submit(encoder.finish());
8513
8653
  this._drawCallCount = 0;
8514
8654
  }
8515
8655
  destroy() {
@@ -9092,10 +9232,11 @@ class WebGpuRenderManager {
9092
9232
  if (typeof gpuNavigator.gpu.getPreferredCanvasFormat !== 'function') {
9093
9233
  throw new Error('WebGPU is available, but navigator.gpu.getPreferredCanvasFormat is not implemented.');
9094
9234
  }
9095
- const context = this._canvas.getContext('webgpu');
9096
- if (context === null) {
9097
- throw new Error('Could not create WebGPU canvas context.');
9098
- }
9235
+ // Request the adapter before acquiring a WebGPU canvas context.
9236
+ // getContext('webgpu') is exclusive per canvas — once it succeeds, the
9237
+ // same canvas can no longer produce a WebGL2 context. Doing it the
9238
+ // other way round means an unavailable adapter still locks the canvas
9239
+ // and breaks the automatic WebGL2 fallback in Application.
9099
9240
  let adapter = null;
9100
9241
  try {
9101
9242
  adapter = await gpuNavigator.gpu.requestAdapter();
@@ -9106,6 +9247,10 @@ class WebGpuRenderManager {
9106
9247
  if (adapter === null) {
9107
9248
  throw new Error('Could not acquire a WebGPU adapter.');
9108
9249
  }
9250
+ const context = this._canvas.getContext('webgpu');
9251
+ if (context === null) {
9252
+ throw new Error('Could not create WebGPU canvas context.');
9253
+ }
9109
9254
  if (typeof adapter.requestDevice !== 'function') {
9110
9255
  throw new Error('WebGPU adapter does not expose requestDevice().');
9111
9256
  }
@@ -9396,41 +9541,47 @@ class WebGpuRenderManager {
9396
9541
  _getMipmapResources() {
9397
9542
  if (this._mipmapShaderModule === null || this._mipmapBindGroupLayout === null || this._mipmapPipelineLayout === null || this._mipmapPipeline === null || this._mipmapSampler === null) {
9398
9543
  this._mipmapShaderModule = this.device.createShaderModule({
9399
- code: `
9400
- struct VertexOutput {
9401
- @builtin(position) position: vec4<f32>,
9402
- @location(0) texcoord: vec2<f32>,
9403
- };
9404
-
9405
- @group(0) @binding(0)
9406
- var sourceTexture: texture_2d<f32>;
9407
- @group(0) @binding(1)
9408
- var sourceSampler: sampler;
9409
-
9410
- @vertex
9411
- fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
9412
- var positions = array<vec2<f32>, 3>(
9413
- vec2<f32>(-1.0, -1.0),
9414
- vec2<f32>(3.0, -1.0),
9415
- vec2<f32>(-1.0, 3.0)
9416
- );
9417
- var texcoords = array<vec2<f32>, 3>(
9418
- vec2<f32>(0.0, 0.0),
9419
- vec2<f32>(2.0, 0.0),
9420
- vec2<f32>(0.0, 2.0)
9421
- );
9422
- var output: VertexOutput;
9423
-
9424
- output.position = vec4<f32>(positions[vertexIndex], 0.0, 1.0);
9425
- output.texcoord = texcoords[vertexIndex];
9426
-
9427
- return output;
9428
- }
9429
-
9430
- @fragment
9431
- fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
9432
- return textureSample(sourceTexture, sourceSampler, input.texcoord);
9433
- }
9544
+ code: `
9545
+ struct VertexOutput {
9546
+ @builtin(position) position: vec4<f32>,
9547
+ @location(0) texcoord: vec2<f32>,
9548
+ };
9549
+
9550
+ @group(0) @binding(0)
9551
+ var sourceTexture: texture_2d<f32>;
9552
+ @group(0) @binding(1)
9553
+ var sourceSampler: sampler;
9554
+
9555
+ @vertex
9556
+ fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
9557
+ var positions = array<vec2<f32>, 3>(
9558
+ vec2<f32>(-1.0, -1.0),
9559
+ vec2<f32>(3.0, -1.0),
9560
+ vec2<f32>(-1.0, 3.0)
9561
+ );
9562
+ // Y is flipped vs the position array: NDC Y points up, but texture UV
9563
+ // Y points down (UV (0,0) is the top-left of the source). Matching the
9564
+ // two ensures that the output texture's top-left pixel samples from the
9565
+ // source's top-left, so every mip level has the same orientation as the
9566
+ // level above it. Prior to this, odd mip levels were rendered upside
9567
+ // down, producing visible texture flips at view-size doublings.
9568
+ var texcoords = array<vec2<f32>, 3>(
9569
+ vec2<f32>(0.0, 1.0),
9570
+ vec2<f32>(2.0, 1.0),
9571
+ vec2<f32>(0.0, -1.0)
9572
+ );
9573
+ var output: VertexOutput;
9574
+
9575
+ output.position = vec4<f32>(positions[vertexIndex], 0.0, 1.0);
9576
+ output.texcoord = texcoords[vertexIndex];
9577
+
9578
+ return output;
9579
+ }
9580
+
9581
+ @fragment
9582
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
9583
+ return textureSample(sourceTexture, sourceSampler, input.texcoord);
9584
+ }
9434
9585
  `,
9435
9586
  });
9436
9587
  this._mipmapBindGroupLayout = this.device.createBindGroupLayout({
@@ -13037,8 +13188,11 @@ class Container extends RenderNode {
13037
13188
  get bottom() {
13038
13189
  return (this.y + this.height - this.origin.y);
13039
13190
  }
13040
- addChild(child) {
13041
- return this.addChildAt(child, this._children.length);
13191
+ addChild(...children) {
13192
+ for (const child of children) {
13193
+ this.addChildAt(child, this._children.length);
13194
+ }
13195
+ return this;
13042
13196
  }
13043
13197
  addChildAt(child, index) {
13044
13198
  if (index < 0 || index > this._children.length) {
@@ -15155,6 +15309,17 @@ class ParticleOptions {
15155
15309
  set elapsedLifetime(elapsedLifetime) {
15156
15310
  this._elapsedLifetime.copy(elapsedLifetime);
15157
15311
  }
15312
+ /**
15313
+ * Spawn position for particles emitted with these options, expressed in
15314
+ * the owning ParticleSystem's LOCAL coordinate space — the system's own
15315
+ * `getGlobalTransform()` is applied on top during rendering (both the
15316
+ * WebGL2 and WebGPU shaders do `projection * translation * rotated`).
15317
+ *
15318
+ * Setting a world-space value here (e.g. `system.x + offset`) will
15319
+ * double-translate the emitter because the shader will translate again.
15320
+ * For an emitter anchored at the system origin, use small offsets around
15321
+ * `(0, 0)` and position the system itself via `system.setPosition(...)`.
15322
+ */
15158
15323
  get position() {
15159
15324
  return this._position;
15160
15325
  }