@codexo/exojs 0.10.0 → 0.11.0

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 (90) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/dist/esm/core/dev.d.ts +21 -0
  3. package/dist/esm/core/dev.js +18 -0
  4. package/dist/esm/core/dev.js.map +1 -0
  5. package/dist/esm/particles/modules/AlphaFadeOverLifetime.d.ts +2 -2
  6. package/dist/esm/particles/modules/AlphaFadeOverLifetime.js +5 -1
  7. package/dist/esm/particles/modules/AlphaFadeOverLifetime.js.map +1 -1
  8. package/dist/esm/rendering/TransformBuffer.d.ts +44 -0
  9. package/dist/esm/rendering/TransformBuffer.js +64 -0
  10. package/dist/esm/rendering/TransformBuffer.js.map +1 -1
  11. package/dist/esm/rendering/gradient/Gradient.d.ts +35 -2
  12. package/dist/esm/rendering/gradient/Gradient.js +51 -5
  13. package/dist/esm/rendering/gradient/Gradient.js.map +1 -1
  14. package/dist/esm/rendering/gradient/LinearGradient.d.ts +11 -3
  15. package/dist/esm/rendering/gradient/LinearGradient.js +23 -0
  16. package/dist/esm/rendering/gradient/LinearGradient.js.map +1 -1
  17. package/dist/esm/rendering/gradient/RadialGradient.d.ts +11 -3
  18. package/dist/esm/rendering/gradient/RadialGradient.js +19 -0
  19. package/dist/esm/rendering/gradient/RadialGradient.js.map +1 -1
  20. package/dist/esm/rendering/index.d.ts +1 -1
  21. package/dist/esm/rendering/pass/RenderPassCoordinator.d.ts +2 -2
  22. package/dist/esm/rendering/pass/RenderPassDescriptor.d.ts +2 -2
  23. package/dist/esm/rendering/pass/RenderPassDescriptor.js +1 -1
  24. package/dist/esm/rendering/plan/RenderCommand.d.ts +21 -2
  25. package/dist/esm/rendering/plan/RenderCommand.js +34 -1
  26. package/dist/esm/rendering/plan/RenderCommand.js.map +1 -1
  27. package/dist/esm/rendering/plan/RenderInstruction.d.ts +51 -0
  28. package/dist/esm/rendering/plan/RenderInstruction.js +45 -0
  29. package/dist/esm/rendering/plan/RenderInstruction.js.map +1 -0
  30. package/dist/esm/rendering/plan/RenderPlanPlayer.d.ts +4 -0
  31. package/dist/esm/rendering/plan/RenderPlanPlayer.js +58 -7
  32. package/dist/esm/rendering/plan/RenderPlanPlayer.js.map +1 -1
  33. package/dist/esm/rendering/primitives/Graphics.d.ts +70 -5
  34. package/dist/esm/rendering/primitives/Graphics.js +172 -14
  35. package/dist/esm/rendering/primitives/Graphics.js.map +1 -1
  36. package/dist/esm/rendering/sprite/spriteMaterialSources.d.ts +13 -8
  37. package/dist/esm/rendering/sprite/spriteMaterialSources.js +35 -14
  38. package/dist/esm/rendering/sprite/spriteMaterialSources.js.map +1 -1
  39. package/dist/esm/rendering/text/BitmapText.d.ts +2 -0
  40. package/dist/esm/rendering/text/BitmapText.js +8 -1
  41. package/dist/esm/rendering/text/BitmapText.js.map +1 -1
  42. package/dist/esm/rendering/text/BmFont.js +3 -0
  43. package/dist/esm/rendering/text/BmFont.js.map +1 -1
  44. package/dist/esm/rendering/text/GlyphSdf.d.ts +14 -0
  45. package/dist/esm/rendering/text/GlyphSdf.js +41 -11
  46. package/dist/esm/rendering/text/GlyphSdf.js.map +1 -1
  47. package/dist/esm/rendering/text/TextStyle.d.ts +5 -0
  48. package/dist/esm/rendering/text/TextStyle.js +1 -1
  49. package/dist/esm/rendering/text/TextStyle.js.map +1 -1
  50. package/dist/esm/rendering/texture/RenderTexture.js.map +1 -1
  51. package/dist/esm/rendering/webgl2/WebGl2Backend.d.ts +23 -1
  52. package/dist/esm/rendering/webgl2/WebGl2Backend.js +50 -0
  53. package/dist/esm/rendering/webgl2/WebGl2Backend.js.map +1 -1
  54. package/dist/esm/rendering/webgl2/WebGl2MeshRenderer.js +3 -3
  55. package/dist/esm/rendering/webgl2/WebGl2MeshRenderer.js.map +1 -1
  56. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.d.ts +8 -0
  57. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js +8 -0
  58. package/dist/esm/rendering/webgl2/WebGl2ParticleRenderer.js.map +1 -1
  59. package/dist/esm/rendering/webgl2/WebGl2SpriteRenderer.d.ts +2 -0
  60. package/dist/esm/rendering/webgl2/WebGl2SpriteRenderer.js +62 -39
  61. package/dist/esm/rendering/webgl2/WebGl2SpriteRenderer.js.map +1 -1
  62. package/dist/esm/rendering/webgl2/WebGl2TextRenderer.d.ts +7 -0
  63. package/dist/esm/rendering/webgl2/WebGl2TextRenderer.js +7 -0
  64. package/dist/esm/rendering/webgl2/WebGl2TextRenderer.js.map +1 -1
  65. package/dist/esm/rendering/webgl2/glsl/sprite.vert.js +1 -1
  66. package/dist/esm/rendering/webgl2/glsl/text-color.frag.js +1 -1
  67. package/dist/esm/rendering/webgpu/WebGpuBackend.d.ts +16 -3
  68. package/dist/esm/rendering/webgpu/WebGpuBackend.js +49 -4
  69. package/dist/esm/rendering/webgpu/WebGpuBackend.js.map +1 -1
  70. package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.js +53 -41
  71. package/dist/esm/rendering/webgpu/WebGpuMeshRenderer.js.map +1 -1
  72. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.d.ts +7 -0
  73. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js +17 -11
  74. package/dist/esm/rendering/webgpu/WebGpuParticleRenderer.js.map +1 -1
  75. package/dist/esm/rendering/webgpu/WebGpuPassCoordinator.d.ts +2 -2
  76. package/dist/esm/rendering/webgpu/WebGpuPassCoordinator.js +2 -2
  77. package/dist/esm/rendering/webgpu/WebGpuSpriteRenderer.d.ts +9 -1
  78. package/dist/esm/rendering/webgpu/WebGpuSpriteRenderer.js +122 -77
  79. package/dist/esm/rendering/webgpu/WebGpuSpriteRenderer.js.map +1 -1
  80. package/dist/esm/rendering/webgpu/WebGpuTextRenderer.d.ts +7 -0
  81. package/dist/esm/rendering/webgpu/WebGpuTextRenderer.js +22 -13
  82. package/dist/esm/rendering/webgpu/WebGpuTextRenderer.js.map +1 -1
  83. package/dist/esm/rendering/webgpu/WebGpuTransformStorage.d.ts +32 -0
  84. package/dist/esm/rendering/webgpu/WebGpuTransformStorage.js +58 -12
  85. package/dist/esm/rendering/webgpu/WebGpuTransformStorage.js.map +1 -1
  86. package/dist/esm/resources/Loader.js +1 -1
  87. package/dist/esm/resources/Loader.js.map +1 -1
  88. package/dist/exo.esm.js +1022 -265
  89. package/dist/exo.esm.js.map +1 -1
  90. package/package.json +9 -5
package/dist/exo.esm.js CHANGED
@@ -11828,6 +11828,39 @@ const makeMaterialKey = (drawable, backend) => {
11828
11828
  bindKey,
11829
11829
  };
11830
11830
  };
11831
+ /**
11832
+ * Whether a draw command's renderer reads the shared {@link TransformBuffer} /
11833
+ * transform storage. The render-group upload boundary packs each command's
11834
+ * world transform (+ tint) keyed by its `nodeIndex`; only renderers that fetch
11835
+ * those rows back from the buffer need a record written.
11836
+ *
11837
+ * Sprite and Mesh (and their subclasses — {@link AnimatedSprite}, Video,
11838
+ * Graphics' meshes) fetch the transform via `nodeIndex` and therefore consume
11839
+ * it. Text/BitmapText and particle renderers pack their own per-node data into
11840
+ * a private data texture / uniforms and never touch the shared buffer, so they
11841
+ * opt out via `_consumesSharedTransform === false` and their writes are skipped.
11842
+ *
11843
+ * Anything else — a custom renderer, or a drawable with no registered renderer
11844
+ * (resolve throws) — defaults to writing, so behaviour is unchanged for any
11845
+ * path that might still rely on the shared transform.
11846
+ *
11847
+ * @internal
11848
+ */
11849
+ const drawCommandUsesSharedTransform = (command, backend) => {
11850
+ const registry = backend.rendererRegistry;
11851
+ if (!registry || typeof registry.resolve !== 'function') {
11852
+ return true;
11853
+ }
11854
+ try {
11855
+ const renderer = registry.resolve(command.drawable);
11856
+ return renderer._consumesSharedTransform !== false;
11857
+ }
11858
+ catch {
11859
+ // No renderer registered for a custom drawable: keep the conservative
11860
+ // write so any consumer of the shared transform keeps working.
11861
+ return true;
11862
+ }
11863
+ };
11831
11864
 
11832
11865
  /** @internal */
11833
11866
  class MutableRenderPlan {
@@ -12264,6 +12297,22 @@ class RenderPlanOptimizer {
12264
12297
  }
12265
12298
  }
12266
12299
 
12300
+ // Internal dev/diagnostic utilities. Guards are stripped in production builds
12301
+ // when false is replaced with `false` by the rollup replace plugin.
12302
+ /**
12303
+ * Assert `condition` at dev/test time. Throws with `[ExoJS] message` when the
12304
+ * condition is falsy and `false` is true. No-op in production builds.
12305
+ */
12306
+ function assert(condition, message) {
12307
+ }
12308
+ /**
12309
+ * Print a `console.warn` at most once per unique `key`.
12310
+ * Subsequent calls with the same key are silenced to avoid per-frame spam.
12311
+ * No-op in production builds.
12312
+ */
12313
+ function warnOnce(key, message) {
12314
+ }
12315
+
12267
12316
  /**
12268
12317
  * A {@link Size} subclass that fires a callback whenever `width` or `height`
12269
12318
  * changes. Used internally by layout-aware types to invalidate cached geometry
@@ -13547,6 +13596,47 @@ class RenderEffectExecutor {
13547
13596
  }
13548
13597
  }
13549
13598
 
13599
+ /**
13600
+ * Materialize the {@link RenderGroup} batch units contained directly in
13601
+ * `scope`. Consecutive draw instructions that share a defined `groupIndex`
13602
+ * coalesce into one group; any non-draw entry (a nested group or barrier)
13603
+ * breaks the run, and a draw whose `groupIndex` is still `undefined` (i.e.
13604
+ * the plan has not been optimized) forms its own singleton group — mirroring
13605
+ * the adjacency semantics the mesh renderers already rely on.
13606
+ *
13607
+ * This is a read-only view over an (optimized) scope; it does not mutate the
13608
+ * plan or affect playback order.
13609
+ *
13610
+ * @internal
13611
+ */
13612
+ function collectRenderGroups(scope) {
13613
+ const groups = [];
13614
+ let current = null;
13615
+ for (const entry of scope.entries) {
13616
+ if (entry.kind !== RenderEntryKind.Draw) {
13617
+ current = null;
13618
+ continue;
13619
+ }
13620
+ const command = entry.command;
13621
+ const groupIndex = command.groupIndex;
13622
+ if (current !== null && groupIndex !== undefined && groupIndex === current.groupIndex) {
13623
+ current.instructions.push(command);
13624
+ continue;
13625
+ }
13626
+ current = {
13627
+ groupIndex: groupIndex ?? 0,
13628
+ material: command.material,
13629
+ instructions: [command],
13630
+ };
13631
+ groups.push(current);
13632
+ if (groupIndex === undefined) {
13633
+ // Unoptimized / non-batchable draw: never coalesce with the next one.
13634
+ current = null;
13635
+ }
13636
+ }
13637
+ return groups;
13638
+ }
13639
+
13550
13640
  /** @internal */
13551
13641
  class RenderPlanPlayer {
13552
13642
  static play(plan, backend) {
@@ -13563,7 +13653,7 @@ class RenderPlanPlayer {
13563
13653
  if (pass.clearColor !== null) {
13564
13654
  backend.clear(pass.clearColor);
13565
13655
  }
13566
- this.playScope(pass.root, backend);
13656
+ this._playScope(pass.root, backend, hooks, this._createPlaybackContext());
13567
13657
  }
13568
13658
  }
13569
13659
  finally {
@@ -13571,31 +13661,81 @@ class RenderPlanPlayer {
13571
13661
  }
13572
13662
  }
13573
13663
  static playScope(scope, backend) {
13664
+ const hooks = backend;
13665
+ this._playScope(scope, backend, hooks, this._createPlaybackContext());
13666
+ }
13667
+ static _playScope(scope, backend, hooks, context) {
13574
13668
  if (scope.kind === RenderEntryKind.Barrier) {
13575
13669
  RenderEffectExecutor.play(scope, backend, childScope => {
13576
- this.playScope(childScope, backend);
13670
+ this._playScope(childScope, backend, hooks, context);
13577
13671
  });
13578
13672
  return;
13579
13673
  }
13580
- this._playGroup(scope, backend);
13674
+ this._playGroup(scope, backend, hooks, context);
13581
13675
  }
13582
- static _playGroup(scope, backend) {
13583
- const hooks = backend;
13676
+ static _playGroup(scope, backend, hooks, context) {
13677
+ const groups = collectRenderGroups(scope);
13678
+ let groupCursor = 0;
13679
+ let currentGroup = null;
13680
+ let currentInstructionIndex = 0;
13584
13681
  for (const entry of scope.entries) {
13585
13682
  if (entry.kind === RenderEntryKind.Draw) {
13683
+ if (currentGroup === null) {
13684
+ currentGroup = groups[groupCursor];
13685
+ currentInstructionIndex = 0;
13686
+ hooks._beginRenderGroup?.(currentGroup);
13687
+ hooks._prepareRenderGroupUpload?.(currentGroup, this._createRenderGroupPlaybackContext(currentGroup.instructions.length, context.passInstructionIndex, context.passGroupIndex));
13688
+ context.passGroupIndex++;
13689
+ }
13690
+ // Allocate the per-draw instruction slot only when a backend consumes
13691
+ // it. No shipped backend implements `_prepareRenderInstructionSlot`, so
13692
+ // skipping the `Object.freeze` slot allocation removes per-draw garbage
13693
+ // from the playback hot path while preserving the extension point.
13694
+ if (hooks._prepareRenderInstructionSlot !== undefined) {
13695
+ const slot = this._createRenderInstructionSlot(currentInstructionIndex, context.passInstructionIndex);
13696
+ hooks._prepareRenderInstructionSlot(entry.command, slot);
13697
+ }
13586
13698
  hooks._prepareDrawCommand?.(entry.command);
13587
13699
  backend.draw(entry.command.drawable);
13700
+ currentInstructionIndex++;
13701
+ context.passInstructionIndex++;
13702
+ if (currentGroup !== null && currentInstructionIndex === currentGroup.instructions.length) {
13703
+ hooks._endRenderGroup?.(currentGroup);
13704
+ currentGroup = null;
13705
+ currentInstructionIndex = 0;
13706
+ groupCursor++;
13707
+ }
13588
13708
  }
13589
13709
  else if (entry.kind === RenderEntryKind.Group) {
13590
- this._playGroup(entry.scope, backend);
13710
+ this._playGroup(entry.scope, backend, hooks, context);
13591
13711
  }
13592
13712
  else {
13593
13713
  RenderEffectExecutor.play(entry.scope, backend, childScope => {
13594
- this.playScope(childScope, backend);
13714
+ this._playScope(childScope, backend, hooks, context);
13595
13715
  });
13596
13716
  }
13597
13717
  }
13598
13718
  }
13719
+ static _createPlaybackContext() {
13720
+ return {
13721
+ passInstructionIndex: 0,
13722
+ passGroupIndex: 0,
13723
+ };
13724
+ }
13725
+ static _createRenderGroupPlaybackContext(groupInstructionCount, firstPassInstructionIndex, passGroupIndex) {
13726
+ return Object.freeze({
13727
+ groupInstructionCount,
13728
+ firstPassInstructionIndex,
13729
+ lastPassInstructionIndex: firstPassInstructionIndex + groupInstructionCount - 1,
13730
+ passGroupIndex,
13731
+ });
13732
+ }
13733
+ static _createRenderInstructionSlot(groupInstructionIndex, passInstructionIndex) {
13734
+ return Object.freeze({
13735
+ groupInstructionIndex,
13736
+ passInstructionIndex,
13737
+ });
13738
+ }
13599
13739
  }
13600
13740
 
13601
13741
  /** @internal — single source of truth for plan build→optimize→play. */
@@ -13615,7 +13755,7 @@ function playRenderTree(node, backend) {
13615
13755
  * Whether a render pass carries a stencil attachment.
13616
13756
  *
13617
13757
  * WebGL2 stencil is ambient per-target GL state, so this flag is largely
13618
- * informational there; the WebGPU backend (from phase 12D onwards) uses it to
13758
+ * informational there; the WebGPU backend uses it to
13619
13759
  * decide whether a pass descriptor needs a `depthStencilAttachment`.
13620
13760
  * @internal
13621
13761
  */
@@ -16654,6 +16794,7 @@ class BmFont {
16654
16794
  fontData;
16655
16795
  textures;
16656
16796
  constructor(fontData, textures) {
16797
+ assert(textures.length === fontData.pages.length, `BmFont: texture count (${textures.length}) must match page count (${fontData.pages.length})`);
16657
16798
  this.fontData = fontData;
16658
16799
  this.textures = textures;
16659
16800
  }
@@ -16975,7 +17116,7 @@ class TextStyle {
16975
17116
  constructor(options = {}) {
16976
17117
  const explicitFace = typeof FontFace !== 'undefined' && options.font instanceof FontFace ? options.font : null;
16977
17118
  this._fontFamily = explicitFace ? explicitFace.family : (options.fontFamily ?? 'Arial');
16978
- this._fontWeight = options.fontWeight ?? 'bold';
17119
+ this._fontWeight = options.fontWeight ?? 'normal';
16979
17120
  this._fontStyle = options.fontStyle ?? 'normal';
16980
17121
  this._fontSize = options.fontSize ?? 20;
16981
17122
  this._fillColor = options.fillColor ? options.fillColor.clone() : Color.white.clone();
@@ -17256,11 +17397,14 @@ class BmFontAdapter {
17256
17397
  _scale;
17257
17398
  /** Fallback advance for characters not present in the font (≈ ½ line height). */
17258
17399
  _fallbackAdvance;
17400
+ /** Identifier used as part of the warnOnce key — derived from the first page filename. */
17401
+ _fontId;
17259
17402
  constructor(fontData, textures, scale) {
17260
17403
  this._fontData = fontData;
17261
17404
  this._textures = textures;
17262
17405
  this._scale = scale;
17263
17406
  this._fallbackAdvance = fontData.lineHeight * scale * 0.5;
17407
+ this._fontId = fontData.pages[0] ?? 'unknown';
17264
17408
  }
17265
17409
  getGlyph(char, _fontSize) {
17266
17410
  const cp = char.codePointAt(0) ?? 0;
@@ -17269,7 +17413,9 @@ class BmFontAdapter {
17269
17413
  const lh = this._fontData.lineHeight;
17270
17414
  const base = this._fontData.base;
17271
17415
  if (g === undefined) {
17272
- // Unknown glyph — return an invisible placeholder with a cursor advance.
17416
+ // Unknown glyph — warn once per font + codepoint, then return an invisible
17417
+ // placeholder with a cursor advance so layout still makes progress.
17418
+ warnOnce(`bitmaptext:${this._fontId}:${cp}`, `BitmapText: missing glyph U+${cp.toString(16).toUpperCase().padStart(4, '0')} ('${char}') in "${this._fontId}"`);
17273
17419
  return {
17274
17420
  x: 0,
17275
17421
  y: 0,
@@ -17284,6 +17430,7 @@ class BmFontAdapter {
17284
17430
  uvBottom: 0,
17285
17431
  };
17286
17432
  }
17433
+ assert(g.page < this._textures.length, `BitmapText: glyph page index ${g.page} is out of range — font "${this._fontId}" has ${this._textures.length} page(s)`);
17287
17434
  const texW = this._textures[g.page]?.width ?? 1;
17288
17435
  const texH = this._textures[g.page]?.height ?? 1;
17289
17436
  return {
@@ -17655,6 +17802,14 @@ class GlyphSdf {
17655
17802
  _radius;
17656
17803
  _cutoff;
17657
17804
  _font;
17805
+ _fontSize;
17806
+ // Font-level ascent/descent — measured once from a reference string so that
17807
+ // all glyph tiles share the same height and baseline position. This avoids
17808
+ // the per-glyph actualBoundingBoxAscent variation that causes baseline jumps
17809
+ // when fontBoundingBoxAscent is unavailable (common for loaded web fonts).
17810
+ _fontAscent = 0;
17811
+ _fontDescent = 0;
17812
+ _metricsReady = false;
17658
17813
  _canvasW = 0;
17659
17814
  _canvasH = 0;
17660
17815
  _canvas;
@@ -17671,18 +17826,19 @@ class GlyphSdf {
17671
17826
  this._buffer = options.buffer ?? 8;
17672
17827
  this._radius = options.radius ?? this._buffer;
17673
17828
  this._cutoff = options.cutoff ?? 0.5;
17829
+ this._fontSize = options.fontSize;
17674
17830
  const stylePart = options.fontStyle && options.fontStyle !== 'normal' ? `${options.fontStyle} ` : '';
17675
17831
  const weight = options.fontWeight ?? 'normal';
17676
17832
  this._font = `${stylePart}${weight} ${options.fontSize}px ${options.fontFamily}`;
17677
17833
  if (typeof OffscreenCanvas !== 'undefined') {
17678
17834
  const c = new OffscreenCanvas(1, 1);
17679
17835
  this._canvas = c;
17680
- this._ctx = c.getContext('2d');
17836
+ this._ctx = c.getContext('2d', { willReadFrequently: true });
17681
17837
  }
17682
17838
  else {
17683
17839
  const c = document.createElement('canvas');
17684
17840
  this._canvas = c;
17685
- this._ctx = c.getContext('2d');
17841
+ this._ctx = c.getContext('2d', { willReadFrequently: true });
17686
17842
  }
17687
17843
  }
17688
17844
  /**
@@ -17692,7 +17848,28 @@ class GlyphSdf {
17692
17848
  * fresh `Uint8ClampedArray`; the caller may hold a reference to it safely
17693
17849
  * across subsequent `draw()` calls.
17694
17850
  */
17851
+ /**
17852
+ * Ensure font-level ascent/descent are measured. Called once per GlyphSdf
17853
+ * instance — deferred to the first draw() so the canvas context is ready.
17854
+ * Uses `fontBoundingBoxAscent/Descent` when available; falls back to
17855
+ * `actualBoundingBoxAscent/Descent` measured from a reference string that
17856
+ * covers ascenders ('H'), descenders ('g'), and diacritics ('É').
17857
+ * Either way the same values are used for EVERY glyph so all tiles share a
17858
+ * consistent height and a consistent baseline position within the tile.
17859
+ */
17860
+ _ensureFontMetrics() {
17861
+ if (this._metricsReady)
17862
+ return;
17863
+ const ctx = this._ctx;
17864
+ ctx.font = this._font;
17865
+ ctx.textBaseline = 'alphabetic';
17866
+ const m = ctx.measureText('HgjpqyÉÅ');
17867
+ this._fontAscent = Math.max(1, Math.ceil(m.fontBoundingBoxAscent ?? m.actualBoundingBoxAscent ?? this._fontSize * 0.8));
17868
+ this._fontDescent = Math.max(1, Math.ceil(m.fontBoundingBoxDescent ?? m.actualBoundingBoxDescent ?? this._fontSize * 0.2));
17869
+ this._metricsReady = true;
17870
+ }
17695
17871
  draw(char) {
17872
+ this._ensureFontMetrics();
17696
17873
  const ctx = this._ctx;
17697
17874
  const buf = this._buffer;
17698
17875
  // ── Measure ──────────────────────────────────────────────────────────
@@ -17700,20 +17877,20 @@ class GlyphSdf {
17700
17877
  ctx.textBaseline = 'alphabetic';
17701
17878
  const m = ctx.measureText(char);
17702
17879
  const advance = m.width;
17880
+ // Width uses per-glyph actual bounds — each glyph has a different width.
17703
17881
  // Canvas 2D `actualBoundingBoxLeft` is the distance from the text's
17704
17882
  // left-alignment point going LEFT to the left edge of the glyph bounding
17705
17883
  // box. For most LTR characters this is 0; italic fonts may have a small
17706
17884
  // positive value (left overhang).
17707
17885
  const bbLeft = Math.max(0, Math.ceil(m.actualBoundingBoxLeft ?? 0));
17708
17886
  const bbRight = Math.max(0, Math.ceil(m.actualBoundingBoxRight ?? advance));
17709
- const bbAscent = Math.max(0, Math.ceil(m.fontBoundingBoxAscent ??
17710
- m.actualBoundingBoxAscent ??
17711
- 0));
17712
- const bbDescent = Math.max(0, Math.ceil(m.fontBoundingBoxDescent ??
17713
- m.actualBoundingBoxDescent ??
17714
- 0));
17715
17887
  const glyphWidth = Math.max(1, bbLeft + bbRight);
17716
- const glyphHeight = Math.max(1, bbAscent + bbDescent);
17888
+ // Height uses font-level metrics same for every glyph of this font/size
17889
+ // so that all tiles are the same height and the baseline is always at
17890
+ // buf + _fontAscent from the tile top. If per-glyph actualBoundingBoxAscent
17891
+ // were used here instead, different glyphs would have their baselines at
17892
+ // different positions in world space (the visual baseline-jump bug).
17893
+ const glyphHeight = this._fontAscent + this._fontDescent;
17717
17894
  const tileW = glyphWidth + 2 * buf;
17718
17895
  const tileH = glyphHeight + 2 * buf;
17719
17896
  // ── Resize canvas if the tile dimensions changed ───────────────────────
@@ -17726,8 +17903,8 @@ class GlyphSdf {
17726
17903
  // ── Rasterize white glyph on transparent background ───────────────────
17727
17904
  ctx.clearRect(0, 0, tileW, tileH);
17728
17905
  ctx.fillStyle = '#ffffff';
17729
- // Position so glyph's left edge lands at x = buf and baseline at y = buf + bbAscent.
17730
- ctx.fillText(char, buf + bbLeft, buf + bbAscent);
17906
+ // Baseline at buf + _fontAscent consistent across all glyphs.
17907
+ ctx.fillText(char, buf + bbLeft, buf + this._fontAscent);
17731
17908
  const rgba = ctx.getImageData(0, 0, tileW, tileH).data;
17732
17909
  const n = tileW * tileH;
17733
17910
  // ── Grow working arrays lazily ────────────────────────────────────────
@@ -18814,9 +18991,48 @@ class TransformBuffer {
18814
18991
  _frameHash = hashOffset >>> 0;
18815
18992
  _lastCommittedHash = 0;
18816
18993
  _lastCommittedCount = -1;
18994
+ _writeCount = 0;
18995
+ _skippedWriteCount = 0;
18996
+ _uploadCount = 0;
18997
+ _uploadedRecordCount = 0;
18817
18998
  get count() {
18818
18999
  return this._count;
18819
19000
  }
19001
+ /**
19002
+ * Number of transform rows written into the buffer (via {@link write} /
19003
+ * {@link push}) since the last {@link begin}. Internal stat for profiling and
19004
+ * regression guards; does not affect packing.
19005
+ * @internal
19006
+ */
19007
+ get writeCount() {
19008
+ return this._writeCount;
19009
+ }
19010
+ /**
19011
+ * Number of draw commands whose transform write was skipped since the last
19012
+ * {@link begin} — recorded by the backend for renderers that opt out of the
19013
+ * shared transform storage (`_consumesSharedTransform === false`).
19014
+ * @internal
19015
+ */
19016
+ get skippedWriteCount() {
19017
+ return this._skippedWriteCount;
19018
+ }
19019
+ /**
19020
+ * Number of GPU uploads (texture / storage writes) issued for this buffer
19021
+ * since the last {@link begin}. Recorded by the backend at its upload
19022
+ * boundary; an unchanged frame uploads zero times.
19023
+ * @internal
19024
+ */
19025
+ get uploadCount() {
19026
+ return this._uploadCount;
19027
+ }
19028
+ /**
19029
+ * Total transform rows pushed to the GPU across all uploads since the last
19030
+ * {@link begin}.
19031
+ * @internal
19032
+ */
19033
+ get uploadedRecordCount() {
19034
+ return this._uploadedRecordCount;
19035
+ }
18820
19036
  get capacity() {
18821
19037
  return this._data.length / floatsPerSlot;
18822
19038
  }
@@ -18832,6 +19048,10 @@ class TransformBuffer {
18832
19048
  }
18833
19049
  this._count = 0;
18834
19050
  this._frameHash = hashOffset >>> 0;
19051
+ this._writeCount = 0;
19052
+ this._skippedWriteCount = 0;
19053
+ this._uploadCount = 0;
19054
+ this._uploadedRecordCount = 0;
18835
19055
  return this;
18836
19056
  }
18837
19057
  push(transform, tint) {
@@ -18865,6 +19085,27 @@ class TransformBuffer {
18865
19085
  for (let i = 0; i < floatsPerSlot; i++) {
18866
19086
  this._frameHash = this._mix(this._frameHash, this._hashFloat(data[offset + i]));
18867
19087
  }
19088
+ this._writeCount++;
19089
+ return this;
19090
+ }
19091
+ /**
19092
+ * Record that a draw command's transform write was intentionally skipped
19093
+ * because its renderer opts out of the shared transform storage. Counts
19094
+ * toward {@link skippedWriteCount} only — buffer contents are untouched.
19095
+ * @internal
19096
+ */
19097
+ recordSkippedWrite() {
19098
+ this._skippedWriteCount++;
19099
+ return this;
19100
+ }
19101
+ /**
19102
+ * Record a GPU upload of `recordCount` transform rows. Called by the backend
19103
+ * at its upload boundary after committing a snapshot; affects stats only.
19104
+ * @internal
19105
+ */
19106
+ recordUpload(recordCount) {
19107
+ this._uploadCount++;
19108
+ this._uploadedRecordCount += recordCount;
18868
19109
  return this;
18869
19110
  }
18870
19111
  commitSnapshot(minCount = 0) {
@@ -19809,16 +20050,16 @@ const initialIndexCapacity$2 = 192;
19809
20050
  const initialNodeIndexCapacity = 64;
19810
20051
  const defaultVertexColor = 0xffffffff; // white, full alpha
19811
20052
  const maxCustomTextureSlots$2 = 8;
19812
- const transformTextureUnit = 8;
20053
+ const transformTextureUnit$1 = 8;
19813
20054
  class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
19814
20055
  _defaultShader = new Shader(vertexSource$3, fragmentSource$3);
19815
20056
  _customShaders = new Map();
19816
20057
  _compatibilityCache = new Map();
19817
20058
  _textureUnitScratch = new Int32Array([0]);
19818
- _transformUnitScratch = new Int32Array([transformTextureUnit]);
20059
+ _transformUnitScratch = new Int32Array([transformTextureUnit$1]);
19819
20060
  // Pre-built texture-unit indices used for custom-shader sampler bindings;
19820
20061
  // pre-allocated so the per-frame uniform path stays allocation-free.
19821
- _slotScratches = Array.from({ length: Math.max(transformTextureUnit + 1, maxCustomTextureSlots$2 + 1) }, (_, i) => new Int32Array([i]));
20062
+ _slotScratches = Array.from({ length: Math.max(transformTextureUnit$1 + 1, maxCustomTextureSlots$2 + 1) }, (_, i) => new Int32Array([i]));
19822
20063
  _vertexCapacity = initialVertexCapacity$2;
19823
20064
  _indexCapacity = initialIndexCapacity$2;
19824
20065
  _nodeIndexCapacity = initialNodeIndexCapacity;
@@ -19972,9 +20213,9 @@ class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
19972
20213
  this._packIndices(draw.mesh, 0);
19973
20214
  const nodeIndex = draw.command?.nodeIndex ?? 0;
19974
20215
  if (draw.command === null) {
19975
- backend._prepareDrawCommand({
19976
- ...this._createSyntheticCommand(draw.mesh, nodeIndex),
19977
- });
20216
+ // The synthetic, non-plan path does not arrive through a render-group
20217
+ // upload boundary, so write its transform into the shared buffer directly.
20218
+ backend._writeTransformCommand(this._createSyntheticCommand(draw.mesh, nodeIndex));
19978
20219
  }
19979
20220
  this._nodeIndexData[0] = nodeIndex >>> 0;
19980
20221
  backend.bindVertexArrayObject(connection.dynamicVao);
@@ -20045,7 +20286,7 @@ class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
20045
20286
  shader.getUniform('u_projection').setValue(backend.view.getTransform().toArray(false));
20046
20287
  }
20047
20288
  if (shader.uniforms.has('u_transforms')) {
20048
- backend.bindTransformBufferTexture(transformTextureUnit, maxNodeIndex + 1);
20289
+ backend.bindTransformBufferTexture(transformTextureUnit$1, maxNodeIndex + 1);
20049
20290
  shader.getUniform('u_transforms').setValue(this._transformUnitScratch);
20050
20291
  }
20051
20292
  if (shader.uniforms.has('u_texture')) {
@@ -20429,6 +20670,14 @@ const wordsPerInstance$2 = instanceStrideBytes$3 / Uint32Array.BYTES_PER_ELEMENT
20429
20670
  const indicesPerQuad = 6;
20430
20671
  const quadIndices$1 = new Uint16Array([0, 1, 2, 0, 2, 3]);
20431
20672
  class WebGl2ParticleRenderer extends AbstractWebGl2Renderer {
20673
+ /**
20674
+ * The particle system's transform is bound as a `u_systemTransform` uniform and
20675
+ * each particle is positioned system-locally, so this renderer never reads the
20676
+ * shared {@link TransformBuffer}; the render-group upload boundary skips writing
20677
+ * transform records for particle draws.
20678
+ * @internal
20679
+ */
20680
+ _consumesSharedTransform = false;
20432
20681
  _shader;
20433
20682
  _batchSize;
20434
20683
  _instanceData;
@@ -20796,10 +21045,15 @@ class WebGl2PassCoordinator {
20796
21045
  * `WebGl2SpriteRenderer` pairs {@link spriteVertexGlsl} with the material's
20797
21046
  * fragment (ignoring any author-supplied `glsl.vertex`), and
20798
21047
  * `WebGpuSpriteRenderer` prepends {@link spriteVertexWgsl} to the material's
20799
- * fragment WGSL. Both pin the per-instance attribute locations 0–5 and the
21048
+ * fragment WGSL. Both pin the per-instance attribute locations and the
20800
21049
  * projection binding exactly as the default sprite path does, so a custom
20801
21050
  * material keeps the single `drawArraysInstanced` / `drawIndexed` batch.
20802
21051
  *
21052
+ * Both paths (locations 0, 3, 4, 5, 6) fetch each instance's world transform
21053
+ * from the shared transform storage keyed by `a_nodeIndex` / `nodeIndex`: the
21054
+ * WebGL2 path samples the `u_transforms` texture, the WGSL path reads the
21055
+ * `transforms` storage buffer (group(0) binding(1)).
21056
+ *
20803
21057
  * A custom fragment receives the interpolated `v_texcoord` and premultiplied
20804
21058
  * `v_color`, plus the per-batch base texture bound as `u_texture` (WebGL2 unit 0
20805
21059
  * / WGSL group(1) binding 0). Material uniforms and additional textures bind on
@@ -20818,13 +21072,13 @@ precision highp int;
20818
21072
  // to the per-instance buffer; gl_VertexID 0..3 selects which corner of the
20819
21073
  // quad this invocation is computing.
20820
21074
  layout(location = 0) in vec4 a_localBounds; // left, top, right, bottom (local space)
20821
- layout(location = 1) in vec3 a_transformAB; // a, b, x — first row of 2D affine
20822
- layout(location = 2) in vec3 a_transformCD; // c, d, y — second row
20823
21075
  layout(location = 3) in vec4 a_uvBounds; // uMin, vMin, uMax, vMax (normalised, already flipY-swapped)
20824
21076
  layout(location = 4) in vec4 a_color; // RGBA tint
20825
21077
  layout(location = 5) in uint a_textureSlot;
21078
+ layout(location = 6) in uint a_nodeIndex; // row into the shared transform buffer
20826
21079
 
20827
21080
  uniform mat3 u_projection;
21081
+ uniform sampler2D u_transforms; // shared per-frame transform buffer (3 texels/row)
20828
21082
 
20829
21083
  out vec2 v_texcoord;
20830
21084
  out vec4 v_color;
@@ -20839,8 +21093,14 @@ void main(void) {
20839
21093
  float localX = (cornerX == 0) ? a_localBounds.x : a_localBounds.z;
20840
21094
  float localY = (cornerY == 0) ? a_localBounds.y : a_localBounds.w;
20841
21095
 
20842
- float worldX = (a_transformAB.x * localX) + (a_transformAB.y * localY) + a_transformAB.z;
20843
- float worldY = (a_transformCD.x * localX) + (a_transformCD.y * localY) + a_transformCD.z;
21096
+ // Fetch the per-instance world transform from the shared buffer (row =
21097
+ // a_nodeIndex): texel 0 = (a, b, c, d), texel 1 = (tx, ty, 0, 0).
21098
+ int row = int(a_nodeIndex);
21099
+ vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0);
21100
+ vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0);
21101
+
21102
+ float worldX = (m0.x * localX) + (m0.y * localY) + m1.x;
21103
+ float worldY = (m0.z * localX) + (m0.w * localY) + m1.y;
20844
21104
 
20845
21105
  gl_Position = vec4((u_projection * vec3(worldX, worldY, 1.0)).xy, 0.0, 1.0);
20846
21106
 
@@ -20855,11 +21115,11 @@ void main(void) {
20855
21115
  /**
20856
21116
  * WGSL vertex stage + shared bindings for the custom sprite-material path.
20857
21117
  * Prepend to a material's fragment WGSL: it declares the per-instance
20858
- * `VertexInput` (locations 05), the `VertexOutput` a custom `@fragment`
20859
- * consumes, the group(0) projection uniform, the group(1) base texture and
20860
- * sampler (`u_texture` / `u_sampler`), and the `vertexMain` entry point. The
20861
- * fragment author adds their group(2) bindings and a `fragmentMain` reading
20862
- * `VertexOutput`.
21118
+ * `VertexInput` (locations 0, 3, 4, 5, 6), the `VertexOutput` a custom
21119
+ * `@fragment` consumes, the group(0) projection uniform + shared transform
21120
+ * storage buffer, the group(1) base texture and sampler (`u_texture` /
21121
+ * `u_sampler`), and the `vertexMain` entry point. The fragment author adds their
21122
+ * group(2) bindings and a `fragmentMain` reading `VertexOutput`.
20863
21123
  * @internal
20864
21124
  */
20865
21125
  const spriteVertexWgsl = `
@@ -20867,18 +21127,24 @@ struct ProjectionUniforms {
20867
21127
  matrix: mat4x4<f32>,
20868
21128
  };
20869
21129
 
21130
+ struct TransformSlot {
21131
+ m0: vec4<f32>,
21132
+ m1: vec4<f32>,
21133
+ m2: vec4<f32>,
21134
+ };
21135
+
20870
21136
  @group(0) @binding(0) var<uniform> projection: ProjectionUniforms;
21137
+ @group(0) @binding(1) var<storage, read> transforms: array<TransformSlot>;
20871
21138
 
20872
21139
  @group(1) @binding(0) var u_texture: texture_2d<f32>;
20873
21140
  @group(1) @binding(1) var u_sampler: sampler;
20874
21141
 
20875
21142
  struct VertexInput {
20876
21143
  @location(0) localBounds: vec4<f32>,
20877
- @location(1) transformAB: vec3<f32>,
20878
- @location(2) transformCD: vec3<f32>,
20879
21144
  @location(3) uvBounds: vec4<f32>,
20880
21145
  @location(4) color: vec4<f32>,
20881
21146
  @location(5) textureSlot: u32,
21147
+ @location(6) nodeIndex: u32,
20882
21148
  };
20883
21149
 
20884
21150
  struct VertexOutput {
@@ -20897,8 +21163,12 @@ fn vertexMain(input: VertexInput, @builtin(vertex_index) vid: u32) -> VertexOutp
20897
21163
  let localX = select(input.localBounds.x, input.localBounds.z, cornerX == 1u);
20898
21164
  let localY = select(input.localBounds.y, input.localBounds.w, cornerY == 1u);
20899
21165
 
20900
- let worldX = input.transformAB.x * localX + input.transformAB.y * localY + input.transformAB.z;
20901
- let worldY = input.transformCD.x * localX + input.transformCD.y * localY + input.transformCD.z;
21166
+ // Fetch this instance's world transform from the shared storage buffer,
21167
+ // keyed by nodeIndex: m0 = (a, b, c, d), m1 = (tx, ty, 0, 0). (m2 carries the
21168
+ // node tint, unused here — the sprite keeps its own per-instance color.)
21169
+ let slot = transforms[input.nodeIndex];
21170
+ let worldX = slot.m0.x * localX + slot.m0.y * localY + slot.m1.x;
21171
+ let worldY = slot.m0.z * localX + slot.m0.w * localY + slot.m1.y;
20902
21172
 
20903
21173
  output.position = projection.matrix * vec4<f32>(worldX, worldY, 0.0, 1.0);
20904
21174
 
@@ -20914,7 +21184,7 @@ fn vertexMain(input: VertexInput, @builtin(vertex_index) vid: u32) -> VertexOutp
20914
21184
 
20915
21185
  var fragmentSource$1 = "#version 300 es\nprecision lowp float;\nprecision lowp int;\n\n// Multi-texture sprite batching: up to 8 textures bound per draw call,\n// each fragment selects its source via a flat-interpolated slot index.\n//\n// GLSL ES 3.0 forbids non-constant array-of-sampler indexing unless the\n// expression is dynamically uniform — which a per-vertex slot is not\n// once different triangles in the same batch carry different slots. The\n// if/else chain below dispatches statically and dodges that constraint.\n\nuniform sampler2D u_texture0;\nuniform sampler2D u_texture1;\nuniform sampler2D u_texture2;\nuniform sampler2D u_texture3;\nuniform sampler2D u_texture4;\nuniform sampler2D u_texture5;\nuniform sampler2D u_texture6;\nuniform sampler2D u_texture7;\n\nin vec2 v_texcoord;\nin vec4 v_color;\nflat in uint v_textureSlot;\n\nlayout(location = 0) out vec4 fragColor;\n\nvoid main(void) {\n vec4 sampleColor;\n\n if (v_textureSlot == 0u) {\n sampleColor = texture(u_texture0, v_texcoord);\n } else if (v_textureSlot == 1u) {\n sampleColor = texture(u_texture1, v_texcoord);\n } else if (v_textureSlot == 2u) {\n sampleColor = texture(u_texture2, v_texcoord);\n } else if (v_textureSlot == 3u) {\n sampleColor = texture(u_texture3, v_texcoord);\n } else if (v_textureSlot == 4u) {\n sampleColor = texture(u_texture4, v_texcoord);\n } else if (v_textureSlot == 5u) {\n sampleColor = texture(u_texture5, v_texcoord);\n } else if (v_textureSlot == 6u) {\n sampleColor = texture(u_texture6, v_texcoord);\n } else {\n sampleColor = texture(u_texture7, v_texcoord);\n }\n\n fragColor = sampleColor * v_color;\n}\n";
20916
21186
 
20917
- var vertexSource$1 = "#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";
21187
+ var vertexSource$1 = "#version 300 es\nprecision lowp float;\nprecision highp 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 = 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;\nlayout(location = 6) in uint a_nodeIndex; // row into the shared transform buffer\n\nuniform mat3 u_projection;\nuniform sampler2D u_transforms; // shared per-frame transform buffer (3 texels/row)\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 // Fetch the world transform for this instance from the shared buffer,\n // keyed by a_nodeIndex. Row layout: texel 0 = (a, b, c, d),\n // texel 1 = (tx, ty, 0, 0). (texel 2 carries tint, unused here — the\n // sprite keeps its own per-instance a_color.)\n int row = int(a_nodeIndex);\n vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); // a, b, c, d\n vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); // tx, ty, 0, 0\n\n // world = M * (localX, localY, 1)\n float worldX = (m0.x * localX) + (m0.y * localY) + m1.x;\n float worldY = (m0.z * localX) + (m0.w * localY) + m1.y;\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";
20918
21188
 
20919
21189
  /**
20920
21190
  * Instanced sprite renderer for WebGL2.
@@ -20924,21 +21194,23 @@ var vertexSource$1 = "#version 300 es\nprecision lowp float;\nprecision lowp int
20924
21194
  * the quad each invocation is computing. All per-sprite data lives in a
20925
21195
  * single per-instance buffer (divisor = 1).
20926
21196
  *
20927
- * Per-instance layout (56 bytes per sprite, 6 attributes):
21197
+ * Per-instance layout (36 bytes per sprite, 5 attributes):
20928
21198
  * ```
20929
21199
  * localBounds f32x4 (offset 0, 16 bytes) — left, top, right, bottom
20930
- * transformAB f32x3 (offset 16, 12 bytes) — first row of 2D affine
20931
- * transformCD f32x3 (offset 28, 12 bytes) — second row of 2D affine
20932
- * uvBounds u16x4 norm (offset 40, 8 bytes) — uMin, vMin, uMax, vMax
20933
- * color u8x4 norm (offset 48, 4 bytes) — RGBA tint
20934
- * textureSlot u32 (offset 52, 4 bytes) — multi-texture slot
21200
+ * uvBounds u16x4 norm (offset 16, 8 bytes) — uMin, vMin, uMax, vMax
21201
+ * color u8x4 norm (offset 24, 4 bytes) — RGBA tint
21202
+ * textureSlot u32 (offset 28, 4 bytes) — multi-texture slot
21203
+ * nodeIndex u32 (offset 32, 4 bytes) — row into the shared TransformBuffer
20935
21204
  * ```
20936
21205
  *
20937
- * vs. the previous per-vertex layout (80 bytes per quad), this saves
20938
- * roughly 30% bandwidth and ~75% of the CPU writes per sprite — the
20939
- * vertex shader expands one instance into four corners on the GPU
20940
- * instead of the CPU duplicating the same color/slot/transform across
20941
- * four vertex entries.
21206
+ * The per-instance world transform no longer lives in this buffer: it is
21207
+ * fetched in the vertex shader from the shared {@link TransformBuffer}
21208
+ * texture (`u_transforms`) keyed by `a_nodeIndex`, exactly like the mesh
21209
+ * renderer. The render-group upload boundary (PR #44) already packs every
21210
+ * draw command's transform into that buffer at its stable `nodeIndex`, so the
21211
+ * sprite just reads it back instead of re-packing 24 bytes of affine rows per
21212
+ * instance. vs. the previous per-vertex layout (80 bytes per quad), the
21213
+ * vertex shader still expands one instance into four corners on the GPU.
20942
21214
  *
20943
21215
  * # Default vs custom-material path
20944
21216
  *
@@ -20952,7 +21224,10 @@ var vertexSource$1 = "#version 300 es\nprecision lowp float;\nprecision lowp int
20952
21224
  * breaks on material instance, base texture, blend mode, or buffer capacity.
20953
21225
  */
20954
21226
  const maxBatchTextures$1 = 8;
20955
- const instanceStrideBytes$2 = 56;
21227
+ // Sprite base textures occupy units 0..7; the shared transform buffer texture
21228
+ // binds on unit 8, matching the mesh renderer's convention.
21229
+ const transformTextureUnit = 8;
21230
+ const instanceStrideBytes$2 = 36;
20956
21231
  const wordsPerInstance$1 = instanceStrideBytes$2 / Uint32Array.BYTES_PER_ELEMENT;
20957
21232
  class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
20958
21233
  _shader;
@@ -20969,9 +21244,14 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
20969
21244
  // Texture-unit index scratches reused for sampler-uniform binds so the
20970
21245
  // per-batch path stays allocation-free.
20971
21246
  _slotScratches = Array.from({ length: maxBatchTextures$1 }, (_, i) => new Int32Array([i]));
21247
+ // Pinned unit index for the shared transform buffer sampler.
21248
+ _transformUnitScratch = new Int32Array([transformTextureUnit]);
20972
21249
  _currentMaterial = null;
20973
21250
  _currentBaseTexture = null;
20974
21251
  _instanceCount = 0;
21252
+ // Highest transform-buffer row referenced by the pending batch; drives the
21253
+ // minimum row count uploaded for the transform texture at flush time.
21254
+ _maxNodeIndex = 0;
20975
21255
  _currentBlendMode = null;
20976
21256
  _currentView = null;
20977
21257
  _currentViewId = -1;
@@ -20993,11 +21273,17 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
20993
21273
  }
20994
21274
  const backend = this.getBackend();
20995
21275
  const material = sprite.material;
21276
+ // The transform lives in the shared buffer, keyed by the draw command's
21277
+ // stable nodeIndex (already packed at the render-group upload boundary).
21278
+ // A direct, non-plan `backend.draw(sprite)` has no command — push the
21279
+ // sprite's transform into the buffer and use the freshly-allocated slot.
21280
+ const command = backend.activeDrawCommand;
21281
+ const nodeIndex = command !== null ? command.nodeIndex : backend._pushTransform(sprite);
20996
21282
  if (material === null) {
20997
- this._renderDefault(sprite, texture, backend);
21283
+ this._renderDefault(sprite, texture, backend, nodeIndex);
20998
21284
  }
20999
21285
  else {
21000
- this._renderCustom(sprite, texture, material, backend);
21286
+ this._renderCustom(sprite, texture, material, backend, nodeIndex);
21001
21287
  }
21002
21288
  return this;
21003
21289
  }
@@ -21007,6 +21293,7 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
21007
21293
  const vao = this._vao;
21008
21294
  const connection = this._connection;
21009
21295
  if (this._instanceCount === 0 || backend === null || instanceBuffer === null || vao === null || connection === null) {
21296
+ this._maxNodeIndex = 0;
21010
21297
  this._resetSlots();
21011
21298
  return;
21012
21299
  }
@@ -21034,6 +21321,13 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
21034
21321
  }
21035
21322
  this._bindCustomUniforms(shader, material, backend);
21036
21323
  }
21324
+ // Bind the shared transform buffer texture (one row per nodeIndex) on the
21325
+ // dedicated unit and point the sampler at it. Done for both the default and
21326
+ // custom programs — both fetch the world transform via a_nodeIndex.
21327
+ backend.bindTransformBufferTexture(transformTextureUnit, this._maxNodeIndex + 1);
21328
+ if (shader.uniforms.has('u_transforms')) {
21329
+ shader.getUniform('u_transforms').setValue(this._transformUnitScratch);
21330
+ }
21037
21331
  shader.sync();
21038
21332
  backend.bindVertexArrayObject(vao);
21039
21333
  instanceBuffer.upload(this._instanceFloat32.subarray(0, this._instanceCount * wordsPerInstance$1));
@@ -21041,6 +21335,7 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
21041
21335
  backend.stats.batches++;
21042
21336
  backend.stats.drawCalls++;
21043
21337
  this._instanceCount = 0;
21338
+ this._maxNodeIndex = 0;
21044
21339
  this._resetSlots();
21045
21340
  }
21046
21341
  onConnect(backend) {
@@ -21051,11 +21346,10 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
21051
21346
  this._shader.sync();
21052
21347
  this._vao = new WebGl2VertexArrayObject(RenderingPrimitives.TriangleStrip)
21053
21348
  .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_localBounds'), gl.FLOAT, false, instanceStrideBytes$2, 0, false, 1)
21054
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_transformAB'), gl.FLOAT, false, instanceStrideBytes$2, 16, false, 1)
21055
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_transformCD'), gl.FLOAT, false, instanceStrideBytes$2, 28, false, 1)
21056
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_uvBounds'), gl.UNSIGNED_SHORT, true, instanceStrideBytes$2, 40, false, 1)
21057
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, instanceStrideBytes$2, 48, false, 1)
21058
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_textureSlot'), gl.UNSIGNED_INT, false, instanceStrideBytes$2, 52, true, 1)
21349
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_uvBounds'), gl.UNSIGNED_SHORT, true, instanceStrideBytes$2, 16, false, 1)
21350
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, instanceStrideBytes$2, 24, false, 1)
21351
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_textureSlot'), gl.UNSIGNED_INT, false, instanceStrideBytes$2, 28, true, 1)
21352
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_nodeIndex'), gl.UNSIGNED_INT, false, instanceStrideBytes$2, 32, true, 1)
21059
21353
  .connect(this._createVaoRuntime(this._connection));
21060
21354
  // Pin the per-slot sampler uniforms to texture units 0..N-1.
21061
21355
  const samplerUnit = new Int32Array(1);
@@ -21081,13 +21375,14 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
21081
21375
  this._currentView = null;
21082
21376
  this._currentViewId = -1;
21083
21377
  this._instanceCount = 0;
21378
+ this._maxNodeIndex = 0;
21084
21379
  }
21085
21380
  destroy() {
21086
21381
  this.disconnect();
21087
21382
  this._shader.destroy();
21088
21383
  }
21089
21384
  /** Default multi-texture path: rotate the base texture through 8 slots. */
21090
- _renderDefault(sprite, texture, backend) {
21385
+ _renderDefault(sprite, texture, backend, nodeIndex) {
21091
21386
  const blendMode = sprite.blendMode;
21092
21387
  const batchFull = this._instanceCount >= this._batchSize;
21093
21388
  const blendModeChanged = blendMode !== this._currentBlendMode;
@@ -21109,11 +21404,11 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
21109
21404
  this._activeTextures[slot] = texture;
21110
21405
  backend.bindTexture(texture, slot);
21111
21406
  }
21112
- this._packInstance(sprite, texture, slot);
21407
+ this._packInstance(sprite, texture, slot, nodeIndex);
21113
21408
  this._instanceCount++;
21114
21409
  }
21115
21410
  /** Custom-material path: single base texture on unit 0, instanced. */
21116
- _renderCustom(sprite, texture, material, backend) {
21411
+ _renderCustom(sprite, texture, material, backend, nodeIndex) {
21117
21412
  // The material owns its blend mode; the sprite's own blendMode overrides it
21118
21413
  // when set away from the default (Normal).
21119
21414
  const blendMode = sprite.blendMode === BlendModes.Normal ? material.blendMode : sprite.blendMode;
@@ -21131,10 +21426,10 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
21131
21426
  this._currentMaterial = material;
21132
21427
  this._currentBaseTexture = texture;
21133
21428
  // textureSlot word is unused by custom fragments (base binds to unit 0).
21134
- this._packInstance(sprite, texture, 0);
21429
+ this._packInstance(sprite, texture, 0, nodeIndex);
21135
21430
  this._instanceCount++;
21136
21431
  }
21137
- _packInstance(sprite, texture, slot) {
21432
+ _packInstance(sprite, texture, slot, nodeIndex) {
21138
21433
  const offset = this._instanceCount * wordsPerInstance$1;
21139
21434
  const f32 = this._instanceFloat32;
21140
21435
  const u32 = this._instanceUint32;
@@ -21144,15 +21439,7 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
21144
21439
  f32[offset + 1] = bounds.top;
21145
21440
  f32[offset + 2] = bounds.right;
21146
21441
  f32[offset + 3] = bounds.bottom;
21147
- // transform rows (offset 4..6 = AB, 7..9 = CD)
21148
- const transform = sprite.getGlobalTransform();
21149
- f32[offset + 4] = transform.a;
21150
- f32[offset + 5] = transform.b;
21151
- f32[offset + 6] = transform.x;
21152
- f32[offset + 7] = transform.c;
21153
- f32[offset + 8] = transform.d;
21154
- f32[offset + 9] = transform.y;
21155
- // uvBounds at offset 10 — 8 bytes = 2 u32 slots, normalised u16x4.
21442
+ // uvBounds at offset 4 — 8 bytes = 2 u32 slots, normalised u16x4.
21156
21443
  // Pack (uMin, vMin, uMax, vMax) into two uint32s, with flipY swap
21157
21444
  // applied at pack time so the shader can stay flip-agnostic.
21158
21445
  const frame = sprite.textureFrame;
@@ -21165,12 +21452,18 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
21165
21452
  const vMaxRaw = ((frame.bottom / texHeight) * 0xffff) & 0xffff;
21166
21453
  const vMin = texture.flipY ? vMaxRaw : vMinRaw;
21167
21454
  const vMax = texture.flipY ? vMinRaw : vMaxRaw;
21168
- u32[offset + 10] = uMin | (vMin << 16);
21169
- u32[offset + 11] = uMax | (vMax << 16);
21170
- // color (u8x4 packed) at word 12
21171
- u32[offset + 12] = sprite.tint.toRgba();
21172
- // textureSlot (u32) at word 13
21173
- u32[offset + 13] = slot;
21455
+ u32[offset + 4] = uMin | (vMin << 16);
21456
+ u32[offset + 5] = uMax | (vMax << 16);
21457
+ // color (u8x4 packed) at word 6
21458
+ u32[offset + 6] = sprite.tint.toRgba();
21459
+ // textureSlot (u32) at word 7
21460
+ u32[offset + 7] = slot;
21461
+ // nodeIndex (u32) at word 8 — row into the shared transform buffer.
21462
+ const node = nodeIndex >>> 0;
21463
+ u32[offset + 8] = node;
21464
+ if (node > this._maxNodeIndex) {
21465
+ this._maxNodeIndex = node;
21466
+ }
21174
21467
  }
21175
21468
  _getOrCreateCustomShader(material, gl) {
21176
21469
  const cached = this._customShaders.get(material);
@@ -21499,7 +21792,7 @@ class WebGl2StencilClipper {
21499
21792
 
21500
21793
  var textVertSource = "#version 300 es\nprecision highp float;\n\nlayout(location = 0) in vec2 a_position;\nlayout(location = 1) in vec2 a_texcoord;\nlayout(location = 2) in float a_nodeIndex;\n\nuniform mat3 u_projection;\nuniform sampler2D u_nodeData;\n\nflat out int v_nodeIndex;\n out vec2 v_texcoord;\n out vec2 v_gradUV;\n\nvoid main(void) {\n int ni = int(a_nodeIndex);\n\n // texel 0: (a, c, 0, tx) texel 1: (b, d, 0, ty)\n vec4 t0 = texelFetch(u_nodeData, ivec2(0, ni), 0);\n vec4 t1 = texelFetch(u_nodeData, ivec2(1, ni), 0);\n\n // Reconstruct column-major mat3 from stored components\n mat3 xf = mat3(\n t0.x, t0.y, 0.0, // col 0: (a, c, 0)\n t1.x, t1.y, 0.0, // col 1: (b, d, 0)\n t0.w, t1.w, 1.0 // col 2: (tx, ty, 1)\n );\n\n gl_Position = vec4((u_projection * xf * vec3(a_position, 1.0)).xy, 0.0, 1.0);\n v_texcoord = a_texcoord;\n v_nodeIndex = ni;\n\n // texel 9: (minX, minY, width, height) — text block bounds in local space.\n // Normalise a_position into [0,1] so fragment shaders can interpolate\n // gradients across the whole block rather than individual atlas UVs.\n vec4 tBounds = texelFetch(u_nodeData, ivec2(9, ni), 0);\n vec2 bSize = tBounds.zw;\n v_gradUV = (bSize.x > 0.0 && bSize.y > 0.0)\n ? clamp((a_position - tBounds.xy) / bSize, 0.0, 1.0)\n : vec2(0.0);\n}\n";
21501
21794
 
21502
- var textColorFragSource = "#version 300 es\nprecision mediump float;\n\nuniform sampler2D u_texture; // RGBA colour-font / emoji atlas\nuniform sampler2D u_nodeData; // RGBA32F per-node data\n\nflat in int v_nodeIndex;\n in vec2 v_texcoord;\n\nlayout(location = 0) out vec4 fragColor;\n\nvoid main(void) {\n // texel 2: fillColor (tint multiplier; (1,1,1,1) = no tint)\n vec4 tint = texelFetch(u_nodeData, ivec2(2, v_nodeIndex), 0);\n vec4 sample = texture(u_texture, v_texcoord);\n fragColor = sample * tint;\n}\n";
21795
+ var textColorFragSource = "#version 300 es\nprecision mediump float;\n\nuniform sampler2D u_texture; // RGBA colour-font / emoji atlas\nuniform sampler2D u_nodeData; // RGBA32F per-node data\n\nflat in int v_nodeIndex;\n in vec2 v_texcoord;\n\nlayout(location = 0) out vec4 fragColor;\n\nvoid main(void) {\n // texel 2: fillColor (tint multiplier; (1,1,1,1) = no tint)\n vec4 tint = texelFetch(u_nodeData, ivec2(2, v_nodeIndex), 0);\n vec4 texel = texture(u_texture, v_texcoord);\n fragColor = texel * tint;\n}\n";
21503
21796
 
21504
21797
  var textMsdfFragSource = "#version 300 es\nprecision mediump float;\n\nuniform sampler2D u_texture; // RGB MSDF atlas\nuniform sampler2D u_nodeData; // RGBA32F per-node data (see WebGl2TextRenderer)\nuniform float u_pageSize; // atlas page size in px (for shadow UV conversion)\n\nflat in int v_nodeIndex;\n in vec2 v_texcoord;\n in vec2 v_gradUV;\n\nlayout(location = 0) out vec4 fragColor;\n\nfloat median(float r, float g, float b) {\n return max(min(r, g), min(max(r, g), b));\n}\n\nvoid main(void) {\n int ni = v_nodeIndex;\n\n // Same node data layout as text-sdf.frag\n vec4 tFill = texelFetch(u_nodeData, ivec2(2, ni), 0);\n vec4 tOutline = texelFetch(u_nodeData, ivec2(3, ni), 0);\n vec4 tParams = texelFetch(u_nodeData, ivec2(4, ni), 0); // (outlineMin, shadowAlpha, softness, gradientEnabled)\n vec4 tShadow = texelFetch(u_nodeData, ivec2(5, ni), 0);\n vec4 tShadow2 = texelFetch(u_nodeData, ivec2(6, ni), 0); // (shadowOffX_px, shadowOffY_px, gradientVertical, 0)\n vec4 tGradTop = texelFetch(u_nodeData, ivec2(7, ni), 0);\n vec4 tGradBot = texelFetch(u_nodeData, ivec2(8, ni), 0);\n\n float outlineMin = tParams.x;\n float shadowAlpha = tParams.y;\n float soft = max(tParams.z, 0.001);\n float gradEnabled = tParams.w;\n vec2 shadowOffset = tShadow2.xy / u_pageSize;\n float gradVertical = tShadow2.z;\n\n vec3 msd = texture(u_texture, v_texcoord).rgb;\n float sd = median(msd.r, msd.g, msd.b);\n float fill = smoothstep(0.5 - soft, 0.5 + soft, sd);\n\n float outline = outlineMin < 0.5\n ? smoothstep(outlineMin - soft, outlineMin + soft, sd) * (1.0 - fill)\n : 0.0;\n\n vec3 shadowMsd = texture(u_texture, v_texcoord - shadowOffset).rgb;\n float shadowSd = median(shadowMsd.r, shadowMsd.g, shadowMsd.b);\n float shadow = smoothstep(0.5 - soft, 0.5 + soft, shadowSd)\n * shadowAlpha * (1.0 - fill) * (1.0 - outline);\n\n vec4 fillColor;\n if (gradEnabled > 0.5) {\n float t = gradVertical > 0.5 ? v_gradUV.y : v_gradUV.x;\n fillColor = mix(tGradBot, tGradTop, t);\n } else {\n fillColor = tFill;\n }\n\n fragColor = fillColor * fill\n + tOutline * outline\n + tShadow * shadow;\n}\n";
21505
21798
 
@@ -21549,6 +21842,13 @@ const initialNodeCapacity$1 = 32;
21549
21842
  * same shader type and atlas page are drawn in a single `drawElements` call.
21550
21843
  */
21551
21844
  class WebGl2TextRenderer extends AbstractWebGl2Renderer {
21845
+ /**
21846
+ * Text packs its world transform into its own per-node data texture and never
21847
+ * reads the shared {@link TransformBuffer}, so the render-group upload boundary
21848
+ * skips writing transform records for text draws.
21849
+ * @internal
21850
+ */
21851
+ _consumesSharedTransform = false;
21552
21852
  _sdfShader = new Shader(textVertSource, textSdfFragSource);
21553
21853
  _msdfShader = new Shader(textVertSource, textMsdfFragSource);
21554
21854
  _colorShader = new Shader(textVertSource, textColorFragSource);
@@ -22138,11 +22438,59 @@ class WebGl2Backend {
22138
22438
  this._drawPlanDepth++;
22139
22439
  }
22140
22440
  /** @internal */
22441
+ _prepareRenderGroupUpload(group) {
22442
+ // Pack the whole render group's world transforms (+ tint) into the shared
22443
+ // transform buffer at the group's upload boundary, keyed by each draw
22444
+ // command's stable nodeIndex. Every draw the player will submit for this
22445
+ // group is covered here, before the group's first draw — so the per-draw
22446
+ // write previously done in `_prepareDrawCommand` is no longer needed and
22447
+ // the buffer is filled one contiguous group slice at a time.
22448
+ //
22449
+ // Renderers that pack their own per-node data (Text, Particle) never read
22450
+ // the shared buffer, so their commands are skipped — no consuming draw ever
22451
+ // references their slots (nodeIndex is unique per command).
22452
+ const instructions = group.instructions;
22453
+ for (let i = 0; i < instructions.length; i++) {
22454
+ const command = instructions[i];
22455
+ if (drawCommandUsesSharedTransform(command, this)) {
22456
+ this._writeTransformCommand(command);
22457
+ }
22458
+ else {
22459
+ this._transformBuffer.recordSkippedWrite();
22460
+ }
22461
+ }
22462
+ }
22463
+ /** @internal */
22141
22464
  _prepareDrawCommand(command) {
22465
+ // Transform packing now happens at the render-group upload boundary
22466
+ // (`_prepareRenderGroupUpload`); this hook only tracks the active draw so
22467
+ // the mesh renderer can read the current command's nodeIndex.
22142
22468
  this._activeDrawCommand = command;
22469
+ }
22470
+ /**
22471
+ * Write a single draw command's world transform (+ tint) into the shared
22472
+ * transform buffer at its `nodeIndex` slot. Used for draws that do not arrive
22473
+ * through a render-group upload boundary — currently the mesh renderer's
22474
+ * synthetic, non-plan instanced path.
22475
+ * @internal
22476
+ */
22477
+ _writeTransformCommand(command) {
22143
22478
  const drawable = command.drawable;
22144
22479
  this._transformBuffer.write(command.nodeIndex, drawable.getGlobalTransform(), drawable.tint);
22145
22480
  }
22481
+ /**
22482
+ * Append a drawable's world transform (+ tint) to the shared transform buffer
22483
+ * and return the slot it was written to. Used by instanced renderers for draws
22484
+ * that arrive without a render-group upload boundary — i.e. a direct
22485
+ * `backend.draw(drawable)` outside the plan player (`activeDrawCommand === null`),
22486
+ * where no stable `nodeIndex` was assigned. Unlike {@link _writeTransformCommand}
22487
+ * (fixed slot) this allocates a fresh slot, so a batch of synthetic draws does
22488
+ * not collide on a single row.
22489
+ * @internal
22490
+ */
22491
+ _pushTransform(drawable) {
22492
+ return this._transformBuffer.push(drawable.getGlobalTransform(), drawable.tint);
22493
+ }
22146
22494
  /** @internal */
22147
22495
  _endDrawPlan() {
22148
22496
  this._activeDrawCommand = null;
@@ -22393,6 +22741,7 @@ class WebGl2Backend {
22393
22741
  }
22394
22742
  if (snapshot.changed || snapshot.count !== this._transformTextureCount || snapshot.hash !== this._transformTextureHash) {
22395
22743
  nextTransformTexture.commitRect(0, 0, 3, snapshot.count);
22744
+ this._transformBuffer.recordUpload(snapshot.count);
22396
22745
  this._transformTextureHash = snapshot.hash;
22397
22746
  this._transformTextureCount = snapshot.count;
22398
22747
  }
@@ -23804,16 +24153,6 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
23804
24153
  if (customShader !== null && customShader.shader.wgsl === null) {
23805
24154
  throw new Error('Mesh material shader has no `wgsl` source; cannot render through the WebGPU backend.');
23806
24155
  }
23807
- if (customShader !== null && backend._passCoordinator.stencilActive) {
23808
- // The WebGPU geometric stencil MVP supports clipping default-material
23809
- // Sprites and Mesh/Graphics (which select stencil-enabled pipeline
23810
- // variants below); a custom MeshMaterial under a Geometry clip would need
23811
- // its own stencil pipeline variants and is not supported yet. Throw at
23812
- // collection time (inside the clip scope's try) so the surrounding
23813
- // push/pop balances cleanly, rather than at flush time where the pop has
23814
- // not yet run.
23815
- throw new Error('WebGPU geometry stencil clipping currently supports default-material Sprites, Meshes, and Graphics. A custom-material Mesh under a Geometry clip (RenderNode.clip with a Geometry clipShape) is not supported yet. Use a Rectangle clipShape (scissor) or the WebGL2 backend instead.');
23816
- }
23817
24156
  const vertexCount = mesh.vertexCount;
23818
24157
  if (vertexCount === 0) {
23819
24158
  return;
@@ -23930,13 +24269,17 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
23930
24269
  this._packedIndexData[start + j] = j;
23931
24270
  }
23932
24271
  }
23933
- // Pack tint+flags for default path.
24272
+ // Pack tint+flags for default path. Color RGB channels are 0..255; the
24273
+ // shader multiplies the sampled texel by this tint, so normalize to
24274
+ // 0..1 (matching TransformBuffer and the WebGL2 mesh shader). Leaving
24275
+ // them at 0..255 scales every non-zero texel channel past 1.0, which
24276
+ // clamps intermediate colors (gradients, photos) to full saturation.
23934
24277
  if (defaultUniformF32 !== null) {
23935
24278
  const offsetWords = (defaultUniformIndex * this._uniformAlignment) / Float32Array.BYTES_PER_ELEMENT;
23936
24279
  const tint = dc.mesh.tint;
23937
- defaultUniformF32[offsetWords + 0] = tint.r;
23938
- defaultUniformF32[offsetWords + 1] = tint.g;
23939
- defaultUniformF32[offsetWords + 2] = tint.b;
24280
+ defaultUniformF32[offsetWords + 0] = tint.r / 255;
24281
+ defaultUniformF32[offsetWords + 1] = tint.g / 255;
24282
+ defaultUniformF32[offsetWords + 2] = tint.b / 255;
23940
24283
  defaultUniformF32[offsetWords + 3] = tint.a;
23941
24284
  defaultUniformF32[offsetWords + 4] = dc.premultiplySample ? 1 : 0;
23942
24285
  defaultUniformF32[offsetWords + 5] = 0;
@@ -23976,7 +24319,7 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
23976
24319
  drawCursor++;
23977
24320
  }
23978
24321
  device.queue.writeBuffer(resources.vertexBuffer, 0, resources.vertexData, 0, resources.totalVertices * vertexStrideBytes$2);
23979
- device.queue.writeBuffer(resources.indexBuffer, 0, resources.indexData.buffer, resources.indexData.byteOffset, resources.totalIndices * Uint16Array.BYTES_PER_ELEMENT);
24322
+ device.queue.writeBuffer(resources.indexBuffer, 0, resources.indexData.buffer, resources.indexData.byteOffset, (resources.totalIndices * Uint16Array.BYTES_PER_ELEMENT + 3) & -4);
23980
24323
  // Build/refresh user uniform UBO from the material (re-built every frame
23981
24324
  // so mutations to material.uniforms.X are picked up).
23982
24325
  this._uploadUserUniforms(material, resources);
@@ -23984,7 +24327,7 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
23984
24327
  // Phase 4: single writeBuffer per resource for the default path.
23985
24328
  if (defaultVertices > 0) {
23986
24329
  device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, defaultVertices * vertexStrideBytes$2);
23987
- device.queue.writeBuffer(this._indexBuffer, 0, this._packedIndexData.buffer, this._packedIndexData.byteOffset, defaultIndices * Uint16Array.BYTES_PER_ELEMENT);
24330
+ device.queue.writeBuffer(this._indexBuffer, 0, this._packedIndexData.buffer, this._packedIndexData.byteOffset, (defaultIndices * Uint16Array.BYTES_PER_ELEMENT + 3) & -4);
23988
24331
  }
23989
24332
  if (defaultUniformData !== null) {
23990
24333
  device.queue.writeBuffer(this._uniformBuffer, 0, defaultUniformData, 0, defaultUniformBytes);
@@ -23997,10 +24340,9 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
23997
24340
  const renderTargetFormat = backend.renderTargetFormat;
23998
24341
  // A clip scope flushes the active renderer on push/pop, so every draw call
23999
24342
  // in this batch shares one stencil state — read it once. While active, the
24000
- // coordinator's pass carries a depth/stencil attachment, so the default and
24001
- // static-batch pipelines must select their stencil-enabled variants. The
24002
- // custom path never reaches here under a clip (render() throws at collection
24003
- // time), so its pipelines stay stencil-free.
24343
+ // coordinator's pass carries a depth/stencil attachment, so the default,
24344
+ // static-batch, and custom-material pipelines must all select their
24345
+ // stencil-enabled variants to match it.
24004
24346
  const stencil = backend._passCoordinator.stencilActive;
24005
24347
  let lastShader = null;
24006
24348
  let lastBlendMode = null;
@@ -24076,7 +24418,7 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
24076
24418
  // batched render pass.
24077
24419
  pass.pushDebugGroup('MeshMaterial (custom)');
24078
24420
  if (needsPipeline) {
24079
- pass.setPipeline(this._getOrCreateCustomPipeline(resources, dc.blendMode, renderTargetFormat));
24421
+ pass.setPipeline(this._getOrCreateCustomPipeline(resources, dc.blendMode, renderTargetFormat, stencil));
24080
24422
  lastShader = dc.customShader;
24081
24423
  lastBlendMode = dc.blendMode;
24082
24424
  lastFormat = renderTargetFormat;
@@ -24528,7 +24870,9 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
24528
24870
  const vertexFloatView = new Float32Array(vertexData);
24529
24871
  const vertexUintView = new Uint32Array(vertexData);
24530
24872
  this._writeMeshVerticesIntoBuffer(mesh, 0, vertexFloatView, vertexUintView);
24531
- const indexData = new Uint16Array(mesh.indexCount);
24873
+ // Allocate one extra element when indexCount is odd so the GPU buffer and
24874
+ // writeBuffer byte count can be rounded up to 4 without a buffer overread.
24875
+ const indexData = new Uint16Array(mesh.indexCount + (mesh.indexCount & 1));
24532
24876
  if (mesh.indices !== null) {
24533
24877
  indexData.set(mesh.indices, 0);
24534
24878
  }
@@ -24537,16 +24881,18 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
24537
24881
  indexData[i] = i;
24538
24882
  }
24539
24883
  }
24884
+ const indexByteLen = mesh.indexCount * Uint16Array.BYTES_PER_ELEMENT;
24885
+ const alignedIndexByteLen = (indexByteLen + 3) & -4;
24540
24886
  const vertexBuffer = this._device.createBuffer({
24541
24887
  size: vertexData.byteLength,
24542
24888
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
24543
24889
  });
24544
24890
  const indexBuffer = this._device.createBuffer({
24545
- size: indexData.byteLength,
24891
+ size: alignedIndexByteLen,
24546
24892
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
24547
24893
  });
24548
24894
  this._device.queue.writeBuffer(vertexBuffer, 0, vertexData, 0, vertexData.byteLength);
24549
- this._device.queue.writeBuffer(indexBuffer, 0, indexData.buffer, indexData.byteOffset, indexData.byteLength);
24895
+ this._device.queue.writeBuffer(indexBuffer, 0, indexData.buffer, indexData.byteOffset, alignedIndexByteLen);
24550
24896
  const disposeListener = () => {
24551
24897
  const entry = this._staticGeometryCache.get(geometry);
24552
24898
  if (entry === undefined) {
@@ -24585,13 +24931,16 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
24585
24931
  }
24586
24932
  }
24587
24933
  _ensureIndexCapacity(indexCount) {
24588
- const requiredBytes = indexCount * Uint16Array.BYTES_PER_ELEMENT;
24589
- if (this._packedIndexData.length < indexCount) {
24590
- this._packedIndexData = new Uint16Array(Math.max(indexCount, this._packedIndexData.length === 0 ? 1 : this._packedIndexData.length * 2));
24934
+ // GPUQueue.writeBuffer requires the byte count to be a multiple of 4.
24935
+ // Round up: odd Uint16 counts (e.g. a 3-index triangle) would otherwise
24936
+ // produce 6-byte writes which the WebGPU validation layer rejects.
24937
+ const requiredBytes = (indexCount * Uint16Array.BYTES_PER_ELEMENT + 3) & -4;
24938
+ if (this._packedIndexData.length * Uint16Array.BYTES_PER_ELEMENT < requiredBytes) {
24939
+ this._packedIndexData = new Uint16Array(Math.max(requiredBytes / Uint16Array.BYTES_PER_ELEMENT, this._packedIndexData.length === 0 ? 2 : this._packedIndexData.length * 2));
24591
24940
  }
24592
24941
  if (requiredBytes > this._indexBufferCapacity) {
24593
24942
  this._indexBuffer?.destroy();
24594
- this._indexBufferCapacity = Math.max(requiredBytes, this._indexBufferCapacity === 0 ? Uint16Array.BYTES_PER_ELEMENT : this._indexBufferCapacity * 2);
24943
+ this._indexBufferCapacity = Math.max(requiredBytes, this._indexBufferCapacity === 0 ? 4 : this._indexBufferCapacity * 2);
24595
24944
  this._indexBuffer = this._device.createBuffer({
24596
24945
  size: this._indexBufferCapacity,
24597
24946
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
@@ -24735,14 +25084,14 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
24735
25084
  usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
24736
25085
  });
24737
25086
  }
24738
- // Index buffer
24739
- const indexBytes = resources.totalIndices * Uint16Array.BYTES_PER_ELEMENT;
24740
- if (resources.indexData.length < resources.totalIndices) {
24741
- resources.indexData = new Uint16Array(Math.max(resources.totalIndices, resources.indexData.length * 2));
25087
+ // Index buffer — capacity must be 4-byte aligned for GPUQueue.writeBuffer.
25088
+ const indexBytes = (resources.totalIndices * Uint16Array.BYTES_PER_ELEMENT + 3) & -4;
25089
+ if (resources.indexData.length * Uint16Array.BYTES_PER_ELEMENT < indexBytes) {
25090
+ resources.indexData = new Uint16Array(Math.max(indexBytes / Uint16Array.BYTES_PER_ELEMENT, resources.indexData.length * 2));
24742
25091
  }
24743
25092
  if (indexBytes > resources.indexBufferCapacity) {
24744
25093
  resources.indexBuffer?.destroy();
24745
- resources.indexBufferCapacity = Math.max(indexBytes, resources.indexBufferCapacity * 2 || Uint16Array.BYTES_PER_ELEMENT);
25094
+ resources.indexBufferCapacity = Math.max(indexBytes, resources.indexBufferCapacity * 2 || 4);
24746
25095
  resources.indexBuffer = device.createBuffer({
24747
25096
  size: resources.indexBufferCapacity,
24748
25097
  usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
@@ -24828,19 +25177,24 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
24828
25177
  data[off + 10] = 1;
24829
25178
  data[off + 11] = 0;
24830
25179
  off += 12;
24831
- // tint (vec4)
25180
+ // tint (vec4). RGB are 0..255; normalize to 0..1 for the shader multiply
25181
+ // (u_mesh.tint is documented as 0..1, matching the default path above).
24832
25182
  const tint = mesh.tint;
24833
- data[off + 0] = tint.r;
24834
- data[off + 1] = tint.g;
24835
- data[off + 2] = tint.b;
25183
+ data[off + 0] = tint.r / 255;
25184
+ data[off + 1] = tint.g / 255;
25185
+ data[off + 2] = tint.b / 255;
24836
25186
  data[off + 3] = tint.a;
24837
25187
  this._device.queue.writeBuffer(resources.meshUniformBuffer, drawCursor * slotBytes, data);
24838
25188
  }
24839
- _getOrCreateCustomPipeline(resources, blendMode, format) {
24840
- const cacheKey = `${blendMode}:${format}`;
25189
+ _getOrCreateCustomPipeline(resources, blendMode, format, stencil) {
25190
+ // The stencil dimension keeps the clip and no-clip variants distinct,
25191
+ // mirroring the default and static-batch caches: a stencil pipeline carries
25192
+ // depth/stencil state and is only valid in a pass with the matching
25193
+ // attachment, so the two are never interchangeable.
25194
+ const cacheKey = `${blendMode}:${format}:${stencil ? 's' : 'n'}`;
24841
25195
  let pipeline = resources.pipelines.get(cacheKey);
24842
25196
  if (pipeline === undefined) {
24843
- pipeline = this._device.createRenderPipeline({
25197
+ const descriptor = {
24844
25198
  layout: resources.pipelineLayout,
24845
25199
  vertex: {
24846
25200
  module: resources.shaderModule,
@@ -24872,7 +25226,14 @@ class WebGpuMeshRenderer extends AbstractWebGpuRenderer {
24872
25226
  topology: 'triangle-list',
24873
25227
  cullMode: 'none',
24874
25228
  },
24875
- });
25229
+ };
25230
+ // While a geometric clip is active the coordinator's pass carries a
25231
+ // depth/stencil attachment; the content pipeline must test stencil ==
25232
+ // reference and leave depth/stencil otherwise inert to match it.
25233
+ if (stencil) {
25234
+ descriptor.depthStencil = stencilContentDepthStencilState();
25235
+ }
25236
+ pipeline = this._device.createRenderPipeline(descriptor);
24876
25237
  resources.pipelines.set(cacheKey, pipeline);
24877
25238
  }
24878
25239
  return pipeline;
@@ -25111,6 +25472,13 @@ const initialParticleCapacity = 1;
25111
25472
  const staticVertexData = new Float32Array([0, 0, 1, 0, 1, 1, 0, 1]);
25112
25473
  const staticIndexData = new Uint16Array([0, 1, 2, 0, 2, 3]);
25113
25474
  class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
25475
+ /**
25476
+ * The particle system's transform is bound as a uniform and each particle is
25477
+ * positioned system-locally, so this renderer never reads the shared transform
25478
+ * storage; the plan player skips writing transform records for particle draws.
25479
+ * @internal
25480
+ */
25481
+ _consumesSharedTransform = false;
25114
25482
  _drawCalls = [];
25115
25483
  _drawCallCount = 0;
25116
25484
  _uniformData = new Float32Array(uniformByteLength / Float32Array.BYTES_PER_ELEMENT);
@@ -25135,12 +25503,6 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
25135
25503
  if (backend === null || !(texture instanceof Texture) || texture.source === null || texture.width === 0 || texture.height === 0 || system.liveCount === 0) {
25136
25504
  return;
25137
25505
  }
25138
- if (backend._passCoordinator.stencilActive) {
25139
- // MVP boundary: stencil clipping supports default-material Sprites, Meshes,
25140
- // and Graphics — not ParticleSystems. Throw at collection time (inside the
25141
- // clip scope's try) so the push/pop balances.
25142
- throw new Error('WebGPU geometry stencil clipping currently supports default-material Sprites, Meshes, and Graphics. ParticleSystem content under a Geometry clip (RenderNode.clip with a Geometry clipShape) is not supported yet. Use a Rectangle clipShape (scissor) or the WebGL2 backend instead.');
25143
- }
25144
25506
  backend.setBlendMode(system.blendMode);
25145
25507
  const drawCallIndex = this._drawCallCount++;
25146
25508
  const drawCall = this._drawCalls[drawCallIndex];
@@ -25199,7 +25561,7 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
25199
25561
  if (particleCount === 0) {
25200
25562
  continue;
25201
25563
  }
25202
- const pipeline = this._getPipeline(drawCall.blendMode, backend.renderTargetFormat);
25564
+ const pipeline = this._getPipeline(drawCall.blendMode, backend.renderTargetFormat, backend._passCoordinator.stencilActive);
25203
25565
  const textureBinding = backend.getTextureBinding(drawCall.texture);
25204
25566
  const textureBindGroup = device.createBindGroup({
25205
25567
  layout: this._textureBindGroupLayout,
@@ -25499,13 +25861,13 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
25499
25861
  }
25500
25862
  return { uvMins: mins, uvMaxs: maxs };
25501
25863
  }
25502
- _getPipeline(blendMode, format) {
25503
- const pipelineKey = `${blendMode}:${format}`;
25864
+ _getPipeline(blendMode, format, stencil) {
25865
+ const pipelineKey = `${blendMode}:${format}:${stencil ? 's' : 'n'}`;
25504
25866
  const existingPipeline = this._pipelines.get(pipelineKey);
25505
25867
  if (existingPipeline) {
25506
25868
  return existingPipeline;
25507
25869
  }
25508
- const pipeline = this._device.createRenderPipeline({
25870
+ const descriptor = {
25509
25871
  layout: this._pipelineLayout,
25510
25872
  vertex: {
25511
25873
  module: this._shaderModule,
@@ -25573,7 +25935,11 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
25573
25935
  primitive: {
25574
25936
  topology: 'triangle-list',
25575
25937
  },
25576
- });
25938
+ };
25939
+ if (stencil) {
25940
+ descriptor.depthStencil = stencilContentDepthStencilState();
25941
+ }
25942
+ const pipeline = this._device.createRenderPipeline(descriptor);
25577
25943
  this._pipelines.set(pipelineKey, pipeline);
25578
25944
  return pipeline;
25579
25945
  }
@@ -25584,8 +25950,8 @@ class WebGpuParticleRenderer extends AbstractWebGpuRenderer {
25584
25950
  * WebGPU implementation of {@link RenderPassCoordinator}.
25585
25951
  *
25586
25952
  * Owns the GPU render-pass mechanics (acquire/end the active
25587
- * `GPURenderPassEncoder`), the clear-vs-load decision, and from phase 12E —
25588
- * geometric stencil clipping: a per-target `depth24plus-stencil8` attachment
25953
+ * `GPURenderPassEncoder`), the clear-vs-load decision, and geometric stencil
25954
+ * clipping: a per-target `depth24plus-stencil8` attachment
25589
25955
  * shared across the multiple submits of a clip scope (via `stencilLoadOp:'load'`)
25590
25956
  * plus a position-only stencil-write pipeline. Renderers select stencil-enabled
25591
25957
  * content pipelines while {@link stencilActive} is true.
@@ -25849,8 +26215,16 @@ struct ProjectionUniforms {
25849
26215
  matrix: mat4x4<f32>,
25850
26216
  };
25851
26217
 
26218
+ struct TransformSlot {
26219
+ m0: vec4<f32>,
26220
+ m1: vec4<f32>,
26221
+ m2: vec4<f32>,
26222
+ };
26223
+
25852
26224
  @group(0) @binding(0)
25853
26225
  var<uniform> projection: ProjectionUniforms;
26226
+ @group(0) @binding(1)
26227
+ var<storage, read> transforms: array<TransformSlot>;
25854
26228
 
25855
26229
  @group(1) @binding(0)
25856
26230
  var spriteTexture0: texture_2d<f32>;
@@ -25886,16 +26260,17 @@ var spriteSampler6: sampler;
25886
26260
  @group(1) @binding(15)
25887
26261
  var spriteSampler7: sampler;
25888
26262
 
25889
- // Per-instance vertex layout (56 bytes per sprite). The four corners
26263
+ // Per-instance vertex layout (36 bytes per sprite). The four corners
25890
26264
  // of the quad are derived from @builtin(vertex_index) 0..3 inside the
25891
- // vertex shader — there is no per-vertex stream.
26265
+ // vertex shader — there is no per-vertex stream. The world transform is
26266
+ // fetched from the shared transform storage buffer keyed by nodeIndex
26267
+ // instead of being packed inline.
25892
26268
  struct VertexInput {
25893
26269
  @location(0) localBounds: vec4<f32>, // left, top, right, bottom (local space)
25894
- @location(1) transformAB: vec3<f32>, // first row of 2D affine
25895
- @location(2) transformCD: vec3<f32>, // second row of 2D affine
25896
26270
  @location(3) uvBounds: vec4<f32>, // uMin, vMin, uMax, vMax (CPU pre-swaps for flipY)
25897
26271
  @location(4) color: vec4<f32>, // RGBA tint
25898
26272
  @location(5) packedSlotFlags: u32, // bits 0..7 = slot, bit 8 = premultiply
26273
+ @location(6) nodeIndex: u32, // row into the shared transform storage buffer
25899
26274
  };
25900
26275
 
25901
26276
  struct VertexOutput {
@@ -25918,8 +26293,12 @@ fn vertexMain(input: VertexInput, @builtin(vertex_index) vid: u32) -> VertexOutp
25918
26293
  let localX = select(input.localBounds.x, input.localBounds.z, cornerX == 1u);
25919
26294
  let localY = select(input.localBounds.y, input.localBounds.w, cornerY == 1u);
25920
26295
 
25921
- let worldX = input.transformAB.x * localX + input.transformAB.y * localY + input.transformAB.z;
25922
- let worldY = input.transformCD.x * localX + input.transformCD.y * localY + input.transformCD.z;
26296
+ // Fetch this instance's world transform from the shared storage buffer,
26297
+ // keyed by nodeIndex: m0 = (a, b, c, d), m1 = (tx, ty, 0, 0). (m2 carries the
26298
+ // node tint, unused here — the sprite keeps its own per-instance color.)
26299
+ let slot = transforms[input.nodeIndex];
26300
+ let worldX = slot.m0.x * localX + slot.m0.y * localY + slot.m1.x;
26301
+ let worldY = slot.m0.z * localX + slot.m0.w * localY + slot.m1.y;
25923
26302
 
25924
26303
  output.position = projection.matrix * vec4<f32>(worldX, worldY, 0.0, 1.0);
25925
26304
 
@@ -25979,7 +26358,7 @@ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
25979
26358
  return resolvedSample * input.color;
25980
26359
  }
25981
26360
  `;
25982
- const instanceStrideBytes = 56;
26361
+ const instanceStrideBytes = 36;
25983
26362
  const wordsPerInstance = instanceStrideBytes / Uint32Array.BYTES_PER_ELEMENT;
25984
26363
  const projectionByteLength = 64;
25985
26364
  const initialBatchCapacity = 32;
@@ -25997,7 +26376,10 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
25997
26376
  _textureBindGroupLayout = null;
25998
26377
  _pipelineLayout = null;
25999
26378
  _uniformBuffer = null;
26000
- _uniformBindGroup = null;
26379
+ // group(0) bind group = projection UBO + shared transform storage buffer.
26380
+ // Recreated whenever the storage buffer identity changes (capacity growth).
26381
+ _transformBindGroup = null;
26382
+ _transformStorageBuffer = null;
26001
26383
  _indexBuffer = null;
26002
26384
  _instanceBuffer = null;
26003
26385
  _instanceCapacity = 0;
@@ -26009,6 +26391,9 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26009
26391
  _textureSlots = new Map();
26010
26392
  _slotCount = 0;
26011
26393
  _instanceCount = 0;
26394
+ // Highest transform-storage row referenced by the pending batch; drives the
26395
+ // minimum row count uploaded for the storage buffer at flush time.
26396
+ _maxNodeIndex = 0;
26012
26397
  _currentBlendMode = null;
26013
26398
  // Custom-material state. Per-material pipelines/bind groups are cached; the
26014
26399
  // current batch's material/base-texture decide when to flush.
@@ -26031,6 +26416,13 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26031
26416
  type: 'uniform',
26032
26417
  },
26033
26418
  },
26419
+ {
26420
+ binding: 1,
26421
+ visibility: GPUShaderStage.VERTEX,
26422
+ buffer: {
26423
+ type: 'read-only-storage',
26424
+ },
26425
+ },
26034
26426
  ],
26035
26427
  });
26036
26428
  this._textureBindGroupLayout = this._device.createBindGroupLayout({
@@ -26065,17 +26457,9 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26065
26457
  size: projectionByteLength,
26066
26458
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
26067
26459
  });
26068
- this._uniformBindGroup = this._device.createBindGroup({
26069
- layout: this._uniformBindGroupLayout,
26070
- entries: [
26071
- {
26072
- binding: 0,
26073
- resource: {
26074
- buffer: this._uniformBuffer,
26075
- },
26076
- },
26077
- ],
26078
- });
26460
+ // The group(0) bind group also binds the shared transform storage buffer,
26461
+ // whose identity changes when its capacity grows — so it is built lazily in
26462
+ // flush() once the active storage buffer is known.
26079
26463
  // Static index buffer for the quad. Allocated once at connect; its
26080
26464
  // contents never change.
26081
26465
  this._indexBuffer = this._device.createBuffer({
@@ -26098,7 +26482,8 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26098
26482
  this._pipelines.clear();
26099
26483
  this._instanceBuffer = null;
26100
26484
  this._indexBuffer = null;
26101
- this._uniformBindGroup = null;
26485
+ this._transformBindGroup = null;
26486
+ this._transformStorageBuffer = null;
26102
26487
  this._uniformBuffer = null;
26103
26488
  this._pipelineLayout = null;
26104
26489
  this._customBaseTextureLayout = null;
@@ -26112,6 +26497,7 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26112
26497
  this._instanceFloat32 = new Float32Array(this._instanceData);
26113
26498
  this._instanceUint32 = new Uint32Array(this._instanceData);
26114
26499
  this._instanceCount = 0;
26500
+ this._maxNodeIndex = 0;
26115
26501
  this._currentBlendMode = null;
26116
26502
  this._currentMaterial = null;
26117
26503
  this._currentBaseTexture = null;
@@ -26129,15 +26515,21 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26129
26515
  return;
26130
26516
  }
26131
26517
  const material = sprite.material;
26518
+ // The transform lives in the shared storage buffer, keyed by the draw
26519
+ // command's stable nodeIndex (already packed at the draw-command boundary).
26520
+ // A direct, non-plan `backend.draw(sprite)` has no command — push the
26521
+ // sprite's transform into the buffer and use the freshly-allocated slot.
26522
+ const command = backend.activeDrawCommand;
26523
+ const nodeIndex = command !== null ? command.nodeIndex : backend._pushTransform(sprite);
26132
26524
  if (material === null) {
26133
- this._renderDefault(sprite, texture, backend);
26525
+ this._renderDefault(sprite, texture, backend, nodeIndex);
26134
26526
  }
26135
26527
  else {
26136
- this._renderCustom(sprite, texture, material, backend);
26528
+ this._renderCustom(sprite, texture, material, backend, nodeIndex);
26137
26529
  }
26138
26530
  }
26139
26531
  /** Default multi-texture path: rotate the base texture through 8 slots. */
26140
- _renderDefault(sprite, texture, backend) {
26532
+ _renderDefault(sprite, texture, backend, nodeIndex) {
26141
26533
  const blendMode = sprite.blendMode;
26142
26534
  // Flush triggers: blend-mode change, texture-slot exhaustion, or a custom
26143
26535
  // batch still in flight that must drain first.
@@ -26163,20 +26555,14 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26163
26555
  // typed-array writes in _packInstance silently fall off the end of a
26164
26556
  // too-small buffer.
26165
26557
  this._ensureInstanceCapacity(this._instanceCount + 1);
26166
- this._packInstance(sprite, texture, packedSlotFlags);
26558
+ this._packInstance(sprite, texture, packedSlotFlags, nodeIndex);
26167
26559
  this._instanceCount++;
26168
26560
  }
26169
26561
  /** Custom-material path: single base texture on group(1), instanced. */
26170
- _renderCustom(sprite, texture, material, backend) {
26562
+ _renderCustom(sprite, texture, material, backend, nodeIndex) {
26171
26563
  if (material.shader.wgsl === null) {
26172
26564
  throw new Error('SpriteMaterial shader has no `wgsl` source; cannot render through the WebGPU backend.');
26173
26565
  }
26174
- if (backend._passCoordinator.stencilActive) {
26175
- // MVP boundary: default-material Sprites, Meshes, and Graphics support the
26176
- // stencil pipeline variants; a custom-material Sprite does not yet. Throw at
26177
- // collection time so the clip scope's push/pop balances.
26178
- throw new Error('WebGPU geometry stencil clipping currently supports default-material Sprites, Meshes, and Graphics. A custom-material Sprite under a Geometry clip (RenderNode.clip with a Geometry clipShape) is not supported yet. Use a Rectangle clipShape (scissor) or the WebGL2 backend instead.');
26179
- }
26180
26566
  // The material owns its blend mode; the sprite's own blendMode overrides it
26181
26567
  // when set away from the default (Normal).
26182
26568
  const blendMode = sprite.blendMode === BlendModes.Normal ? material.blendMode : sprite.blendMode;
@@ -26193,15 +26579,14 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26193
26579
  backend.setBlendMode(blendMode);
26194
26580
  // textureSlot word is unused by custom fragments (base binds to group(1)).
26195
26581
  this._ensureInstanceCapacity(this._instanceCount + 1);
26196
- this._packInstance(sprite, texture, 0);
26582
+ this._packInstance(sprite, texture, 0, nodeIndex);
26197
26583
  this._instanceCount++;
26198
26584
  }
26199
26585
  flush() {
26200
26586
  const backend = this._backend;
26201
26587
  const device = this._device;
26202
26588
  const uniformBuffer = this._uniformBuffer;
26203
- const uniformBindGroup = this._uniformBindGroup;
26204
- if (!backend || !device || !uniformBuffer || !uniformBindGroup) {
26589
+ if (!backend || !device || !uniformBuffer) {
26205
26590
  return;
26206
26591
  }
26207
26592
  if (this._instanceCount === 0 && !backend.clearRequested) {
@@ -26218,13 +26603,19 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26218
26603
  const pass = backend._passCoordinator.acquirePass().pass;
26219
26604
  if (this._instanceCount > 0 && !maskClipsAll && this._instanceBuffer !== null && this._indexBuffer !== null && this._currentBlendMode !== null) {
26220
26605
  device.queue.writeBuffer(this._instanceBuffer, 0, this._instanceData, 0, this._instanceCount * instanceStrideBytes);
26606
+ // Resolve the shared transform storage buffer (rows uploaded up to the
26607
+ // max nodeIndex referenced by this batch) and bind it alongside the
26608
+ // projection UBO on group(0). Both the default and custom programs fetch
26609
+ // the world transform from it via nodeIndex.
26610
+ const storage = backend.getTransformStorageBuffer(this._maxNodeIndex + 1);
26611
+ const transformBindGroup = this._getOrCreateTransformBindGroup(device, uniformBuffer, storage.buffer);
26221
26612
  const material = this._currentMaterial;
26222
26613
  const stencil = backend._passCoordinator.stencilActive;
26223
26614
  if (material === null) {
26224
26615
  const pipeline = this._getPipeline(this._currentBlendMode, backend.renderTargetFormat, stencil);
26225
26616
  const textureBindGroup = this._createTextureBindGroup(device, backend);
26226
26617
  pass.setPipeline(pipeline);
26227
- pass.setBindGroup(0, uniformBindGroup);
26618
+ pass.setBindGroup(0, transformBindGroup);
26228
26619
  pass.setBindGroup(1, textureBindGroup);
26229
26620
  pass.setVertexBuffer(0, this._instanceBuffer);
26230
26621
  pass.setIndexBuffer(this._indexBuffer, 'uint16');
@@ -26232,7 +26623,7 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26232
26623
  }
26233
26624
  else {
26234
26625
  pass.pushDebugGroup('SpriteMaterial (custom)');
26235
- this._drawCustomBatch(pass, device, backend, material, uniformBindGroup);
26626
+ this._drawCustomBatch(pass, device, backend, material, transformBindGroup, stencil);
26236
26627
  pass.popDebugGroup();
26237
26628
  }
26238
26629
  backend.stats.batches++;
@@ -26240,11 +26631,31 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26240
26631
  }
26241
26632
  backend._passCoordinator.endPass();
26242
26633
  this._instanceCount = 0;
26634
+ this._maxNodeIndex = 0;
26243
26635
  this._resetSlots();
26244
26636
  this._currentBlendMode = null;
26245
26637
  this._currentMaterial = null;
26246
26638
  this._currentBaseTexture = null;
26247
26639
  }
26640
+ /**
26641
+ * Build (or reuse) the group(0) bind group pairing the fixed projection UBO
26642
+ * with the shared transform storage buffer. Cached against the storage buffer
26643
+ * identity, which changes only when its capacity grows.
26644
+ */
26645
+ _getOrCreateTransformBindGroup(device, uniformBuffer, storageBuffer) {
26646
+ if (this._transformBindGroup !== null && this._transformStorageBuffer === storageBuffer) {
26647
+ return this._transformBindGroup;
26648
+ }
26649
+ this._transformStorageBuffer = storageBuffer;
26650
+ this._transformBindGroup = device.createBindGroup({
26651
+ layout: this._uniformBindGroupLayout,
26652
+ entries: [
26653
+ { binding: 0, resource: { buffer: uniformBuffer } },
26654
+ { binding: 1, resource: { buffer: storageBuffer } },
26655
+ ],
26656
+ });
26657
+ return this._transformBindGroup;
26658
+ }
26248
26659
  destroy() {
26249
26660
  this.disconnect();
26250
26661
  }
@@ -26291,24 +26702,18 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26291
26702
  }
26292
26703
  await Promise.all(promises);
26293
26704
  }
26294
- _packInstance(sprite, texture, packedSlotFlags) {
26705
+ _packInstance(sprite, texture, packedSlotFlags, nodeIndex) {
26295
26706
  const offset = this._instanceCount * wordsPerInstance;
26296
26707
  const f32 = this._instanceFloat32;
26297
26708
  const u32 = this._instanceUint32;
26709
+ // localBounds: left, top, right, bottom (words 0..3, offset 0)
26298
26710
  const bounds = sprite.getLocalBounds();
26299
26711
  f32[offset + 0] = bounds.left;
26300
26712
  f32[offset + 1] = bounds.top;
26301
26713
  f32[offset + 2] = bounds.right;
26302
26714
  f32[offset + 3] = bounds.bottom;
26303
- const transform = sprite.getGlobalTransform();
26304
- f32[offset + 4] = transform.a;
26305
- f32[offset + 5] = transform.b;
26306
- f32[offset + 6] = transform.x;
26307
- f32[offset + 7] = transform.c;
26308
- f32[offset + 8] = transform.d;
26309
- f32[offset + 9] = transform.y;
26310
- // uvBounds: u16x4 normalised, packed into two u32 slots. The CPU
26311
- // applies the flipY swap so the shader stays orientation-agnostic.
26715
+ // uvBounds: u16x4 normalised, packed into two u32 slots (words 4,5, offset
26716
+ // 16). The CPU applies the flipY swap so the shader stays orientation-agnostic.
26312
26717
  const frame = sprite.textureFrame;
26313
26718
  const texWidth = texture.width;
26314
26719
  const texHeight = texture.height;
@@ -26319,10 +26724,18 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26319
26724
  const flipY = texture instanceof Texture && texture.flipY;
26320
26725
  const vMin = flipY ? vMaxRaw : vMinRaw;
26321
26726
  const vMax = flipY ? vMinRaw : vMaxRaw;
26322
- u32[offset + 10] = uMin | (vMin << 16);
26323
- u32[offset + 11] = uMax | (vMax << 16);
26324
- u32[offset + 12] = sprite.tint.toRgba();
26325
- u32[offset + 13] = packedSlotFlags;
26727
+ u32[offset + 4] = uMin | (vMin << 16);
26728
+ u32[offset + 5] = uMax | (vMax << 16);
26729
+ // color (u8x4 packed) at word 6 (offset 24)
26730
+ u32[offset + 6] = sprite.tint.toRgba();
26731
+ // packedSlotFlags (u32) at word 7 (offset 28)
26732
+ u32[offset + 7] = packedSlotFlags;
26733
+ // nodeIndex (u32) at word 8 (offset 32) — row into the shared transform buffer.
26734
+ const node = nodeIndex >>> 0;
26735
+ u32[offset + 8] = node;
26736
+ if (node > this._maxNodeIndex) {
26737
+ this._maxNodeIndex = node;
26738
+ }
26326
26739
  }
26327
26740
  _ensureInstanceCapacity(instanceCount) {
26328
26741
  if (!this._device || instanceCount <= this._instanceCapacity) {
@@ -26424,29 +26837,24 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26424
26837
  offset: 0,
26425
26838
  format: 'float32x4',
26426
26839
  },
26427
- {
26428
- shaderLocation: 1,
26429
- offset: 16,
26430
- format: 'float32x3',
26431
- },
26432
- {
26433
- shaderLocation: 2,
26434
- offset: 28,
26435
- format: 'float32x3',
26436
- },
26437
26840
  {
26438
26841
  shaderLocation: 3,
26439
- offset: 40,
26842
+ offset: 16,
26440
26843
  format: 'unorm16x4',
26441
26844
  },
26442
26845
  {
26443
26846
  shaderLocation: 4,
26444
- offset: 48,
26847
+ offset: 24,
26445
26848
  format: 'unorm8x4',
26446
26849
  },
26447
26850
  {
26448
26851
  shaderLocation: 5,
26449
- offset: 52,
26852
+ offset: 28,
26853
+ format: 'uint32',
26854
+ },
26855
+ {
26856
+ shaderLocation: 6,
26857
+ offset: 32,
26450
26858
  format: 'uint32',
26451
26859
  },
26452
26860
  ],
@@ -26476,14 +26884,14 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26476
26884
  // ---------------------------------------------------------------------------
26477
26885
  // Custom-material path
26478
26886
  // ---------------------------------------------------------------------------
26479
- _drawCustomBatch(pass, device, backend, material, projectionBindGroup) {
26887
+ _drawCustomBatch(pass, device, backend, material, transformBindGroup, stencil) {
26480
26888
  const resources = this._getOrCreateCustomResources(material, device);
26481
26889
  const baseTexture = this._currentBaseTexture ?? Texture.empty;
26482
26890
  // Re-built every frame so mutations to material.uniforms.X are picked up.
26483
26891
  this._uploadUserUniforms(material, resources, device);
26484
- const pipeline = this._getOrCreateCustomPipeline(resources, this._currentBlendMode, backend.renderTargetFormat, device);
26892
+ const pipeline = this._getOrCreateCustomPipeline(resources, this._currentBlendMode, backend.renderTargetFormat, stencil, device);
26485
26893
  pass.setPipeline(pipeline);
26486
- pass.setBindGroup(0, projectionBindGroup);
26894
+ pass.setBindGroup(0, transformBindGroup);
26487
26895
  pass.setBindGroup(1, this._getCustomBaseTextureBindGroup(resources, backend, baseTexture, device));
26488
26896
  pass.setBindGroup(2, this._buildUserBindGroup(material, resources, backend, device));
26489
26897
  pass.setVertexBuffer(0, this._instanceBuffer);
@@ -26500,8 +26908,8 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26500
26908
  throw new Error('SpriteMaterial shader has no `wgsl` source; cannot render through the WebGPU backend.');
26501
26909
  }
26502
26910
  // The engine owns the vertex stage: prepend the canonical sprite vertex
26503
- // module (VertexInput/VertexOutput, group(0) projection, group(1) base
26504
- // texture + sampler) to the material's fragment WGSL.
26911
+ // module (VertexInput/VertexOutput, group(0) projection + transform storage,
26912
+ // group(1) base texture + sampler) to the material's fragment WGSL.
26505
26913
  const shaderModule = device.createShaderModule({ code: `${spriteVertexWgsl}\n${wgsl}` });
26506
26914
  const userLayout = this._buildUserBindGroupLayout(device, material);
26507
26915
  const pipelineLayout = device.createPipelineLayout({
@@ -26526,13 +26934,13 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26526
26934
  });
26527
26935
  return resources;
26528
26936
  }
26529
- _getOrCreateCustomPipeline(resources, blendMode, format, device) {
26530
- const cacheKey = `${blendMode}:${format}`;
26937
+ _getOrCreateCustomPipeline(resources, blendMode, format, stencil, device) {
26938
+ const cacheKey = `${blendMode}:${format}:${stencil ? 's' : 'n'}`;
26531
26939
  const existing = resources.pipelines.get(cacheKey);
26532
26940
  if (existing !== undefined) {
26533
26941
  return existing;
26534
26942
  }
26535
- const pipeline = device.createRenderPipeline({
26943
+ const descriptor = {
26536
26944
  layout: resources.pipelineLayout,
26537
26945
  vertex: {
26538
26946
  module: resources.shaderModule,
@@ -26543,11 +26951,10 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26543
26951
  stepMode: 'instance',
26544
26952
  attributes: [
26545
26953
  { shaderLocation: 0, offset: 0, format: 'float32x4' },
26546
- { shaderLocation: 1, offset: 16, format: 'float32x3' },
26547
- { shaderLocation: 2, offset: 28, format: 'float32x3' },
26548
- { shaderLocation: 3, offset: 40, format: 'unorm16x4' },
26549
- { shaderLocation: 4, offset: 48, format: 'unorm8x4' },
26550
- { shaderLocation: 5, offset: 52, format: 'uint32' },
26954
+ { shaderLocation: 3, offset: 16, format: 'unorm16x4' },
26955
+ { shaderLocation: 4, offset: 24, format: 'unorm8x4' },
26956
+ { shaderLocation: 5, offset: 28, format: 'uint32' },
26957
+ { shaderLocation: 6, offset: 32, format: 'uint32' },
26551
26958
  ],
26552
26959
  },
26553
26960
  ],
@@ -26566,7 +26973,11 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
26566
26973
  primitive: {
26567
26974
  topology: 'triangle-list',
26568
26975
  },
26569
- });
26976
+ };
26977
+ if (stencil) {
26978
+ descriptor.depthStencil = stencilContentDepthStencilState();
26979
+ }
26980
+ const pipeline = device.createRenderPipeline(descriptor);
26570
26981
  resources.pipelines.set(cacheKey, pipeline);
26571
26982
  return pipeline;
26572
26983
  }
@@ -26913,6 +27324,13 @@ fn fragmentColor(in: VertexOutput) -> @location(0) vec4<f32> {
26913
27324
  * render pass.
26914
27325
  */
26915
27326
  class WebGpuTextRenderer extends AbstractWebGpuRenderer {
27327
+ /**
27328
+ * Text packs its world transform into its own per-node data buffer and never
27329
+ * reads the shared transform storage, so the plan player skips writing
27330
+ * transform records for text draws.
27331
+ * @internal
27332
+ */
27333
+ _consumesSharedTransform = false;
26916
27334
  _device = null;
26917
27335
  _shaderModule = null;
26918
27336
  _frameBindGroupLayout = null;
@@ -26948,12 +27366,6 @@ class WebGpuTextRenderer extends AbstractWebGpuRenderer {
26948
27366
  render(node) {
26949
27367
  if (!this._device)
26950
27368
  throw new Error('WebGpuTextRenderer is not connected to a backend.');
26951
- if (this.getBackend()._passCoordinator.stencilActive) {
26952
- // MVP boundary: stencil clipping supports default-material Sprites, Meshes,
26953
- // and Graphics — not Text. Throw at collection time (inside the clip scope's
26954
- // try) so the push/pop balances.
26955
- throw new Error('WebGPU geometry stencil clipping currently supports default-material Sprites, Meshes, and Graphics. Text content under a Geometry clip (RenderNode.clip with a Geometry clipShape) is not supported yet. Use a Rectangle clipShape (scissor) or the WebGL2 backend instead.');
26956
- }
26957
27369
  if (node instanceof Text) {
26958
27370
  this._collectText(node);
26959
27371
  }
@@ -27055,6 +27467,7 @@ class WebGpuTextRenderer extends AbstractWebGpuRenderer {
27055
27467
  device.queue.writeBuffer(this._vertexBuffer, 0, this._vertexData, 0, packedV * vertexStrideBytes$1);
27056
27468
  device.queue.writeBuffer(this._indexBuffer, 0, this._indexData.buffer, 0, packedI * 2);
27057
27469
  const format = backend.renderTargetFormat;
27470
+ const stencil = backend._passCoordinator.stencilActive;
27058
27471
  const frameBindGroup = this._getFrameBindGroup(device);
27059
27472
  // The coordinator owns the GPU pass (load/clear resolution, pass count and
27060
27473
  // scissor are applied there) and ends + submits it below.
@@ -27065,7 +27478,7 @@ class WebGpuTextRenderer extends AbstractWebGpuRenderer {
27065
27478
  let lastTexture = null;
27066
27479
  for (const batch of batches) {
27067
27480
  if (batch.shaderType !== lastShaderType) {
27068
- pass.setPipeline(this._getPipeline(batch.shaderType, format));
27481
+ pass.setPipeline(this._getPipeline(batch.shaderType, format, stencil));
27069
27482
  pass.setBindGroup(0, frameBindGroup);
27070
27483
  lastShaderType = batch.shaderType;
27071
27484
  }
@@ -27098,7 +27511,9 @@ class WebGpuTextRenderer extends AbstractWebGpuRenderer {
27098
27511
  const promises = [];
27099
27512
  for (const shaderType of shaderTypes) {
27100
27513
  for (const format of formats) {
27101
- const key = `${shaderType}:${format}`;
27514
+ // Prewarm only the no-clip variant (matches the _getPipeline cache key
27515
+ // for stencil = false); stencil variants compile lazily under a clip.
27516
+ const key = `${shaderType}:${format}:n`;
27102
27517
  if (this._pipelines.has(key))
27103
27518
  continue;
27104
27519
  promises.push(device.createRenderPipelineAsync(this._buildPipelineDescriptor(shaderType, format)).then(pipeline => {
@@ -27377,16 +27792,16 @@ class WebGpuTextRenderer extends AbstractWebGpuRenderer {
27377
27792
  return group;
27378
27793
  }
27379
27794
  // ── Pipeline helpers ─────────────────────────────────────────────────────
27380
- _getPipeline(shaderType, format) {
27381
- const key = `${shaderType}:${format}`;
27795
+ _getPipeline(shaderType, format, stencil) {
27796
+ const key = `${shaderType}:${format}:${stencil ? 's' : 'n'}`;
27382
27797
  const existing = this._pipelines.get(key);
27383
27798
  if (existing)
27384
27799
  return existing;
27385
- const pipeline = this._device.createRenderPipeline(this._buildPipelineDescriptor(shaderType, format));
27800
+ const pipeline = this._device.createRenderPipeline(this._buildPipelineDescriptor(shaderType, format, stencil));
27386
27801
  this._pipelines.set(key, pipeline);
27387
27802
  return pipeline;
27388
27803
  }
27389
- _buildPipelineDescriptor(shaderType, format) {
27804
+ _buildPipelineDescriptor(shaderType, format, stencil = false) {
27390
27805
  let fragEntry;
27391
27806
  if (shaderType === 'sdf') {
27392
27807
  fragEntry = 'fragmentSdf';
@@ -27397,7 +27812,7 @@ class WebGpuTextRenderer extends AbstractWebGpuRenderer {
27397
27812
  else {
27398
27813
  fragEntry = 'fragmentColor';
27399
27814
  }
27400
- return {
27815
+ const descriptor = {
27401
27816
  label: `WebGpuTextRenderer/${shaderType}`,
27402
27817
  layout: this._pipelineLayout,
27403
27818
  vertex: {
@@ -27428,6 +27843,10 @@ class WebGpuTextRenderer extends AbstractWebGpuRenderer {
27428
27843
  },
27429
27844
  primitive: { topology: 'triangle-list' },
27430
27845
  };
27846
+ if (stencil) {
27847
+ descriptor.depthStencil = stencilContentDepthStencilState();
27848
+ }
27849
+ return descriptor;
27431
27850
  }
27432
27851
  // ── Capacity helpers ─────────────────────────────────────────────────────
27433
27852
  _ensureVertexCapacity(vertexCount) {
@@ -27471,6 +27890,14 @@ class WebGpuTransformStorage {
27471
27890
  _storageCapacity = 0;
27472
27891
  _storageHash = 0;
27473
27892
  _storageCount = -1;
27893
+ /**
27894
+ * Underlying shared transform buffer. Exposed for internal stats / tests
27895
+ * (write, skip and upload counters); not part of any public surface.
27896
+ * @internal
27897
+ */
27898
+ get buffer() {
27899
+ return this._buffer;
27900
+ }
27474
27901
  begin(nodeCount) {
27475
27902
  this._buffer.begin(nodeCount);
27476
27903
  }
@@ -27478,27 +27905,51 @@ class WebGpuTransformStorage {
27478
27905
  const drawable = command.drawable;
27479
27906
  this._buffer.write(command.nodeIndex, drawable.getGlobalTransform(), drawable.tint);
27480
27907
  }
27908
+ /**
27909
+ * Record that a draw command's transform write was skipped because its
27910
+ * renderer opts out of the shared transform storage. Stats only.
27911
+ * @internal
27912
+ */
27913
+ recordSkippedWrite() {
27914
+ this._buffer.recordSkippedWrite();
27915
+ }
27916
+ /**
27917
+ * Append a drawable's world transform (+ tint) to the shared buffer and return
27918
+ * the slot it was written to. Used for draws that arrive without a stable
27919
+ * `nodeIndex` — a direct `backend.draw(drawable)` outside the plan player —
27920
+ * so a batch of synthetic draws does not collide on a single row.
27921
+ */
27922
+ push(drawable) {
27923
+ return this._buffer.push(drawable.getGlobalTransform(), drawable.tint);
27924
+ }
27925
+ /**
27926
+ * Pre-allocate (or grow) the GPU storage buffer to hold at least
27927
+ * `recordCount` transform slots. Called once before render-plan playback so
27928
+ * that later per-group flushes never trigger a mid-frame reallocation.
27929
+ *
27930
+ * If the buffer already covers `recordCount` this is a no-op — no GPU
27931
+ * objects are destroyed or re-created. Capacity only grows, never shrinks.
27932
+ * @internal
27933
+ */
27934
+ reserve(device, recordCount) {
27935
+ const minCount = Math.max(1, recordCount);
27936
+ const requiredBytes = minCount * slotFloatCount * Float32Array.BYTES_PER_ELEMENT;
27937
+ if (this._storageBuffer !== null && requiredBytes <= this._storageCapacity) {
27938
+ return;
27939
+ }
27940
+ this._growBuffer(device, requiredBytes);
27941
+ }
27481
27942
  getBuffer(device, minCount) {
27482
27943
  const requiredCount = Math.max(1, minCount);
27483
27944
  const requiredBytes = requiredCount * slotFloatCount * Float32Array.BYTES_PER_ELEMENT;
27484
27945
  const snapshot = this._buffer.commitSnapshot(requiredCount);
27485
27946
  if (this._storageBuffer === null || requiredBytes > this._storageCapacity) {
27486
- let nextCapacity = Math.max(this._storageCapacity, slotFloatCount * Float32Array.BYTES_PER_ELEMENT);
27487
- while (nextCapacity < requiredBytes) {
27488
- nextCapacity *= 2;
27489
- }
27490
- this._storageBuffer?.destroy();
27491
- this._storageBuffer = device.createBuffer({
27492
- size: nextCapacity,
27493
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
27494
- });
27495
- this._storageCapacity = nextCapacity;
27496
- this._storageHash = 0;
27497
- this._storageCount = -1;
27947
+ this._growBuffer(device, requiredBytes);
27498
27948
  }
27499
27949
  if (snapshot.changed || snapshot.hash !== this._storageHash || snapshot.count !== this._storageCount) {
27500
27950
  const bytes = snapshot.count * slotFloatCount * Float32Array.BYTES_PER_ELEMENT;
27501
27951
  device.queue.writeBuffer(this._storageBuffer, 0, this._buffer.data.buffer, this._buffer.data.byteOffset, bytes);
27952
+ this._buffer.recordUpload(snapshot.count);
27502
27953
  this._storageHash = snapshot.hash;
27503
27954
  this._storageCount = snapshot.count;
27504
27955
  }
@@ -27514,6 +27965,20 @@ class WebGpuTransformStorage {
27514
27965
  this._storageHash = 0;
27515
27966
  this._storageCount = -1;
27516
27967
  }
27968
+ _growBuffer(device, requiredBytes) {
27969
+ let nextCapacity = Math.max(this._storageCapacity, slotFloatCount * Float32Array.BYTES_PER_ELEMENT);
27970
+ while (nextCapacity < requiredBytes) {
27971
+ nextCapacity *= 2;
27972
+ }
27973
+ this._storageBuffer?.destroy();
27974
+ this._storageBuffer = device.createBuffer({
27975
+ size: nextCapacity,
27976
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
27977
+ });
27978
+ this._storageCapacity = nextCapacity;
27979
+ this._storageHash = 0;
27980
+ this._storageCount = -1;
27981
+ }
27517
27982
  }
27518
27983
 
27519
27984
  /* eslint-disable max-lines */
@@ -27638,8 +28103,8 @@ class WebGpuBackend {
27638
28103
  return this._activeDrawCommand;
27639
28104
  }
27640
28105
  /**
27641
- * Internal render-pass coordinator. Owns the clear-vs-load decision and (from
27642
- * phase 12D) the active render pass; not part of the public
28106
+ * Internal render-pass coordinator. Owns the clear-vs-load decision and the
28107
+ * active render pass; not part of the public
27643
28108
  * {@link RenderBackend} surface.
27644
28109
  * @internal
27645
28110
  */
@@ -27671,14 +28136,46 @@ class WebGpuBackend {
27671
28136
  }
27672
28137
  /** @internal */
27673
28138
  _beginDrawPlan(nodeCount) {
27674
- this._getTransformStorage().begin(nodeCount);
28139
+ const storage = this._getTransformStorage();
28140
+ storage.begin(nodeCount);
28141
+ // Pre-allocate the GPU storage buffer for the full plan before any group
28142
+ // flush runs. Without this, a later flush with a higher maxNodeIndex would
28143
+ // destroy and replace the buffer mid-frame while earlier command buffers
28144
+ // may still reference the old allocation.
28145
+ if (nodeCount > 0 && this._device !== null && !this._deviceLost) {
28146
+ storage.reserve(this._device, nodeCount);
28147
+ }
27675
28148
  this._activeDrawCommand = null;
27676
28149
  this._drawPlanDepth++;
27677
28150
  }
27678
28151
  /** @internal */
28152
+ _prepareRenderGroupUpload(group) {
28153
+ // Pack the whole render group's world transforms (+ tint) into the shared
28154
+ // transform storage at the group's upload boundary, keyed by each draw
28155
+ // command's stable nodeIndex. Every draw the player will submit for this
28156
+ // group is covered here, before the group's first draw.
28157
+ //
28158
+ // Renderers that pack their own per-node data (Text, Particle) never read
28159
+ // the shared storage, so their commands are skipped — no consuming draw
28160
+ // ever references their slots (nodeIndex is unique per command).
28161
+ const storage = this._getTransformStorage();
28162
+ const instructions = group.instructions;
28163
+ for (let i = 0; i < instructions.length; i++) {
28164
+ const command = instructions[i];
28165
+ if (drawCommandUsesSharedTransform(command, this)) {
28166
+ storage.writeCommand(command);
28167
+ }
28168
+ else {
28169
+ storage.recordSkippedWrite();
28170
+ }
28171
+ }
28172
+ }
28173
+ /** @internal */
27679
28174
  _prepareDrawCommand(command) {
28175
+ // Transform packing now happens at the render-group upload boundary
28176
+ // (`_prepareRenderGroupUpload`); this hook only tracks the active draw so
28177
+ // renderers can read the current command's nodeIndex.
27680
28178
  this._activeDrawCommand = command;
27681
- this._getTransformStorage().writeCommand(command);
27682
28179
  }
27683
28180
  /** @internal */
27684
28181
  _endDrawPlan() {
@@ -27991,6 +28488,18 @@ class WebGpuBackend {
27991
28488
  getTransformStorageBuffer(minCount) {
27992
28489
  return this._getTransformStorage().getBuffer(this.device, minCount);
27993
28490
  }
28491
+ /**
28492
+ * Append a drawable's world transform (+ tint) to the shared transform storage
28493
+ * and return the slot it was written to. Used by instanced renderers for draws
28494
+ * that arrive without a render-group upload boundary — i.e. a direct
28495
+ * `backend.draw(drawable)` outside the plan player (`activeDrawCommand === null`),
28496
+ * where no stable `nodeIndex` was assigned. Each call allocates a fresh slot, so
28497
+ * a batch of synthetic draws does not collide on a single row.
28498
+ * @internal
28499
+ */
28500
+ _pushTransform(drawable) {
28501
+ return this._getTransformStorage().push(drawable);
28502
+ }
27994
28503
  _setActiveRenderer(renderer) {
27995
28504
  if (this._renderer !== renderer) {
27996
28505
  this._flushActiveRenderer();
@@ -31486,7 +31995,7 @@ class Loader {
31486
31995
  return `id:${this._getTypeId(type)}:${discriminator}`;
31487
31996
  }
31488
31997
  _resolveUrl(path) {
31489
- if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//')) {
31998
+ if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//') || path.startsWith('/')) {
31490
31999
  return path;
31491
32000
  }
31492
32001
  return `${this._basePath}${path}`;
@@ -35077,7 +35586,10 @@ const lookupSize$4 = 256;
35077
35586
  */
35078
35587
  class AlphaFadeOverLifetime extends UpdateModule {
35079
35588
  curve;
35080
- constructor(curve) {
35589
+ constructor(curve = new Curve([
35590
+ { t: 0, v: 1 },
35591
+ { t: 1, v: 0 },
35592
+ ])) {
35081
35593
  super();
35082
35594
  this.curve = curve;
35083
35595
  }
@@ -37407,20 +37919,53 @@ class Geometry {
37407
37919
 
37408
37920
  const sortedStopOffset = (left, right) => left.offset - right.offset;
37409
37921
  /**
37410
- * CPU-rasterized gradient base that can be converted into a {@link DataTexture}.
37922
+ * CPU-rasterized gradient base. A {@link Color}-like paint value (not a
37923
+ * {@link Drawable}): it owns a normalized list of color stops plus subclass
37924
+ * geometry and supports the value-object contract — {@link Gradient.clone},
37925
+ * {@link Gradient.copy}, and {@link Gradient.equals}. Stops are cloned on the
37926
+ * way in, clamped to `0..1`, and kept sorted by offset.
37927
+ *
37928
+ * Convert a gradient into a sampleable {@link DataTexture} with
37929
+ * {@link Gradient.toTexture}; wrap that texture in a `Sprite`/`Mesh` to draw it.
37411
37930
  */
37412
37931
  class Gradient {
37413
37932
  _stops;
37414
37933
  _sample = new Float32Array(4);
37415
37934
  constructor(stops) {
37416
- if (stops.length < 2) {
37417
- throw new Error('Gradient requires at least 2 color stops.');
37418
- }
37419
- this._stops = [...stops].map(stop => ({ offset: clamp(stop.offset, 0, 1), color: stop.color.clone() })).sort(sortedStopOffset);
37935
+ this._stops = Gradient._normalizeStops(stops);
37420
37936
  }
37421
37937
  get stops() {
37422
37938
  return this._stops;
37423
37939
  }
37940
+ /**
37941
+ * Copy every value (stops and geometry) from a same-type `source` into this
37942
+ * instance, replacing its current state. Returns `this` for chaining.
37943
+ */
37944
+ copy(source) {
37945
+ this._stops = source._stops.map(stop => ({ offset: stop.offset, color: stop.color.clone() }));
37946
+ this._copyGeometry(source);
37947
+ return this;
37948
+ }
37949
+ /**
37950
+ * Structural value equality: same {@link GradientType}, same ordered stops
37951
+ * (offset and {@link Color}), and same subclass geometry.
37952
+ */
37953
+ equals(other) {
37954
+ if (this === other) {
37955
+ return true;
37956
+ }
37957
+ if (this.type !== other.type || this._stops.length !== other._stops.length) {
37958
+ return false;
37959
+ }
37960
+ for (let i = 0; i < this._stops.length; i++) {
37961
+ const own = this._stops[i];
37962
+ const their = other._stops[i];
37963
+ if (own.offset !== their.offset || !own.color.equals(their.color)) {
37964
+ return false;
37965
+ }
37966
+ }
37967
+ return this._geometryEquals(other);
37968
+ }
37424
37969
  toTexture(width, height, options = {}) {
37425
37970
  if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
37426
37971
  throw new Error('Gradient.toTexture() width/height must be positive integers.');
@@ -37463,6 +38008,19 @@ class Gradient {
37463
38008
  out[2] = previousColor.b / 255;
37464
38009
  out[3] = previousColor.a;
37465
38010
  }
38011
+ static _normalizeStops(stops) {
38012
+ if (stops.length < 2) {
38013
+ throw new Error('Gradient requires at least 2 color stops.');
38014
+ }
38015
+ return stops
38016
+ .map(stop => {
38017
+ if (!Number.isFinite(stop.offset)) {
38018
+ throw new Error('Gradient stop offset must be a finite number.');
38019
+ }
38020
+ return { offset: clamp(stop.offset, 0, 1), color: stop.color.clone() };
38021
+ })
38022
+ .sort(sortedStopOffset);
38023
+ }
37466
38024
  _toRgba8Texture(width, height, options) {
37467
38025
  const texture = new DataTexture({
37468
38026
  width,
@@ -37518,6 +38076,7 @@ const toUnorm8 = (value) => (clamp(value, 0, 1) * 255 + 0.5) | 0;
37518
38076
  * Linear gradient in UV space projected from `start` to `end`.
37519
38077
  */
37520
38078
  class LinearGradient extends Gradient {
38079
+ type = 'linear';
37521
38080
  _start;
37522
38081
  _end;
37523
38082
  constructor(stops, start = [0, 0], end = [1, 0]) {
@@ -37525,6 +38084,17 @@ class LinearGradient extends Gradient {
37525
38084
  this._start = [start[0], start[1]];
37526
38085
  this._end = [end[0], end[1]];
37527
38086
  }
38087
+ /** Projection axis start point in UV space. */
38088
+ get start() {
38089
+ return [this._start[0], this._start[1]];
38090
+ }
38091
+ /** Projection axis end point in UV space. */
38092
+ get end() {
38093
+ return [this._end[0], this._end[1]];
38094
+ }
38095
+ clone() {
38096
+ return new LinearGradient(this.stops, this._start, this._end);
38097
+ }
37528
38098
  resolveT(u, v) {
37529
38099
  const axisX = this._end[0] - this._start[0];
37530
38100
  const axisY = this._end[1] - this._start[1];
@@ -37534,12 +38104,24 @@ class LinearGradient extends Gradient {
37534
38104
  }
37535
38105
  return ((u - this._start[0]) * axisX + (v - this._start[1]) * axisY) / lengthSquared;
37536
38106
  }
38107
+ _copyGeometry(source) {
38108
+ this._start = [source._start[0], source._start[1]];
38109
+ this._end = [source._end[0], source._end[1]];
38110
+ }
38111
+ _geometryEquals(other) {
38112
+ return (other instanceof LinearGradient &&
38113
+ this._start[0] === other._start[0] &&
38114
+ this._start[1] === other._start[1] &&
38115
+ this._end[0] === other._end[0] &&
38116
+ this._end[1] === other._end[1]);
38117
+ }
37537
38118
  }
37538
38119
 
37539
38120
  /**
37540
38121
  * Radial gradient in UV space around `center` with normalized radius.
37541
38122
  */
37542
38123
  class RadialGradient extends Gradient {
38124
+ type = 'radial';
37543
38125
  _center;
37544
38126
  _radius;
37545
38127
  constructor(stops, center = [0.5, 0.5], radius = 0.5) {
@@ -37547,6 +38129,17 @@ class RadialGradient extends Gradient {
37547
38129
  this._center = [center[0], center[1]];
37548
38130
  this._radius = Math.max(0, radius);
37549
38131
  }
38132
+ /** Gradient center in UV space. */
38133
+ get center() {
38134
+ return [this._center[0], this._center[1]];
38135
+ }
38136
+ /** Normalized radius (UV units, clamped to be non-negative). */
38137
+ get radius() {
38138
+ return this._radius;
38139
+ }
38140
+ clone() {
38141
+ return new RadialGradient(this.stops, this._center, this._radius);
38142
+ }
37550
38143
  resolveT(u, v) {
37551
38144
  if (this._radius <= 0.000001) {
37552
38145
  return 1;
@@ -37555,6 +38148,13 @@ class RadialGradient extends Gradient {
37555
38148
  const dy = v - this._center[1];
37556
38149
  return Math.sqrt(dx * dx + dy * dy) / this._radius;
37557
38150
  }
38151
+ _copyGeometry(source) {
38152
+ this._center = [source._center[0], source._center[1]];
38153
+ this._radius = source._radius;
38154
+ }
38155
+ _geometryEquals(other) {
38156
+ return other instanceof RadialGradient && this._center[0] === other._center[0] && this._center[1] === other._center[1] && this._radius === other._radius;
38157
+ }
37558
38158
  }
37559
38159
 
37560
38160
  /**
@@ -37979,23 +38579,43 @@ class SpriteMaterial extends Material {
37979
38579
  }
37980
38580
  }
37981
38581
 
38582
+ /**
38583
+ * Edge length of the square gradient texture rasterized for gradient paints.
38584
+ * The full 2D UV field is baked so both linear and radial gradients map across
38585
+ * the filled shape's bounding box without per-axis special-casing.
38586
+ */
38587
+ const gradientTextureSize = 256;
37982
38588
  /**
37983
38589
  * Immediate-mode 2D shape API backed by {@link Mesh} children.
37984
38590
  *
37985
38591
  * Each draw call (e.g. `drawCircle`, `drawRectangle`, `drawLine`) appends a
37986
- * new {@link Mesh} child colored with the current `fillColor` or `lineColor`.
38592
+ * new {@link Mesh} child painted with the active fill or stroke style. A style
38593
+ * is either a solid {@link Color} or a {@link Gradient}, assigned through
38594
+ * {@link fillStyle} / {@link strokeStyle}. The {@link fillColor} /
38595
+ * {@link lineColor} accessors are color-only conveniences over those styles.
37987
38596
  * The active `lineWidth` controls stroke thickness for path and outline draws.
37988
38597
  * Path commands (`moveTo`, `lineTo`, `quadraticCurveTo`, etc.) track a cursor
37989
38598
  * point and flush a Mesh on each segment.
37990
38599
  *
38600
+ * Gradient styles are rasterized once to a {@link DataTexture} via
38601
+ * {@link Gradient.toTexture} and sampled across each shape's local bounding
38602
+ * box, so {@link LinearGradient} and {@link RadialGradient} render through the
38603
+ * same texture path as a textured Mesh. The textures are owned by the Graphics
38604
+ * and released on {@link clear} / {@link destroy}.
38605
+ *
37991
38606
  * Call {@link clear} to remove all child meshes and reset pen state. Because
37992
38607
  * each shape is a separate Mesh, `Graphics` inherits full filter, blend,
37993
38608
  * tint, and mask support from {@link Container}.
37994
38609
  */
37995
38610
  class Graphics extends Container {
37996
38611
  _lineWidth = 0;
37997
- _lineColor = new Color();
37998
38612
  _fillColor = new Color();
38613
+ _lineColor = new Color();
38614
+ _fillStyle = this._fillColor;
38615
+ _strokeStyle = this._lineColor;
38616
+ _fillStyleTexture = null;
38617
+ _strokeStyleTexture = null;
38618
+ _ownedTextures = new Set();
37999
38619
  _currentPoint = new Vector(0, 0);
38000
38620
  get lineWidth() {
38001
38621
  return this._lineWidth;
@@ -38003,17 +38623,59 @@ class Graphics extends Container {
38003
38623
  set lineWidth(lineWidth) {
38004
38624
  this._lineWidth = lineWidth;
38005
38625
  }
38626
+ /** Solid stroke color slot: the last solid color assigned to the stroke. */
38006
38627
  get lineColor() {
38007
38628
  return this._lineColor;
38008
38629
  }
38630
+ /**
38631
+ * Convenience solid-color setter for the stroke. Copies `lineColor` into the
38632
+ * color slot and makes it the active {@link strokeStyle}, replacing any
38633
+ * gradient stroke style.
38634
+ */
38009
38635
  set lineColor(lineColor) {
38010
- this._lineColor.copy(lineColor);
38636
+ this.strokeStyle = lineColor;
38011
38637
  }
38638
+ /** Solid fill color slot: the last solid color assigned to the fill. */
38012
38639
  get fillColor() {
38013
38640
  return this._fillColor;
38014
38641
  }
38642
+ /**
38643
+ * Convenience solid-color setter for the fill. Copies `fillColor` into the
38644
+ * color slot and makes it the active {@link fillStyle}, replacing any
38645
+ * gradient fill style.
38646
+ */
38015
38647
  set fillColor(fillColor) {
38016
- this._fillColor.copy(fillColor);
38648
+ this.fillStyle = fillColor;
38649
+ }
38650
+ /** Active fill style: a solid {@link Color} or a {@link Gradient}. */
38651
+ get fillStyle() {
38652
+ return this._fillStyle;
38653
+ }
38654
+ /**
38655
+ * Set the fill style. Accepts a solid {@link Color}, a {@link Gradient}
38656
+ * (cloned on assignment and rasterized lazily on first fill), or `null` to
38657
+ * revert to the solid color held by {@link fillColor}. A {@link Color} value
38658
+ * is copied into the {@link fillColor} slot; the most recently assigned style
38659
+ * wins.
38660
+ */
38661
+ set fillStyle(style) {
38662
+ this._fillStyle = this._resolveStyle(style, this._fillColor);
38663
+ this._fillStyleTexture = null;
38664
+ }
38665
+ /** Active stroke style: a solid {@link Color} or a {@link Gradient}. */
38666
+ get strokeStyle() {
38667
+ return this._strokeStyle;
38668
+ }
38669
+ /**
38670
+ * Set the stroke style. Accepts a solid {@link Color}, a {@link Gradient}
38671
+ * (cloned on assignment and rasterized lazily on first stroke), or `null` to
38672
+ * revert to the solid color held by {@link lineColor}. A {@link Color} value
38673
+ * is copied into the {@link lineColor} slot; the most recently assigned style
38674
+ * wins.
38675
+ */
38676
+ set strokeStyle(style) {
38677
+ this._strokeStyle = this._resolveStyle(style, this._lineColor);
38678
+ this._strokeStyleTexture = null;
38017
38679
  }
38018
38680
  get currentPoint() {
38019
38681
  return this._currentPoint;
@@ -38137,19 +38799,19 @@ class Graphics extends Container {
38137
38799
  /** Draw a stroked line between two explicit points, independent of the current pen position. */
38138
38800
  drawLine(startX, startY, endX, endY) {
38139
38801
  const data = buildLine(startX, startY, endX, endY, this._lineWidth);
38140
- this.addChild(this._createMesh(data, this._lineColor));
38802
+ this.addChild(this._createStrokeMesh(data));
38141
38803
  return this;
38142
38804
  }
38143
38805
  /** Draw a stroked polyline from a flat `[x0,y0, x1,y1, ...]` coordinate array. */
38144
38806
  drawPath(path) {
38145
38807
  const data = buildPath(path, this._lineWidth);
38146
- this.addChild(this._createMesh(data, this._lineColor));
38808
+ this.addChild(this._createStrokeMesh(data));
38147
38809
  return this;
38148
38810
  }
38149
38811
  /** Fill a closed polygon defined by `[x0,y0, x1,y1, ...]` and optionally stroke its outline. */
38150
38812
  drawPolygon(path) {
38151
38813
  const data = buildPolygon(path);
38152
- this.addChild(this._createMesh(data, this._fillColor));
38814
+ this.addChild(this._createFillMesh(data));
38153
38815
  if (this._lineWidth > 0) {
38154
38816
  this.drawPath(data.points);
38155
38817
  }
@@ -38158,7 +38820,7 @@ class Graphics extends Container {
38158
38820
  /** Fill a circle and optionally stroke its outline if `lineWidth > 0`. */
38159
38821
  drawCircle(centerX, centerY, radius) {
38160
38822
  const data = buildCircle(centerX, centerY, radius);
38161
- this.addChild(this._createMesh(data, this._fillColor));
38823
+ this.addChild(this._createFillMesh(data));
38162
38824
  if (this._lineWidth > 0) {
38163
38825
  this.drawPath(data.points);
38164
38826
  }
@@ -38167,7 +38829,7 @@ class Graphics extends Container {
38167
38829
  /** Fill an ellipse and optionally stroke its outline if `lineWidth > 0`. */
38168
38830
  drawEllipse(centerX, centerY, radiusX, radiusY) {
38169
38831
  const data = buildEllipse(centerX, centerY, radiusX, radiusY);
38170
- this.addChild(this._createMesh(data, this._fillColor));
38832
+ this.addChild(this._createFillMesh(data));
38171
38833
  if (this._lineWidth > 0) {
38172
38834
  this.drawPath(data.points);
38173
38835
  }
@@ -38176,7 +38838,7 @@ class Graphics extends Container {
38176
38838
  /** Fill a rectangle and optionally stroke its outline if `lineWidth > 0`. */
38177
38839
  drawRectangle(x, y, width, height) {
38178
38840
  const data = buildRectangle(x, y, width, height);
38179
- this.addChild(this._createMesh(data, this._fillColor));
38841
+ this.addChild(this._createFillMesh(data));
38180
38842
  if (this._lineWidth > 0) {
38181
38843
  this.drawPath(data.points);
38182
38844
  }
@@ -38188,18 +38850,23 @@ class Graphics extends Container {
38188
38850
  */
38189
38851
  drawStar(centerX, centerY, points, radius, innerRadius = radius / 2, rotation = 0) {
38190
38852
  const data = buildStar(centerX, centerY, points, radius, innerRadius, rotation);
38191
- this.addChild(this._createMesh(data, this._fillColor));
38853
+ this.addChild(this._createFillMesh(data));
38192
38854
  if (this._lineWidth > 0) {
38193
38855
  this.drawPath(data.points);
38194
38856
  }
38195
38857
  return this;
38196
38858
  }
38197
- /** Remove all child meshes and reset pen state (position, colors, line width). */
38859
+ /** Remove all child meshes and reset pen state (position, fill/stroke styles, line width). */
38198
38860
  clear() {
38199
38861
  this.removeChildren();
38200
38862
  this._lineWidth = 0;
38201
- this._lineColor.copy(Color.black);
38202
38863
  this._fillColor.copy(Color.black);
38864
+ this._lineColor.copy(Color.black);
38865
+ this._fillStyle = this._fillColor;
38866
+ this._strokeStyle = this._lineColor;
38867
+ this._fillStyleTexture = null;
38868
+ this._strokeStyleTexture = null;
38869
+ this._destroyOwnedTextures();
38203
38870
  this._currentPoint.set(0, 0);
38204
38871
  return this;
38205
38872
  }
@@ -38210,7 +38877,38 @@ class Graphics extends Container {
38210
38877
  this._fillColor.destroy();
38211
38878
  this._currentPoint.destroy();
38212
38879
  }
38213
- _createMesh(data, color) {
38880
+ /**
38881
+ * Resolve an assigned style into the stored paint. A {@link Color} is copied
38882
+ * into the solid `colorSlot` (keeping the {@link fillColor} / {@link lineColor}
38883
+ * convenience getters in sync) and that slot is returned; `null` reverts to
38884
+ * the slot; a {@link Gradient} is cloned so later external mutation cannot
38885
+ * change the stored paint.
38886
+ */
38887
+ _resolveStyle(style, colorSlot) {
38888
+ if (style === null) {
38889
+ return colorSlot;
38890
+ }
38891
+ if (style instanceof Color) {
38892
+ colorSlot.copy(style);
38893
+ return colorSlot;
38894
+ }
38895
+ return style.clone();
38896
+ }
38897
+ _createFillMesh(data) {
38898
+ if (this._fillStyle instanceof Color) {
38899
+ return this._createSolidMesh(data, this._fillStyle);
38900
+ }
38901
+ this._fillStyleTexture ??= this._rasterizeGradient(this._fillStyle);
38902
+ return this._createGradientMesh(data, this._fillStyleTexture);
38903
+ }
38904
+ _createStrokeMesh(data) {
38905
+ if (this._strokeStyle instanceof Color) {
38906
+ return this._createSolidMesh(data, this._strokeStyle);
38907
+ }
38908
+ this._strokeStyleTexture ??= this._rasterizeGradient(this._strokeStyle);
38909
+ return this._createGradientMesh(data, this._strokeStyleTexture);
38910
+ }
38911
+ _createSolidMesh(data, color) {
38214
38912
  const mesh = new Mesh({
38215
38913
  vertices: data.vertices,
38216
38914
  indices: data.indices,
@@ -38218,7 +38916,66 @@ class Graphics extends Container {
38218
38916
  mesh.tint = color;
38219
38917
  return mesh;
38220
38918
  }
38221
- }
38919
+ /**
38920
+ * Build a textured mesh whose UVs span the shape's local bounding box, so the
38921
+ * gradient texture samples across the filled/stroked geometry. The default
38922
+ * white tint and vertex color leave the sampled gradient color unmodulated.
38923
+ */
38924
+ _createGradientMesh(data, texture) {
38925
+ return new Mesh({
38926
+ vertices: data.vertices,
38927
+ indices: data.indices,
38928
+ uvs: computeBoundsUvs(data.vertices),
38929
+ texture,
38930
+ });
38931
+ }
38932
+ _rasterizeGradient(gradient) {
38933
+ const texture = gradient.toTexture(gradientTextureSize, gradientTextureSize, {
38934
+ samplerOptions: { scaleMode: ScaleModes.Linear },
38935
+ });
38936
+ this._ownedTextures.add(texture);
38937
+ return texture;
38938
+ }
38939
+ _destroyOwnedTextures() {
38940
+ for (const texture of this._ownedTextures) {
38941
+ texture.destroy();
38942
+ }
38943
+ this._ownedTextures.clear();
38944
+ }
38945
+ }
38946
+ /**
38947
+ * Normalize each `(x, y)` vertex into `0..1` UV space relative to the flat
38948
+ * vertex array's axis-aligned bounding box. Degenerate (zero-width/height)
38949
+ * spans collapse to `0` to avoid division by zero.
38950
+ */
38951
+ const computeBoundsUvs = (vertices) => {
38952
+ let minX = Infinity;
38953
+ let minY = Infinity;
38954
+ let maxX = -Infinity;
38955
+ let maxY = -Infinity;
38956
+ for (let i = 0; i < vertices.length; i += 2) {
38957
+ const x = vertices[i];
38958
+ const y = vertices[i + 1];
38959
+ if (x < minX)
38960
+ minX = x;
38961
+ if (x > maxX)
38962
+ maxX = x;
38963
+ if (y < minY)
38964
+ minY = y;
38965
+ if (y > maxY)
38966
+ maxY = y;
38967
+ }
38968
+ const spanX = maxX - minX;
38969
+ const spanY = maxY - minY;
38970
+ const invX = spanX > 0 ? 1 / spanX : 0;
38971
+ const invY = spanY > 0 ? 1 / spanY : 0;
38972
+ const uvs = new Float32Array(vertices.length);
38973
+ for (let i = 0; i < vertices.length; i += 2) {
38974
+ uvs[i] = (vertices[i] - minX) * invX;
38975
+ uvs[i + 1] = (vertices[i + 1] - minY) * invY;
38976
+ }
38977
+ return uvs;
38978
+ };
38222
38979
 
38223
38980
  const defaultClipFps = 12;
38224
38981
  /**