@codexo/exojs 0.12.0 → 0.13.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 (101) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/esm/core/BuildInfo.js +2 -2
  3. package/dist/esm/extensions/Extension.d.ts +39 -7
  4. package/dist/esm/extensions/Extension.d.ts.map +1 -1
  5. package/dist/esm/extensions/ExtensionRegistry.d.ts.map +1 -1
  6. package/dist/esm/extensions/ExtensionRegistry.js.map +1 -1
  7. package/dist/esm/extensions/snapshot.d.ts +12 -2
  8. package/dist/esm/extensions/snapshot.d.ts.map +1 -1
  9. package/dist/esm/extensions/snapshot.js +43 -14
  10. package/dist/esm/extensions/snapshot.js.map +1 -1
  11. package/dist/esm/index.js +8 -0
  12. package/dist/esm/index.js.map +1 -1
  13. package/dist/esm/rendering/Drawable.d.ts +23 -0
  14. package/dist/esm/rendering/Drawable.d.ts.map +1 -1
  15. package/dist/esm/rendering/Drawable.js +34 -0
  16. package/dist/esm/rendering/Drawable.js.map +1 -1
  17. package/dist/esm/rendering/coreRendererBindings.d.ts.map +1 -1
  18. package/dist/esm/rendering/coreRendererBindings.js +22 -0
  19. package/dist/esm/rendering/coreRendererBindings.js.map +1 -1
  20. package/dist/esm/rendering/index.d.ts +13 -0
  21. package/dist/esm/rendering/index.d.ts.map +1 -1
  22. package/dist/esm/rendering/pixelSnap.d.ts +219 -0
  23. package/dist/esm/rendering/pixelSnap.d.ts.map +1 -0
  24. package/dist/esm/rendering/pixelSnap.js +186 -0
  25. package/dist/esm/rendering/pixelSnap.js.map +1 -0
  26. package/dist/esm/rendering/plan/RenderPlanPlayer.d.ts.map +1 -1
  27. package/dist/esm/rendering/plan/RenderPlanPlayer.js +21 -1
  28. package/dist/esm/rendering/plan/RenderPlanPlayer.js.map +1 -1
  29. package/dist/esm/rendering/sprite/NineSliceSprite.d.ts +69 -0
  30. package/dist/esm/rendering/sprite/NineSliceSprite.d.ts.map +1 -0
  31. package/dist/esm/rendering/sprite/NineSliceSprite.js +207 -0
  32. package/dist/esm/rendering/sprite/NineSliceSprite.js.map +1 -0
  33. package/dist/esm/rendering/sprite/RepeatingSprite.d.ts +120 -0
  34. package/dist/esm/rendering/sprite/RepeatingSprite.d.ts.map +1 -0
  35. package/dist/esm/rendering/sprite/RepeatingSprite.js +279 -0
  36. package/dist/esm/rendering/sprite/RepeatingSprite.js.map +1 -0
  37. package/dist/esm/rendering/sprite/Sprite.d.ts +13 -0
  38. package/dist/esm/rendering/sprite/Sprite.d.ts.map +1 -1
  39. package/dist/esm/rendering/sprite/Sprite.js +23 -0
  40. package/dist/esm/rendering/sprite/Sprite.js.map +1 -1
  41. package/dist/esm/rendering/sprite/nineSlice.d.ts +53 -0
  42. package/dist/esm/rendering/sprite/nineSlice.d.ts.map +1 -0
  43. package/dist/esm/rendering/sprite/nineSlice.js +340 -0
  44. package/dist/esm/rendering/sprite/nineSlice.js.map +1 -0
  45. package/dist/esm/rendering/sprite/repeatingSpritePlan.d.ts +57 -0
  46. package/dist/esm/rendering/sprite/repeatingSpritePlan.d.ts.map +1 -0
  47. package/dist/esm/rendering/sprite/repeatingSpritePlan.js +156 -0
  48. package/dist/esm/rendering/sprite/repeatingSpritePlan.js.map +1 -0
  49. package/dist/esm/rendering/texture/TextureRegion.d.ts +100 -0
  50. package/dist/esm/rendering/texture/TextureRegion.d.ts.map +1 -0
  51. package/dist/esm/rendering/texture/TextureRegion.js +144 -0
  52. package/dist/esm/rendering/texture/TextureRegion.js.map +1 -0
  53. package/dist/esm/rendering/texture/repeat.d.ts +96 -0
  54. package/dist/esm/rendering/texture/repeat.d.ts.map +1 -0
  55. package/dist/esm/rendering/texture/repeat.js +158 -0
  56. package/dist/esm/rendering/texture/repeat.js.map +1 -0
  57. package/dist/esm/rendering/webgl2/WebGl2Backend.d.ts +20 -0
  58. package/dist/esm/rendering/webgl2/WebGl2Backend.d.ts.map +1 -1
  59. package/dist/esm/rendering/webgl2/WebGl2Backend.js +31 -2
  60. package/dist/esm/rendering/webgl2/WebGl2Backend.js.map +1 -1
  61. package/dist/esm/rendering/webgl2/WebGl2NineSliceSpriteRenderer.d.ts +32 -0
  62. package/dist/esm/rendering/webgl2/WebGl2NineSliceSpriteRenderer.d.ts.map +1 -0
  63. package/dist/esm/rendering/webgl2/WebGl2NineSliceSpriteRenderer.js +308 -0
  64. package/dist/esm/rendering/webgl2/WebGl2NineSliceSpriteRenderer.js.map +1 -0
  65. package/dist/esm/rendering/webgl2/WebGl2RepeatingSpriteRenderer.d.ts +49 -0
  66. package/dist/esm/rendering/webgl2/WebGl2RepeatingSpriteRenderer.d.ts.map +1 -0
  67. package/dist/esm/rendering/webgl2/WebGl2RepeatingSpriteRenderer.js +535 -0
  68. package/dist/esm/rendering/webgl2/WebGl2RepeatingSpriteRenderer.js.map +1 -0
  69. package/dist/esm/rendering/webgl2/WebGl2SpriteRenderer.d.ts +9 -0
  70. package/dist/esm/rendering/webgl2/WebGl2SpriteRenderer.d.ts.map +1 -1
  71. package/dist/esm/rendering/webgl2/WebGl2SpriteRenderer.js +22 -2
  72. package/dist/esm/rendering/webgl2/WebGl2SpriteRenderer.js.map +1 -1
  73. package/dist/esm/rendering/webgpu/WebGpuBackend.d.ts +21 -1
  74. package/dist/esm/rendering/webgpu/WebGpuBackend.d.ts.map +1 -1
  75. package/dist/esm/rendering/webgpu/WebGpuBackend.js +29 -2
  76. package/dist/esm/rendering/webgpu/WebGpuBackend.js.map +1 -1
  77. package/dist/esm/rendering/webgpu/WebGpuNineSliceSpriteRenderer.d.ts +36 -0
  78. package/dist/esm/rendering/webgpu/WebGpuNineSliceSpriteRenderer.d.ts.map +1 -0
  79. package/dist/esm/rendering/webgpu/WebGpuNineSliceSpriteRenderer.js +358 -0
  80. package/dist/esm/rendering/webgpu/WebGpuNineSliceSpriteRenderer.js.map +1 -0
  81. package/dist/esm/rendering/webgpu/WebGpuRepeatingSpriteRenderer.d.ts +52 -0
  82. package/dist/esm/rendering/webgpu/WebGpuRepeatingSpriteRenderer.d.ts.map +1 -0
  83. package/dist/esm/rendering/webgpu/WebGpuRepeatingSpriteRenderer.js +556 -0
  84. package/dist/esm/rendering/webgpu/WebGpuRepeatingSpriteRenderer.js.map +1 -0
  85. package/dist/esm/rendering/webgpu/WebGpuSpriteRenderer.d.ts +9 -0
  86. package/dist/esm/rendering/webgpu/WebGpuSpriteRenderer.d.ts.map +1 -1
  87. package/dist/esm/rendering/webgpu/WebGpuSpriteRenderer.js +22 -2
  88. package/dist/esm/rendering/webgpu/WebGpuSpriteRenderer.js.map +1 -1
  89. package/dist/esm/rendering/webgpu/WebGpuTransformStorage.d.ts +3 -2
  90. package/dist/esm/rendering/webgpu/WebGpuTransformStorage.d.ts.map +1 -1
  91. package/dist/esm/rendering/webgpu/WebGpuTransformStorage.js +4 -4
  92. package/dist/esm/rendering/webgpu/WebGpuTransformStorage.js.map +1 -1
  93. package/dist/esm/rendering.d.ts +1 -0
  94. package/dist/esm/rendering.d.ts.map +1 -1
  95. package/dist/esm/resources/Loader.d.ts +36 -8
  96. package/dist/esm/resources/Loader.d.ts.map +1 -1
  97. package/dist/esm/resources/Loader.js +30 -11
  98. package/dist/esm/resources/Loader.js.map +1 -1
  99. package/dist/exo.esm.js +3449 -59
  100. package/dist/exo.esm.js.map +1 -1
  101. package/package.json +10 -3
package/dist/exo.esm.js CHANGED
@@ -2567,7 +2567,7 @@ var CollisionType;
2567
2567
  CollisionType[CollisionType["SceneNode"] = 6] = "SceneNode";
2568
2568
  })(CollisionType || (CollisionType = {}));
2569
2569
 
2570
- const epsilon = 1e-10;
2570
+ const epsilon$1 = 1e-10;
2571
2571
  const getCurveSegments = (radiusA, radiusB = radiusA) => Math.max(16, Math.ceil(Math.sqrt(Math.max(radiusA, radiusB)) * 8));
2572
2572
  /** Generate a polygon approximation of an ellipse as an array of world-space points. Segment count scales with the larger radius. */
2573
2573
  const buildEllipsePoints = ({ x: centerX, y: centerY, rx, ry }) => {
@@ -2612,10 +2612,10 @@ const buildRectanglePoints = ({ x, y, width, height }) => [
2612
2612
  ];
2613
2613
  /** Translate a polygon's local-space `points` by its world offset `(x, y)`. */
2614
2614
  const buildPolygonWorldPoints = ({ x: offsetX, y: offsetY, points }) => points.map(({ x, y }) => ({ x: x + offsetX, y: y + offsetY }));
2615
- const pointOnSegment = ({ x: px, y: py }, { x: x1, y: y1 }, { x: x2, y: y2 }) => px <= Math.max(x1, x2) + epsilon && px >= Math.min(x1, x2) - epsilon && py <= Math.max(y1, y2) + epsilon && py >= Math.min(y1, y2) - epsilon;
2615
+ const pointOnSegment = ({ x: px, y: py }, { x: x1, y: y1 }, { x: x2, y: y2 }) => px <= Math.max(x1, x2) + epsilon$1 && px >= Math.min(x1, x2) - epsilon$1 && py <= Math.max(y1, y2) + epsilon$1 && py >= Math.min(y1, y2) - epsilon$1;
2616
2616
  const orientation = ({ x: x1, y: y1 }, { x: x2, y: y2 }, { x: x3, y: y3 }) => {
2617
2617
  const determinant = (y2 - y1) * (x3 - x2) - (x2 - x1) * (y3 - y2);
2618
- if (Math.abs(determinant) <= epsilon) {
2618
+ if (Math.abs(determinant) <= epsilon$1) {
2619
2619
  return 0;
2620
2620
  }
2621
2621
  return determinant > 0 ? 1 : 2;
@@ -2705,7 +2705,7 @@ const intersectionPointEllipse$1 = ({ x: x1, y: y1 }, { x: x2, y: y2, rx, ry })
2705
2705
  const intersectionPointPoly$1 = (point, { points }) => polygonContainsPoint(point, points);
2706
2706
  const intersectionLineLineSegments = (a1, a2, b1, b2) => {
2707
2707
  const denominator = (a2.x - a1.x) * (b2.y - b1.y) - (b2.x - b1.x) * (a2.y - a1.y);
2708
- if (Math.abs(denominator) <= epsilon) {
2708
+ if (Math.abs(denominator) <= epsilon$1) {
2709
2709
  return false;
2710
2710
  }
2711
2711
  const uA = ((b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x)) / denominator;
@@ -7975,8 +7975,9 @@ const emptySnapshot = Object.freeze({
7975
7975
  assets: Object.freeze([]),
7976
7976
  });
7977
7977
  /**
7978
- * Flatten an ordered extension list into a snapshot.
7979
- * De-duplicates same-id/same-object entries; throws on same-id/different-object.
7978
+ * Flatten an ordered extension list into a snapshot using stable depth-first
7979
+ * post-order traversal. Dependencies are materialised before their dependents.
7980
+ * De-duplicates same-object entries; throws on same-id/different-object or cycles.
7980
7981
  * Binding-level conflicts are checked per Application at materialisation time.
7981
7982
  * @internal
7982
7983
  */
@@ -7984,19 +7985,47 @@ function buildSnapshot(input) {
7984
7985
  if (input.length === 0) {
7985
7986
  return emptySnapshot;
7986
7987
  }
7987
- const seenById = new Map();
7988
- const extensions = [];
7988
+ const byId = new Map();
7989
+ const visiting = new Set();
7990
+ const visited = new Set();
7991
+ const stack = [];
7992
+ const ordered = [];
7993
+ function visit(ext) {
7994
+ // (1) Reserve ID + mismatch check FIRST — before dependency traversal.
7995
+ // This catches nested same-id/different-object descriptors immediately.
7996
+ const existing = byId.get(ext.id);
7997
+ if (existing !== undefined && existing !== ext) {
7998
+ throw new Error(`Extension "${ext.id}" was provided by multiple descriptor objects.`);
7999
+ }
8000
+ if (existing === undefined) {
8001
+ byId.set(ext.id, ext);
8002
+ }
8003
+ // (2) Already fully processed — diamond / shared dependency.
8004
+ if (visited.has(ext))
8005
+ return;
8006
+ // (3) Back-edge in current DFS stack — cycle.
8007
+ if (visiting.has(ext)) {
8008
+ const cyclePath = [...stack.slice(stack.indexOf(ext)), ext].map(e => e.id).join(' → ');
8009
+ throw new Error(`Extension dependency cycle detected: ${cyclePath}`);
8010
+ }
8011
+ // (4) Recurse into dependencies (post-order: deps before this extension).
8012
+ visiting.add(ext);
8013
+ stack.push(ext);
8014
+ for (const dep of ext.dependencies ?? []) {
8015
+ visit(dep);
8016
+ }
8017
+ stack.pop();
8018
+ visiting.delete(ext);
8019
+ // (5) Finalise — remove from in-progress, mark as fully processed, push to output.
8020
+ visited.add(ext);
8021
+ ordered.push(ext);
8022
+ }
8023
+ for (const ext of input)
8024
+ visit(ext);
8025
+ // Flatten in topological (post-order) order — deps before dependents.
7989
8026
  const renderers = [];
7990
8027
  const assets = [];
7991
- for (const ext of input) {
7992
- const existing = seenById.get(ext.id);
7993
- if (existing !== undefined) {
7994
- if (existing === ext)
7995
- continue;
7996
- throw new Error(`Duplicate extension id "${ext.id}" with a different descriptor in the provided extensions list.`);
7997
- }
7998
- seenById.set(ext.id, ext);
7999
- extensions.push(ext);
8028
+ for (const ext of ordered) {
8000
8029
  if (ext.renderers) {
8001
8030
  for (const binding of ext.renderers) {
8002
8031
  renderers.push(binding);
@@ -8009,7 +8038,7 @@ function buildSnapshot(input) {
8009
8038
  }
8010
8039
  }
8011
8040
  return Object.freeze({
8012
- extensions: Object.freeze(extensions),
8041
+ extensions: Object.freeze(ordered),
8013
8042
  renderers: Object.freeze(renderers),
8014
8043
  assets: Object.freeze(assets),
8015
8044
  });
@@ -13819,13 +13848,33 @@ class RenderPlanPlayer {
13819
13848
  let groupCursor = 0;
13820
13849
  let currentGroup = null;
13821
13850
  let currentInstructionIndex = 0;
13851
+ // Phase 1 — populate the CPU transform buffer for all groups in this scope
13852
+ // before any renderer draws execute. Without this separation, each
13853
+ // per-group upload changes the buffer hash while a renderer holds an
13854
+ // in-flight batch; the next flush detects the changed hash and re-uploads
13855
+ // the growing buffer, producing O(groups²) GPU transform writes per pass
13856
+ // (measured: ~240 KB/frame for 100 NineSlice sprites across 8 textures,
13857
+ // ~600 MB/frame for 5000 RepeatingSprites). Writing every group's
13858
+ // transforms first ensures the hash is stable by the time the first flush
13859
+ // calls bindTransformBufferTexture/getTransformStorageBuffer, so all
13860
+ // subsequent flushes within the same scope find an unchanged hash and skip
13861
+ // the upload entirely.
13862
+ if (hooks._prepareRenderGroupUpload !== undefined) {
13863
+ let preInstructionIndex = context.passInstructionIndex;
13864
+ for (let gi = 0; gi < groups.length; gi++) {
13865
+ const group = groups[gi];
13866
+ hooks._prepareRenderGroupUpload(group, this._createRenderGroupPlaybackContext(group.instructions.length, preInstructionIndex, context.passGroupIndex + gi));
13867
+ preInstructionIndex += group.instructions.length;
13868
+ }
13869
+ }
13870
+ // Phase 2 — execute draws in document order. Transform writes are already
13871
+ // done; _prepareRenderGroupUpload is intentionally not called a second time.
13822
13872
  for (const entry of scope.entries) {
13823
13873
  if (entry.kind === RenderEntryKind.Draw) {
13824
13874
  if (currentGroup === null) {
13825
13875
  currentGroup = groups[groupCursor];
13826
13876
  currentInstructionIndex = 0;
13827
13877
  hooks._beginRenderGroup?.(currentGroup);
13828
- hooks._prepareRenderGroupUpload?.(currentGroup, this._createRenderGroupPlaybackContext(currentGroup.instructions.length, context.passInstructionIndex, context.passGroupIndex));
13829
13878
  context.passGroupIndex++;
13830
13879
  }
13831
13880
  // Allocate the per-draw instruction slot only when a backend consumes
@@ -15091,6 +15140,190 @@ class InteractionManager {
15091
15140
  }
15092
15141
  }
15093
15142
 
15143
+ const pixelSnapModes = new Set(['none', 'position', 'geometry']);
15144
+ /**
15145
+ * Runtime guard for the {@link PixelSnapMode} union. Used by the public setter to
15146
+ * reject JavaScript-invalid values atomically.
15147
+ * @internal
15148
+ */
15149
+ function isPixelSnapMode(value) {
15150
+ return typeof value === 'string' && pixelSnapModes.has(value);
15151
+ }
15152
+ /** Below this magnitude an axis is treated as collapsed / cross-coupled. @internal */
15153
+ const epsilon = 1e-6;
15154
+ /**
15155
+ * Build the device-pixel snap context for `world` (a node's global transform)
15156
+ * under `view`, targeting a surface of `targetPxWidth` × `targetPxHeight` device
15157
+ * pixels. Pure — does not mutate `world` or `view`. Falls back to a no-op context
15158
+ * (snapped origin = original origin) when the target size or projection is
15159
+ * degenerate, so callers can always use the result safely.
15160
+ * @internal
15161
+ */
15162
+ function buildPixelSnapContext(world, view, targetPxWidth, targetPxHeight) {
15163
+ const ox = world.x;
15164
+ const oy = world.y;
15165
+ if (!(targetPxWidth > 0) || !(targetPxHeight > 0)) {
15166
+ return noopContext(ox, oy);
15167
+ }
15168
+ // Forward projection only (world → device). The view's inverse is deliberately
15169
+ // avoided; instead the origin plus two world-unit axis tips give the exact
15170
+ // world→device Jacobian, which we invert ourselves (a 2×2) to map the snapped
15171
+ // device origin back to world space. This stays exact for any affine view.
15172
+ const origin = view.worldToScreen(ox, oy, targetPxWidth, targetPxHeight);
15173
+ if (!Number.isFinite(origin.x) || !Number.isFinite(origin.y)) {
15174
+ return noopContext(ox, oy);
15175
+ }
15176
+ const tipWorldX = view.worldToScreen(ox + 1, oy, targetPxWidth, targetPxHeight);
15177
+ const tipWorldY = view.worldToScreen(ox, oy + 1, targetPxWidth, targetPxHeight);
15178
+ // Columns of the world→device Jacobian J = [[jxx, jyx], [jxy, jyy]].
15179
+ const jxx = tipWorldX.x - origin.x;
15180
+ const jxy = tipWorldX.y - origin.y;
15181
+ const jyx = tipWorldY.x - origin.x;
15182
+ const jyy = tipWorldY.y - origin.y;
15183
+ // Local axes in device pixels: apply the node's linear part to the Jacobian —
15184
+ // local (1,0)→world (a,c), local (0,1)→world (b,d).
15185
+ const scaleX = world.a * jxx + world.c * jyx; // device-x per local-x unit
15186
+ const crossXy = world.a * jxy + world.c * jyy; // device-y per local-x unit
15187
+ const crossYx = world.b * jxx + world.d * jyx; // device-x per local-y unit
15188
+ const scaleY = world.b * jxy + world.d * jyy; // device-y per local-y unit
15189
+ const axisAligned = Math.abs(crossXy) < epsilon && Math.abs(crossYx) < epsilon;
15190
+ const snappedOriginX = Math.round(origin.x);
15191
+ const snappedOriginY = Math.round(origin.y);
15192
+ // Map the snapped device origin back to world via J⁻¹ so that, re-projected
15193
+ // through the (unchanged) view, the rendered origin lands on the device pixel.
15194
+ let worldX = ox;
15195
+ let worldY = oy;
15196
+ const det = jxx * jyy - jyx * jxy;
15197
+ if (Math.abs(det) > epsilon) {
15198
+ const ddx = snappedOriginX - origin.x;
15199
+ const ddy = snappedOriginY - origin.y;
15200
+ worldX = ox + (jyy * ddx - jyx * ddy) / det;
15201
+ worldY = oy + (jxx * ddy - jxy * ddx) / det;
15202
+ }
15203
+ return {
15204
+ originX: origin.x,
15205
+ originY: origin.y,
15206
+ snappedOriginX,
15207
+ snappedOriginY,
15208
+ worldX,
15209
+ worldY,
15210
+ scaleX,
15211
+ scaleY,
15212
+ axisAligned,
15213
+ };
15214
+ }
15215
+ function noopContext(ox, oy) {
15216
+ return {
15217
+ originX: ox,
15218
+ originY: oy,
15219
+ snappedOriginX: ox,
15220
+ snappedOriginY: oy,
15221
+ worldX: ox,
15222
+ worldY: oy,
15223
+ scaleX: 0,
15224
+ scaleY: 0,
15225
+ axisAligned: false,
15226
+ };
15227
+ }
15228
+ /**
15229
+ * Copy `world` into `out`, replacing only the translation with the snapped world
15230
+ * origin from `ctx`. The linear part `(a, b, c, d)` and homogeneous row are
15231
+ * preserved, so rotation / scale / skew are untouched — position snapping is safe
15232
+ * under any transform. The source `world` matrix is never mutated.
15233
+ * @internal
15234
+ */
15235
+ function snapWorldTranslationInto(out, world, ctx) {
15236
+ out.copy(world);
15237
+ out.x = ctx.worldX;
15238
+ out.y = ctx.worldY;
15239
+ return out;
15240
+ }
15241
+ /**
15242
+ * Snap a single local boundary coordinate to the device-pixel grid along an axis
15243
+ * whose local→device scale is `scale`. Returns the local value whose device
15244
+ * position (relative to the already-snapped origin) lands on an integer device
15245
+ * pixel: `round(L · scale) / scale`.
15246
+ *
15247
+ * The function is pure, so two boundaries with the same input value snap to the
15248
+ * same output — shared boundaries stay shared and seams cannot open. It is also
15249
+ * monotonic non-decreasing in `L` for any non-zero `scale` (including negative
15250
+ * scale from a flip), so boundary order is preserved and snapped spans never go
15251
+ * negative. Degenerate scales (`|scale| < epsilon`) and non-finite inputs return
15252
+ * the value unchanged.
15253
+ * @internal
15254
+ */
15255
+ function snapLocalBoundary(localValue, scale) {
15256
+ if (!Number.isFinite(localValue) || Math.abs(scale) < epsilon) {
15257
+ return localValue;
15258
+ }
15259
+ return Math.round(localValue * scale) / scale;
15260
+ }
15261
+ /**
15262
+ * Snap every quad's local X/Y boundaries to the device grid using the per-axis
15263
+ * scale in `ctx`, writing the result into the reusable `out` buffer (grown /
15264
+ * truncated to match `source`, never reallocated per frame once warm). UVs are
15265
+ * copied verbatim — sampling is unchanged. Because {@link snapLocalBoundary} is
15266
+ * pure, quads sharing a boundary value stay seam-free without any explicit
15267
+ * de-duplication.
15268
+ * @internal
15269
+ */
15270
+ function snapQuadsInto(source, ctx, out) {
15271
+ const { scaleX, scaleY } = ctx;
15272
+ out.length = source.length;
15273
+ for (let i = 0; i < source.length; i++) {
15274
+ const q = source[i];
15275
+ let t = out[i];
15276
+ if (t === undefined) {
15277
+ t = out[i] = { x0: 0, y0: 0, x1: 0, y1: 0, u0: 0, v0: 0, u1: 0, v1: 0 };
15278
+ }
15279
+ t.x0 = snapLocalBoundary(q.x0, scaleX);
15280
+ t.x1 = snapLocalBoundary(q.x1, scaleX);
15281
+ t.y0 = snapLocalBoundary(q.y0, scaleY);
15282
+ t.y1 = snapLocalBoundary(q.y1, scaleY);
15283
+ t.u0 = q.u0;
15284
+ t.v0 = q.v0;
15285
+ t.u1 = q.u1;
15286
+ t.v1 = q.v1;
15287
+ }
15288
+ return out;
15289
+ }
15290
+ /**
15291
+ * Resolve the world transform to upload for `drawable` at the transform-buffer
15292
+ * write seam. Returns the drawable's live global transform unchanged when its
15293
+ * mode is `'none'` (zero overhead), otherwise a snapped copy written into the
15294
+ * caller-owned `scratch` matrix — the logical global transform is never mutated.
15295
+ *
15296
+ * Both backends call this at their single transform-write boundary, so position
15297
+ * snapping (and tilemap chunk/layer origin snapping) is applied once, backend-
15298
+ * neutrally, to every drawable. `view` and the target device-pixel dimensions
15299
+ * come from the active pass.
15300
+ * @internal
15301
+ */
15302
+ function resolveUploadTransform(drawable, view, targetPxWidth, targetPxHeight, scratch) {
15303
+ const world = drawable.getGlobalTransform();
15304
+ if (drawable.pixelSnapMode === 'none') {
15305
+ return world;
15306
+ }
15307
+ const ctx = buildPixelSnapContext(world, view, targetPxWidth, targetPxHeight);
15308
+ return snapWorldTranslationInto(scratch, world, ctx);
15309
+ }
15310
+ /**
15311
+ * Snap a single local-space bounds rectangle (e.g. a sprite quad) to the device
15312
+ * grid using the per-axis scale in `ctx`, writing the result into `out`. Each of
15313
+ * the four edges is snapped by {@link snapLocalBoundary}, so combined with the
15314
+ * device-snapped origin every corner lands on a whole device pixel. `out` may be
15315
+ * the same instance across frames (no allocation). UV/texture mapping is the
15316
+ * caller's concern and is unaffected.
15317
+ * @internal
15318
+ */
15319
+ function snapBoundsInto(base, ctx, out) {
15320
+ const left = snapLocalBoundary(base.left, ctx.scaleX);
15321
+ const top = snapLocalBoundary(base.top, ctx.scaleY);
15322
+ const right = snapLocalBoundary(base.right, ctx.scaleX);
15323
+ const bottom = snapLocalBoundary(base.bottom, ctx.scaleY);
15324
+ return out.set(left, top, right - left, bottom - top);
15325
+ }
15326
+
15094
15327
  /**
15095
15328
  * Base class for every renderable scene object.
15096
15329
  *
@@ -15101,6 +15334,7 @@ class InteractionManager {
15101
15334
  class Drawable extends RenderNode {
15102
15335
  _tint = Color.white.clone();
15103
15336
  _blendMode = BlendModes.Normal;
15337
+ _pixelSnapMode = 'none';
15104
15338
  get tint() {
15105
15339
  return this._tint;
15106
15340
  }
@@ -15113,6 +15347,38 @@ class Drawable extends RenderNode {
15113
15347
  set blendMode(blendMode) {
15114
15348
  this.setBlendMode(blendMode);
15115
15349
  }
15350
+ /**
15351
+ * Render-only pixel-snapping policy for this drawable. Aligns the rendered
15352
+ * origin (`'position'`) or origin plus shared geometry boundaries
15353
+ * (`'geometry'`) to the active render target's device-pixel grid. Purely
15354
+ * visual: logical `x`/`y`, transforms, bounds, collision, tween and physics
15355
+ * state are never affected, and {@link getBounds}/{@link getGlobalTransform}
15356
+ * keep returning logical values.
15357
+ *
15358
+ * `'geometry'` is guaranteed only for axis-aligned transforms; rotation or
15359
+ * skew (on this node, an ancestor, or the view) downgrade it to `'position'`
15360
+ * for the affected frame, with no logical-state change. Snapping targets
15361
+ * device pixels (× view scale × pixel ratio), not integer world units.
15362
+ *
15363
+ * Setting the current value is a no-op. Setting a value outside the
15364
+ * {@link PixelSnapMode} union throws and leaves the prior mode unchanged.
15365
+ *
15366
+ * @default 'none'
15367
+ * @stable
15368
+ */
15369
+ get pixelSnapMode() {
15370
+ return this._pixelSnapMode;
15371
+ }
15372
+ set pixelSnapMode(mode) {
15373
+ if (mode === this._pixelSnapMode) {
15374
+ return;
15375
+ }
15376
+ if (!isPixelSnapMode(mode)) {
15377
+ throw new Error(`Drawable.pixelSnapMode must be 'none', 'position', or 'geometry' (got ${String(mode)}).`);
15378
+ }
15379
+ this._pixelSnapMode = mode;
15380
+ this.invalidateCache();
15381
+ }
15116
15382
  /**
15117
15383
  * Set the tint colour by copying `color` into the internal {@link Color} instance.
15118
15384
  * Invalidates the render cache so the change is picked up on the next frame.
@@ -15387,6 +15653,1264 @@ function clamp01(value) {
15387
15653
  return value;
15388
15654
  }
15389
15655
 
15656
+ function isFinite(value) {
15657
+ return typeof value === 'number' && Number.isFinite(value);
15658
+ }
15659
+ function normalizeExtrusion(extrusion) {
15660
+ if (extrusion === undefined) {
15661
+ return Object.freeze({
15662
+ left: 0,
15663
+ top: 0,
15664
+ right: 0,
15665
+ bottom: 0,
15666
+ });
15667
+ }
15668
+ if (typeof extrusion === 'number') {
15669
+ return Object.freeze({
15670
+ left: extrusion,
15671
+ top: extrusion,
15672
+ right: extrusion,
15673
+ bottom: extrusion,
15674
+ });
15675
+ }
15676
+ // Copy caller-owned values into an engine-owned frozen object so that
15677
+ // external mutation of the original never affects the region.
15678
+ return Object.freeze({
15679
+ left: extrusion.left,
15680
+ top: extrusion.top,
15681
+ right: extrusion.right,
15682
+ bottom: extrusion.bottom,
15683
+ });
15684
+ }
15685
+ function validateExtrusion(extrusion, x, y, width, height, textureWidth, textureHeight) {
15686
+ const { left, top, right, bottom } = extrusion;
15687
+ if (!isFinite(left) || !isFinite(top) || !isFinite(right) || !isFinite(bottom)) {
15688
+ throw new Error(`TextureRegion extrusion values must be finite numbers (got left=${left}, top=${top}, right=${right}, bottom=${bottom}).`);
15689
+ }
15690
+ if (left < 0 || top < 0 || right < 0 || bottom < 0) {
15691
+ throw new Error(`TextureRegion extrusion values must be non-negative (got left=${left}, top=${top}, right=${right}, bottom=${bottom}).`);
15692
+ }
15693
+ if (left > x || top > y || right > textureWidth - (x + width) || bottom > textureHeight - (y + height)) {
15694
+ throw new Error(`TextureRegion extrusion exceeds available source texture bounds: left=${left} (>${x}), top=${top} (>${y}), ` +
15695
+ `right=${right} (>${textureWidth - (x + width)}), bottom=${bottom} (>${textureHeight - (y + height)}).`);
15696
+ }
15697
+ }
15698
+ function validateOptions(options, textureWidth, textureHeight) {
15699
+ const { x, y, width, height } = options;
15700
+ if (!isFinite(x) || !isFinite(y) || !isFinite(width) || !isFinite(height)) {
15701
+ throw new Error(`TextureRegion coordinates and dimensions must be finite numbers (got x=${x}, y=${y}, width=${width}, height=${height}).`);
15702
+ }
15703
+ if (width <= 0 || height <= 0) {
15704
+ throw new Error(`TextureRegion dimensions must be positive (got width=${width}, height=${height}).`);
15705
+ }
15706
+ if (textureWidth <= 0 || textureHeight <= 0) {
15707
+ throw new Error(`Texture must have positive dimensions (got ${textureWidth}x${textureHeight}).`);
15708
+ }
15709
+ if (x < 0 || y < 0) {
15710
+ throw new Error(`TextureRegion origin must be non-negative (got x=${x}, y=${y}).`);
15711
+ }
15712
+ if (x >= textureWidth || y >= textureHeight) {
15713
+ throw new Error(`TextureRegion origin (${x}, ${y}) is outside texture bounds (${textureWidth}x${textureHeight}).`);
15714
+ }
15715
+ if (x + width > textureWidth) {
15716
+ throw new Error(`TextureRegion right edge (${x + width}) exceeds texture width (${textureWidth}).`);
15717
+ }
15718
+ if (y + height > textureHeight) {
15719
+ throw new Error(`TextureRegion bottom edge (${y + height}) exceeds texture height (${textureHeight}).`);
15720
+ }
15721
+ }
15722
+ /**
15723
+ * An immutable descriptor for a rectangular sub-region of a {@link Texture}.
15724
+ *
15725
+ * Stores the pixel-space source rectangle, pre-computed normalised UV bounds,
15726
+ * and optional extrusion/padding metadata for atlas-safe linear filtering.
15727
+ * Constructed once and reused across sprites, tile-sets, atlas lookups, and
15728
+ * the scalable-sprite repeat planners.
15729
+ *
15730
+ * All region descriptor fields are stable after construction. Extrusion
15731
+ * metadata is copied and frozen during construction — the caller retains no
15732
+ * mutable reference to the stored object. The underlying {@link Texture}
15733
+ * reference is stable, but the Texture's own lifecycle and sampler state
15734
+ * remain owned by {@link Texture}.
15735
+ *
15736
+ * Texture dimensions **must** be known at construction time; a texture with
15737
+ * zero dimensions will cause the constructor to throw.
15738
+ *
15739
+ * @example
15740
+ * ```ts
15741
+ * const region = new TextureRegion(texture, {
15742
+ * x: 32, y: 16,
15743
+ * width: 64, height: 32,
15744
+ * });
15745
+ * ```
15746
+ * @stable
15747
+ */
15748
+ class TextureRegion {
15749
+ /** The underlying {@link Texture} this region belongs to. */
15750
+ texture;
15751
+ /** Left edge of the region in texture pixels. */
15752
+ x;
15753
+ /** Top edge of the region in texture pixels. */
15754
+ y;
15755
+ /** Width of the region in texture pixels. */
15756
+ width;
15757
+ /** Height of the region in texture pixels. */
15758
+ height;
15759
+ /** Normalised left texture coordinate (U-min). */
15760
+ u0;
15761
+ /** Normalised top texture coordinate (V-min). */
15762
+ v0;
15763
+ /** Normalised right texture coordinate (U-max). */
15764
+ u1;
15765
+ /** Normalised bottom texture coordinate (V-max). */
15766
+ v1;
15767
+ /** Per-edge extrusion/padding metadata (engine-owned, frozen). */
15768
+ extrusion;
15769
+ /**
15770
+ * Create a new immutable region.
15771
+ *
15772
+ * @throws When coordinates or dimensions are non-finite, zero, negative, or
15773
+ * extend beyond the texture bounds, or when extrusion values are
15774
+ * invalid.
15775
+ */
15776
+ constructor(texture, options) {
15777
+ if (!texture) {
15778
+ throw new Error('TextureRegion requires a non-null Texture.');
15779
+ }
15780
+ const textureWidth = texture.width;
15781
+ const textureHeight = texture.height;
15782
+ validateOptions(options, textureWidth, textureHeight);
15783
+ const extrusion = normalizeExtrusion(options.extrusion);
15784
+ validateExtrusion(extrusion, options.x, options.y, options.width, options.height, textureWidth, textureHeight);
15785
+ this.texture = texture;
15786
+ this.x = options.x;
15787
+ this.y = options.y;
15788
+ this.width = options.width;
15789
+ this.height = options.height;
15790
+ this.u0 = options.x / textureWidth;
15791
+ this.v0 = options.y / textureHeight;
15792
+ this.u1 = (options.x + options.width) / textureWidth;
15793
+ this.v1 = (options.y + options.height) / textureHeight;
15794
+ this.extrusion = extrusion;
15795
+ }
15796
+ }
15797
+
15798
+ /**
15799
+ * Validate generic numeric inputs shared across all modes.
15800
+ * Throws unconditionally — these are user/programming errors, not dev-only
15801
+ * assertions.
15802
+ */
15803
+ function validateInputs(sourceLength, destinationLength) {
15804
+ if (!Number.isFinite(sourceLength) || !Number.isFinite(destinationLength)) {
15805
+ throw new Error(`RepeatPlanner: sourceLength and destinationLength must be finite numbers (got ${sourceLength}, ${destinationLength}).`);
15806
+ }
15807
+ if (sourceLength <= 0) {
15808
+ throw new Error(`RepeatPlanner: sourceLength must be positive (got ${sourceLength}).`);
15809
+ }
15810
+ if (destinationLength < 0) {
15811
+ throw new Error(`RepeatPlanner: destinationLength must be non-negative (got ${destinationLength}).`);
15812
+ }
15813
+ }
15814
+ function buildStretchPlan(destinationLength, sourceLength) {
15815
+ if (destinationLength === 0) {
15816
+ return {
15817
+ destinationLength: 0,
15818
+ sourceLength,
15819
+ segments: [],
15820
+ };
15821
+ }
15822
+ const segment = {
15823
+ destinationStart: 0,
15824
+ destinationLength,
15825
+ sourceStart: 0,
15826
+ sourceEnd: 1,
15827
+ mirrored: false,
15828
+ };
15829
+ return {
15830
+ destinationLength,
15831
+ sourceLength,
15832
+ segments: [segment],
15833
+ };
15834
+ }
15835
+ function buildClipPlan(destinationLength, sourceLength, mirror) {
15836
+ if (destinationLength === 0) {
15837
+ return {
15838
+ destinationLength: 0,
15839
+ sourceLength,
15840
+ segments: [],
15841
+ };
15842
+ }
15843
+ const segments = [];
15844
+ let cursor = 0;
15845
+ let index = 0;
15846
+ while (cursor < destinationLength) {
15847
+ const remaining = destinationLength - cursor;
15848
+ const segLength = Math.min(sourceLength, remaining);
15849
+ const sourceFraction = segLength / sourceLength;
15850
+ const mirrored = mirror && (index % 2 === 1);
15851
+ const segment = {
15852
+ destinationStart: cursor,
15853
+ destinationLength: segLength,
15854
+ sourceStart: mirrored ? 1 : 0,
15855
+ sourceEnd: mirrored ? 1 - sourceFraction : sourceFraction,
15856
+ mirrored,
15857
+ };
15858
+ segments.push(segment);
15859
+ cursor += segLength;
15860
+ index++;
15861
+ }
15862
+ return {
15863
+ destinationLength,
15864
+ sourceLength,
15865
+ segments,
15866
+ };
15867
+ }
15868
+ function buildRoundPlan(destinationLength, sourceLength, mirror) {
15869
+ if (destinationLength === 0) {
15870
+ return {
15871
+ destinationLength: 0,
15872
+ sourceLength,
15873
+ segments: [],
15874
+ };
15875
+ }
15876
+ const ratio = destinationLength / sourceLength;
15877
+ const count = Math.max(1, Math.round(ratio));
15878
+ const segLength = destinationLength / count;
15879
+ const segments = [];
15880
+ for (let i = 0; i < count; i++) {
15881
+ const mirrored = mirror && (i % 2 === 1);
15882
+ const segment = {
15883
+ destinationStart: i * segLength,
15884
+ destinationLength: segLength,
15885
+ sourceStart: mirrored ? 1 : 0,
15886
+ sourceEnd: mirrored ? 0 : 1,
15887
+ mirrored,
15888
+ };
15889
+ segments.push(segment);
15890
+ }
15891
+ return {
15892
+ destinationLength,
15893
+ sourceLength,
15894
+ segments,
15895
+ };
15896
+ }
15897
+ /**
15898
+ * Compute a deterministic repeat plan for filling a destination span with a
15899
+ * tiled or stretched source pattern.
15900
+ *
15901
+ * This is a **pure, renderer-independent** function used as the shared repeat
15902
+ * layout engine by {@link NineSliceSprite} edges/center,
15903
+ * {@link RepeatingSprite} geometry path, and tilemap chunk builders.
15904
+ *
15905
+ * ## Modes
15906
+ *
15907
+ * | Mode | Behaviour |
15908
+ * |------------------|-----------|
15909
+ * | `'stretch'` | One segment stretched to the full destination. `fit` is ignored. |
15910
+ * | `'repeat'` | Native-size repeats; final segment is clipped when the destination is not an exact multiple. |
15911
+ * | `'mirror-repeat'`| Alternating normal/mirrored segments (period-2). Clipped final segment when non-exact. |
15912
+ *
15913
+ * ## Fit
15914
+ *
15915
+ * | Fit | Effect |
15916
+ * |----------|--------|
15917
+ * | `'clip'` | Native-size segments; the final segment is clipped if necessary. |
15918
+ * | `'round'`| Integer count of equally-sized segments stretched or squeezed so the destination fills exactly. |
15919
+ *
15920
+ * ## Allocation and caching
15921
+ *
15922
+ * The planner returns a new result object on every call. Callers should
15923
+ * cache the plan and recompute only when `sourceLength`, `destinationLength`,
15924
+ * `mode`, or `fit` changes — typically on resize or texture change.
15925
+ *
15926
+ * @param sourceLength - Native size of the source pattern in destination units.
15927
+ * @param destinationLength - Total span to fill.
15928
+ * @param mode - Fill strategy.
15929
+ * @param fit - Fitting strategy for `'repeat'` / `'mirror-repeat'`. Ignored for `'stretch'`.
15930
+ * @throws When inputs are non-finite, negative, or zero-length source.
15931
+ * @advanced
15932
+ */
15933
+ function planRepeat(sourceLength, destinationLength, mode, fit = 'round') {
15934
+ validateInputs(sourceLength, destinationLength);
15935
+ switch (mode) {
15936
+ case 'stretch':
15937
+ return buildStretchPlan(destinationLength, sourceLength);
15938
+ case 'repeat':
15939
+ if (fit === 'round') {
15940
+ return buildRoundPlan(destinationLength, sourceLength, false);
15941
+ }
15942
+ return buildClipPlan(destinationLength, sourceLength, false);
15943
+ case 'mirror-repeat':
15944
+ if (fit === 'round') {
15945
+ return buildRoundPlan(destinationLength, sourceLength, true);
15946
+ }
15947
+ return buildClipPlan(destinationLength, sourceLength, true);
15948
+ default: {
15949
+ throw new Error(`RepeatPlanner: unknown RepeatMode.`);
15950
+ }
15951
+ }
15952
+ }
15953
+
15954
+ const halfTexelInset$1 = 0.5;
15955
+ // ---------------------------------------------------------------------------
15956
+ // Validation helpers (module-private)
15957
+ // ---------------------------------------------------------------------------
15958
+ function isFiniteNumber(value) {
15959
+ return typeof value === 'number' && Number.isFinite(value);
15960
+ }
15961
+ function validateSlices(slices, regionWidth, regionHeight) {
15962
+ const { left, top, right, bottom } = slices;
15963
+ if (!isFiniteNumber(left) || !isFiniteNumber(top) ||
15964
+ !isFiniteNumber(right) || !isFiniteNumber(bottom)) {
15965
+ throw new Error(`NineSliceSprite: slice values must be finite numbers (got left=${left}, top=${top}, right=${right}, bottom=${bottom}).`);
15966
+ }
15967
+ if (left < 0 || top < 0 || right < 0 || bottom < 0) {
15968
+ throw new Error(`NineSliceSprite: slice values must be non-negative (got left=${left}, top=${top}, right=${right}, bottom=${bottom}).`);
15969
+ }
15970
+ if (left + right > regionWidth) {
15971
+ throw new Error(`NineSliceSprite: slices.left (${left}) + slices.right (${right}) exceeds region width (${regionWidth}).`);
15972
+ }
15973
+ if (top + bottom > regionHeight) {
15974
+ throw new Error(`NineSliceSprite: slices.top (${top}) + slices.bottom (${bottom}) exceeds region height (${regionHeight}).`);
15975
+ }
15976
+ }
15977
+ function validateBorder(border) {
15978
+ const { left, top, right, bottom } = border;
15979
+ if (!isFiniteNumber(left) || !isFiniteNumber(top) ||
15980
+ !isFiniteNumber(right) || !isFiniteNumber(bottom)) {
15981
+ throw new Error(`NineSliceSprite: border values must be finite numbers (got left=${left}, top=${top}, right=${right}, bottom=${bottom}).`);
15982
+ }
15983
+ if (left < 0 || top < 0 || right < 0 || bottom < 0) {
15984
+ throw new Error(`NineSliceSprite: border values must be non-negative (got left=${left}, top=${top}, right=${right}, bottom=${bottom}).`);
15985
+ }
15986
+ }
15987
+ // ---------------------------------------------------------------------------
15988
+ // Normalization helpers (module-private)
15989
+ // ---------------------------------------------------------------------------
15990
+ /** Normalise a uniform number or partial insets object into a full {@link NineSliceInsets}. */
15991
+ function normalizeInsets(value, fallback) {
15992
+ if (typeof value === 'number') {
15993
+ return Object.freeze({ left: value, top: value, right: value, bottom: value });
15994
+ }
15995
+ return Object.freeze({
15996
+ left: value.left ?? fallback?.left ?? 0,
15997
+ top: value.top ?? fallback?.top ?? 0,
15998
+ right: value.right ?? fallback?.right ?? 0,
15999
+ bottom: value.bottom ?? fallback?.bottom ?? 0,
16000
+ });
16001
+ }
16002
+ const validRepeatModes$1 = new Set(['stretch', 'repeat', 'mirror-repeat']);
16003
+ const validRepeatFits$1 = new Set(['clip', 'round']);
16004
+ function validateModeField(value, label) {
16005
+ if (typeof value !== 'string' || !validRepeatModes$1.has(value)) {
16006
+ throw new Error(`NineSliceSprite: ${label} must be "stretch", "repeat", or "mirror-repeat".`);
16007
+ }
16008
+ }
16009
+ function validateFitField(value, label) {
16010
+ if (typeof value !== 'string' || !validRepeatFits$1.has(value)) {
16011
+ throw new Error(`NineSliceSprite: ${label} must be "clip" or "round".`);
16012
+ }
16013
+ }
16014
+ function normalizeModes(modes) {
16015
+ if (!modes) {
16016
+ return _defaultModes;
16017
+ }
16018
+ const normalized = {};
16019
+ if (modes.edges !== undefined) {
16020
+ validateModeField(modes.edges, 'modes.edges');
16021
+ normalized.edges = modes.edges;
16022
+ }
16023
+ if (modes.center !== undefined) {
16024
+ validateModeField(modes.center, 'modes.center');
16025
+ normalized.center = modes.center;
16026
+ }
16027
+ if (modes.top !== undefined) {
16028
+ validateModeField(modes.top, 'modes.top');
16029
+ normalized.top = modes.top;
16030
+ }
16031
+ if (modes.right !== undefined) {
16032
+ validateModeField(modes.right, 'modes.right');
16033
+ normalized.right = modes.right;
16034
+ }
16035
+ if (modes.bottom !== undefined) {
16036
+ validateModeField(modes.bottom, 'modes.bottom');
16037
+ normalized.bottom = modes.bottom;
16038
+ }
16039
+ if (modes.left !== undefined) {
16040
+ validateModeField(modes.left, 'modes.left');
16041
+ normalized.left = modes.left;
16042
+ }
16043
+ if (modes.edgeFit !== undefined) {
16044
+ validateFitField(modes.edgeFit, 'modes.edgeFit');
16045
+ normalized.edgeFit = modes.edgeFit;
16046
+ }
16047
+ if (modes.centerFit !== undefined) {
16048
+ validateFitField(modes.centerFit, 'modes.centerFit');
16049
+ normalized.centerFit = modes.centerFit;
16050
+ }
16051
+ return Object.freeze(normalized);
16052
+ }
16053
+ const _defaultModes = Object.freeze({});
16054
+ function equalInsets(a, b) {
16055
+ return a.left === b.left && a.top === b.top && a.right === b.right && a.bottom === b.bottom;
16056
+ }
16057
+ function equalModes(a, b) {
16058
+ return a.edges === b.edges
16059
+ && a.center === b.center
16060
+ && a.top === b.top
16061
+ && a.right === b.right
16062
+ && a.bottom === b.bottom
16063
+ && a.left === b.left
16064
+ && a.edgeFit === b.edgeFit
16065
+ && a.centerFit === b.centerFit;
16066
+ }
16067
+ // ---------------------------------------------------------------------------
16068
+ // Mode resolution helpers (module-private)
16069
+ // ---------------------------------------------------------------------------
16070
+ function resolveEdgeMode(modes, side) {
16071
+ return modes?.[side] ?? modes?.edges ?? 'stretch';
16072
+ }
16073
+ function resolveCenterMode(modes) {
16074
+ return modes?.center ?? 'stretch';
16075
+ }
16076
+ function resolveEdgeFit(modes) {
16077
+ return modes?.edgeFit ?? 'round';
16078
+ }
16079
+ function resolveCenterFit(modes) {
16080
+ return modes?.centerFit ?? 'round';
16081
+ }
16082
+ /**
16083
+ * Compute exact per-slice UV boundaries from source pixel positions.
16084
+ *
16085
+ * Source-slice boundaries correspond to actual pixel coordinates:
16086
+ * left bound = region.x + slices.left
16087
+ * right bound = region.x + region.width - slices.right
16088
+ * top bound = region.y + slices.top
16089
+ * bottom bound = region.y + region.height - slices.bottom
16090
+ *
16091
+ * A half-texel inset is applied at internal boundaries to prevent bilinear
16092
+ * filtering from sampling across slice seams. Outer boundaries use the full
16093
+ * region UV range when extrusion is present, or a half-texel inset otherwise.
16094
+ */
16095
+ function computeSliceUvGrid(region, slices) {
16096
+ const tw = region.texture.width;
16097
+ const th = region.texture.height;
16098
+ const rx = region.x;
16099
+ const ry = region.y;
16100
+ const rw = region.width;
16101
+ const rh = region.height;
16102
+ const ext = region.extrusion;
16103
+ // Outer boundary insets: use extrusion if available, otherwise half-texel.
16104
+ const outerInsetU = ext.left > 0 || ext.right > 0
16105
+ ? 0
16106
+ : halfTexelInset$1 / tw;
16107
+ const outerInsetV = ext.top > 0 || ext.bottom > 0
16108
+ ? 0
16109
+ : halfTexelInset$1 / th;
16110
+ // Inner slice boundary insets: always apply half-texel to prevent
16111
+ // bilinear bleed across internal slice seams.
16112
+ const innerInsetU = halfTexelInset$1 / tw;
16113
+ const innerInsetV = halfTexelInset$1 / th;
16114
+ // Exact source pixel boundaries in UV space
16115
+ const uLeftBound = (rx + slices.left) / tw;
16116
+ const uRightBound = (rx + rw - slices.right) / tw;
16117
+ const vTopBound = (ry + slices.top) / th;
16118
+ const vBottomBound = (ry + rh - slices.bottom) / th;
16119
+ // Outer UV bounds (possibly inset)
16120
+ const uOuter0 = region.u0 + outerInsetU;
16121
+ const uOuter1 = region.u1 - outerInsetU;
16122
+ const vOuter0 = region.v0 + outerInsetV;
16123
+ const vOuter1 = region.v1 - outerInsetV;
16124
+ // Clamp internal boundaries to prevent UV inversion on narrow cells
16125
+ const u1 = clampUv(uLeftBound - innerInsetU, uOuter0, uOuter1);
16126
+ const u2 = clampUv(uRightBound + innerInsetU, uOuter0, uOuter1);
16127
+ const v1 = clampUv(vTopBound - innerInsetV, vOuter0, vOuter1);
16128
+ const v2 = clampUv(vBottomBound + innerInsetV, vOuter0, vOuter1);
16129
+ return {
16130
+ col0: { u0: uOuter0, u1 },
16131
+ col1: { u0: u1, u1: u2 },
16132
+ col2: { u0: u2, u1: uOuter1 },
16133
+ row0: { u0: vOuter0, u1: v1 },
16134
+ row1: { u0: v1, u1: v2 },
16135
+ row2: { u0: v2, u1: vOuter1 },
16136
+ };
16137
+ }
16138
+ function clampUv(value, min, max) {
16139
+ if (!isFiniteNumber(value) || value < min)
16140
+ return min;
16141
+ if (value > max)
16142
+ return max;
16143
+ return value;
16144
+ }
16145
+ function compressBorders(border, width, height) {
16146
+ let bl = border.left;
16147
+ let br = border.right;
16148
+ let bt = border.top;
16149
+ let bb = border.bottom;
16150
+ if (bl + br > width && bl + br > 0) {
16151
+ const k = width / (bl + br);
16152
+ bl *= k;
16153
+ br *= k;
16154
+ }
16155
+ if (bt + bb > height && bt + bb > 0) {
16156
+ const k = height / (bt + bb);
16157
+ bt *= k;
16158
+ bb *= k;
16159
+ }
16160
+ return { bl, br, bt, bb };
16161
+ }
16162
+ // ---------------------------------------------------------------------------
16163
+ // Geometry builder
16164
+ // ---------------------------------------------------------------------------
16165
+ /**
16166
+ * Build the list of quads that collectively render a nine-slice sprite at the
16167
+ * requested destination size. Called lazily by {@link NineSliceSprite}.
16168
+ * @internal
16169
+ */
16170
+ function buildNineSliceQuads(region, slices, border, width, height, modes) {
16171
+ const uvGrid = computeSliceUvGrid(region, slices);
16172
+ const compressed = compressBorders(border, width, height);
16173
+ const bl = compressed.bl;
16174
+ const br = compressed.br;
16175
+ const bt = compressed.bt;
16176
+ const bb = compressed.bb;
16177
+ const centerW = Math.max(0, width - bl - br);
16178
+ const centerH = Math.max(0, height - bt - bb);
16179
+ const dx0 = 0;
16180
+ const dx1 = bl;
16181
+ const dx2 = bl + centerW;
16182
+ const dx3 = width;
16183
+ const dy0 = 0;
16184
+ const dy1 = bt;
16185
+ const dy2 = bt + centerH;
16186
+ const dy3 = height;
16187
+ const srcEdgeW = Math.max(0, region.width - slices.left - slices.right);
16188
+ const srcEdgeH = Math.max(0, region.height - slices.top - slices.bottom);
16189
+ const topScale = slices.top > 0 ? bt / slices.top : 1;
16190
+ const bottomScale = slices.bottom > 0 ? bb / slices.bottom : 1;
16191
+ const leftScale = slices.left > 0 ? bl / slices.left : 1;
16192
+ const rightScale = slices.right > 0 ? br / slices.right : 1;
16193
+ const topNativeW = srcEdgeW * topScale;
16194
+ const bottomNativeW = srcEdgeW * bottomScale;
16195
+ const leftNativeH = srcEdgeH * leftScale;
16196
+ const rightNativeH = srcEdgeH * rightScale;
16197
+ const centerNativeW = topNativeW;
16198
+ const centerNativeH = leftNativeH;
16199
+ const topMode = resolveEdgeMode(modes, 'top');
16200
+ const bottomMode = resolveEdgeMode(modes, 'bottom');
16201
+ const leftMode = resolveEdgeMode(modes, 'left');
16202
+ const rightMode = resolveEdgeMode(modes, 'right');
16203
+ const centerMode = resolveCenterMode(modes);
16204
+ const edgeFit = resolveEdgeFit(modes);
16205
+ const centerFit = resolveCenterFit(modes);
16206
+ const quads = [];
16207
+ const { col0, col1, col2, row0, row1, row2 } = uvGrid;
16208
+ // Corners
16209
+ if (bl > 0 && bt > 0) {
16210
+ quads.push({ x0: dx0, y0: dy0, x1: dx1, y1: dy1, u0: col0.u0, v0: row0.u0, u1: col0.u1, v1: row0.u1 });
16211
+ }
16212
+ if (br > 0 && bt > 0) {
16213
+ quads.push({ x0: dx2, y0: dy0, x1: dx3, y1: dy1, u0: col2.u0, v0: row0.u0, u1: col2.u1, v1: row0.u1 });
16214
+ }
16215
+ if (bl > 0 && bb > 0) {
16216
+ quads.push({ x0: dx0, y0: dy2, x1: dx1, y1: dy3, u0: col0.u0, v0: row2.u0, u1: col0.u1, v1: row2.u1 });
16217
+ }
16218
+ if (br > 0 && bb > 0) {
16219
+ quads.push({ x0: dx2, y0: dy2, x1: dx3, y1: dy3, u0: col2.u0, v0: row2.u0, u1: col2.u1, v1: row2.u1 });
16220
+ }
16221
+ const col1U0 = col1.u0;
16222
+ const col1U1 = col1.u1;
16223
+ const row1U0 = row1.u0;
16224
+ const row1U1 = row1.u1;
16225
+ // Top edge
16226
+ if (centerW > 0 && bt > 0 && srcEdgeW > 0 && topNativeW > 0) {
16227
+ const plan = planRepeat(topNativeW, centerW, topMode, edgeFit);
16228
+ for (const seg of plan.segments) {
16229
+ const qx0 = dx1 + seg.destinationStart;
16230
+ const qx1 = dx1 + seg.destinationStart + seg.destinationLength;
16231
+ const qu0 = col1U0 + seg.sourceStart * (col1U1 - col1U0);
16232
+ const qu1 = col1U0 + seg.sourceEnd * (col1U1 - col1U0);
16233
+ quads.push({ x0: qx0, y0: dy0, x1: qx1, y1: dy1, u0: qu0, v0: row0.u0, u1: qu1, v1: row0.u1 });
16234
+ }
16235
+ }
16236
+ // Bottom edge
16237
+ if (centerW > 0 && bb > 0 && srcEdgeW > 0 && bottomNativeW > 0) {
16238
+ const plan = planRepeat(bottomNativeW, centerW, bottomMode, edgeFit);
16239
+ for (const seg of plan.segments) {
16240
+ const qx0 = dx1 + seg.destinationStart;
16241
+ const qx1 = dx1 + seg.destinationStart + seg.destinationLength;
16242
+ const qu0 = col1U0 + seg.sourceStart * (col1U1 - col1U0);
16243
+ const qu1 = col1U0 + seg.sourceEnd * (col1U1 - col1U0);
16244
+ quads.push({ x0: qx0, y0: dy2, x1: qx1, y1: dy3, u0: qu0, v0: row2.u0, u1: qu1, v1: row2.u1 });
16245
+ }
16246
+ }
16247
+ // Left edge
16248
+ if (bl > 0 && centerH > 0 && srcEdgeH > 0 && leftNativeH > 0) {
16249
+ const plan = planRepeat(leftNativeH, centerH, leftMode, edgeFit);
16250
+ for (const seg of plan.segments) {
16251
+ const qy0 = dy1 + seg.destinationStart;
16252
+ const qy1 = dy1 + seg.destinationStart + seg.destinationLength;
16253
+ const qv0 = row1U0 + seg.sourceStart * (row1U1 - row1U0);
16254
+ const qv1 = row1U0 + seg.sourceEnd * (row1U1 - row1U0);
16255
+ quads.push({ x0: dx0, y0: qy0, x1: dx1, y1: qy1, u0: col0.u0, v0: qv0, u1: col0.u1, v1: qv1 });
16256
+ }
16257
+ }
16258
+ // Right edge
16259
+ if (br > 0 && centerH > 0 && srcEdgeH > 0 && rightNativeH > 0) {
16260
+ const plan = planRepeat(rightNativeH, centerH, rightMode, edgeFit);
16261
+ for (const seg of plan.segments) {
16262
+ const qy0 = dy1 + seg.destinationStart;
16263
+ const qy1 = dy1 + seg.destinationStart + seg.destinationLength;
16264
+ const qv0 = row1U0 + seg.sourceStart * (row1U1 - row1U0);
16265
+ const qv1 = row1U0 + seg.sourceEnd * (row1U1 - row1U0);
16266
+ quads.push({ x0: dx2, y0: qy0, x1: dx3, y1: qy1, u0: col2.u0, v0: qv0, u1: col2.u1, v1: qv1 });
16267
+ }
16268
+ }
16269
+ // Center
16270
+ if (centerW > 0 && centerH > 0 && srcEdgeW > 0 && srcEdgeH > 0 && centerNativeW > 0 && centerNativeH > 0) {
16271
+ const planX = planRepeat(centerNativeW, centerW, centerMode, centerFit);
16272
+ const planY = planRepeat(centerNativeH, centerH, centerMode, centerFit);
16273
+ for (const segY of planY.segments) {
16274
+ const qy0 = dy1 + segY.destinationStart;
16275
+ const qy1 = dy1 + segY.destinationStart + segY.destinationLength;
16276
+ const qv0 = row1U0 + segY.sourceStart * (row1U1 - row1U0);
16277
+ const qv1 = row1U0 + segY.sourceEnd * (row1U1 - row1U0);
16278
+ for (const segX of planX.segments) {
16279
+ const qx0 = dx1 + segX.destinationStart;
16280
+ const qx1 = dx1 + segX.destinationStart + segX.destinationLength;
16281
+ const qu0 = col1U0 + segX.sourceStart * (col1U1 - col1U0);
16282
+ const qu1 = col1U0 + segX.sourceEnd * (col1U1 - col1U0);
16283
+ quads.push({ x0: qx0, y0: qy0, x1: qx1, y1: qy1, u0: qu0, v0: qv0, u1: qu1, v1: qv1 });
16284
+ }
16285
+ }
16286
+ }
16287
+ return quads;
16288
+ }
16289
+
16290
+ function validateSizeInput$1(width, height) {
16291
+ if (!Number.isFinite(width) || !Number.isFinite(height)) {
16292
+ throw new Error(`NineSliceSprite: width and height must be finite numbers (got ${width}, ${height}).`);
16293
+ }
16294
+ if (width < 0) {
16295
+ throw new Error(`NineSliceSprite: width must be non-negative (got ${width}).`);
16296
+ }
16297
+ if (height < 0) {
16298
+ throw new Error(`NineSliceSprite: height must be non-negative (got ${height}).`);
16299
+ }
16300
+ }
16301
+ /**
16302
+ * A scalable nine-slice (9-patch) sprite.
16303
+ * Corners stay pixel-perfect; edges/center fill by stretch, repeat, or mirror-repeat.
16304
+ * @stable
16305
+ */
16306
+ class NineSliceSprite extends Drawable {
16307
+ _region;
16308
+ _slices;
16309
+ _border;
16310
+ _width;
16311
+ _height;
16312
+ _modes;
16313
+ _quads = [];
16314
+ _geometryDirty = true;
16315
+ _renderQuads = [];
16316
+ constructor(texture, options) {
16317
+ super();
16318
+ this._region = texture instanceof TextureRegion
16319
+ ? texture
16320
+ : new TextureRegion(texture, {
16321
+ x: 0,
16322
+ y: 0,
16323
+ width: texture.width,
16324
+ height: texture.height,
16325
+ });
16326
+ const region = this._region;
16327
+ // Validate and own slices
16328
+ const rawSlices = normalizeInsets(options.slices);
16329
+ validateSlices(rawSlices, region.width, region.height);
16330
+ this._slices = rawSlices;
16331
+ // Validate and own border
16332
+ const rawBorder = options.border !== undefined
16333
+ ? normalizeInsets(options.border)
16334
+ : normalizeInsets(options.slices);
16335
+ validateBorder(rawBorder);
16336
+ this._border = rawBorder;
16337
+ // Validate and own size
16338
+ const width = options.width ?? region.width;
16339
+ const height = options.height ?? region.height;
16340
+ validateSizeInput$1(width, height);
16341
+ this._width = width;
16342
+ this._height = height;
16343
+ // Copy and freeze modes
16344
+ this._modes = normalizeModes(options.modes);
16345
+ }
16346
+ // -----------------------------------------------------------------------
16347
+ // Public read-only accessors (engine-owned, frozen)
16348
+ // -----------------------------------------------------------------------
16349
+ /** The TextureRegion this nine-slice samples from. */
16350
+ get region() {
16351
+ return this._region;
16352
+ }
16353
+ /** Convenience accessor: the texture underlying the region. */
16354
+ get texture() {
16355
+ return this._region.texture;
16356
+ }
16357
+ /** The engine-owned, frozen source slice insets. */
16358
+ get slices() {
16359
+ return this._slices;
16360
+ }
16361
+ /** The engine-owned, frozen destination border insets. */
16362
+ get border() {
16363
+ return this._border;
16364
+ }
16365
+ /** The engine-owned, frozen edge/center fill modes. */
16366
+ get modes() {
16367
+ return this._modes;
16368
+ }
16369
+ // -----------------------------------------------------------------------
16370
+ // Width / Height (with atomic validation)
16371
+ // -----------------------------------------------------------------------
16372
+ /** Destination width in local units. */
16373
+ get width() {
16374
+ return this._width;
16375
+ }
16376
+ set width(value) {
16377
+ this.setSize(value, this._height);
16378
+ }
16379
+ /** Destination height in local units. */
16380
+ get height() {
16381
+ return this._height;
16382
+ }
16383
+ set height(value) {
16384
+ this.setSize(this._width, value);
16385
+ }
16386
+ // -----------------------------------------------------------------------
16387
+ // Mutators
16388
+ // -----------------------------------------------------------------------
16389
+ /** Set destination size. Fails atomically — prior state is preserved on invalid input. */
16390
+ setSize(width, height) {
16391
+ validateSizeInput$1(width, height);
16392
+ if (this._width !== width || this._height !== height) {
16393
+ this._width = width;
16394
+ this._height = height;
16395
+ this._geometryDirty = true;
16396
+ this.invalidateCache();
16397
+ }
16398
+ return this;
16399
+ }
16400
+ /** Update the SOURCE-space slice insets. Fails atomically. No-ops on equivalent values. */
16401
+ setSlices(slices) {
16402
+ const region = this._region;
16403
+ const normalized = normalizeInsets(slices);
16404
+ validateSlices(normalized, region.width, region.height);
16405
+ if (equalInsets(normalized, this._slices)) {
16406
+ return this;
16407
+ }
16408
+ this._slices = normalized;
16409
+ this._geometryDirty = true;
16410
+ this.invalidateCache();
16411
+ return this;
16412
+ }
16413
+ /** Update the DESTINATION border sizes. Fails atomically. No-ops on equivalent values. */
16414
+ setBorder(border) {
16415
+ const normalized = normalizeInsets(border);
16416
+ validateBorder(normalized);
16417
+ if (equalInsets(normalized, this._border)) {
16418
+ return this;
16419
+ }
16420
+ this._border = normalized;
16421
+ this._geometryDirty = true;
16422
+ this.invalidateCache();
16423
+ return this;
16424
+ }
16425
+ /** Update the edge/center fill modes. Input is copied, validated, and frozen. No-ops on equivalent values. */
16426
+ setModes(modes) {
16427
+ const normalized = normalizeModes(modes);
16428
+ if (equalModes(normalized, this._modes)) {
16429
+ return this;
16430
+ }
16431
+ this._modes = normalized;
16432
+ this._geometryDirty = true;
16433
+ this.invalidateCache();
16434
+ return this;
16435
+ }
16436
+ // -----------------------------------------------------------------------
16437
+ // Bounds
16438
+ // -----------------------------------------------------------------------
16439
+ getLocalBounds() {
16440
+ const bounds = super.getLocalBounds();
16441
+ bounds.set(0, 0, this._width, this._height);
16442
+ return bounds;
16443
+ }
16444
+ // -----------------------------------------------------------------------
16445
+ // Internal geometry (for renderers)
16446
+ // -----------------------------------------------------------------------
16447
+ /**
16448
+ * Lazily-built geometry quads. Each quad describes one rendered sub-region
16449
+ * in local space with its corresponding UV bounds.
16450
+ * @internal
16451
+ */
16452
+ get quads() {
16453
+ if (this._geometryDirty) {
16454
+ this._rebuildGeometry();
16455
+ }
16456
+ return this._quads;
16457
+ }
16458
+ /**
16459
+ * Render-time quads for the active pass. In `'geometry'` pixel-snap mode (and
16460
+ * only when the combined node+view transform is axis-aligned) the shared
16461
+ * boundary plan is snapped to the render target's device-pixel grid via the
16462
+ * common {@link snapQuadsInto} helper, so every corner/edge/center quad reuses
16463
+ * the exact same snapped boundary value and no seams can open. The content
16464
+ * quad cache ({@link quads}) is never rebuilt by snapping — camera movement
16465
+ * reuses it — and snapped quads are written into a reused buffer. Returns the
16466
+ * unsnapped content quads for `'none'`/`'position'` or under a rotation/skew
16467
+ * downgrade.
16468
+ * @internal
16469
+ */
16470
+ getRenderQuads(view, targetPxWidth, targetPxHeight) {
16471
+ const base = this.quads;
16472
+ if (base.length === 0) {
16473
+ return base;
16474
+ }
16475
+ const ctx = buildPixelSnapContext(this.getGlobalTransform(), view, targetPxWidth, targetPxHeight);
16476
+ if (!ctx.axisAligned) {
16477
+ return base;
16478
+ }
16479
+ return snapQuadsInto(base, ctx, this._renderQuads);
16480
+ }
16481
+ // -----------------------------------------------------------------------
16482
+ // Private helpers
16483
+ // -----------------------------------------------------------------------
16484
+ _rebuildGeometry() {
16485
+ this._quads = buildNineSliceQuads(this._region, this._slices, this._border, this._width, this._height, this._modes);
16486
+ this._geometryDirty = false;
16487
+ }
16488
+ }
16489
+
16490
+ // ---------------------------------------------------------------------------
16491
+ // Validation
16492
+ // ---------------------------------------------------------------------------
16493
+ const validRepeatModes = new Set(['stretch', 'repeat', 'mirror-repeat']);
16494
+ const validRepeatFits = new Set(['clip', 'round']);
16495
+ function validateSizeInput(width, height) {
16496
+ if (!Number.isFinite(width) || !Number.isFinite(height)) {
16497
+ throw new Error(`RepeatingSprite: width and height must be finite numbers (got ${width}, ${height}).`);
16498
+ }
16499
+ if (width < 0) {
16500
+ throw new Error(`RepeatingSprite: width must be non-negative (got ${width}).`);
16501
+ }
16502
+ if (height < 0) {
16503
+ throw new Error(`RepeatingSprite: height must be non-negative (got ${height}).`);
16504
+ }
16505
+ }
16506
+ function validateMode(mode, label) {
16507
+ if (typeof mode !== 'string' || !validRepeatModes.has(mode)) {
16508
+ throw new Error(`RepeatingSprite: ${label} must be "stretch", "repeat", or "mirror-repeat" (got ${String(mode)}).`);
16509
+ }
16510
+ }
16511
+ function validateFit(fit, label) {
16512
+ if (typeof fit !== 'string' || !validRepeatFits.has(fit)) {
16513
+ throw new Error(`RepeatingSprite: ${label} must be "clip" or "round" (got ${String(fit)}).`);
16514
+ }
16515
+ }
16516
+ function validateOffset(value, label) {
16517
+ if (!Number.isFinite(value)) {
16518
+ throw new Error(`RepeatingSprite: ${label} must be a finite number (got ${value}).`);
16519
+ }
16520
+ }
16521
+ // ---------------------------------------------------------------------------
16522
+ // Shader-path tiling helpers
16523
+ // ---------------------------------------------------------------------------
16524
+ /**
16525
+ * Compute the UV tiling scale for the standalone shader path.
16526
+ *
16527
+ * Returns how many times the source repeats across the destination span.
16528
+ * - `stretch`: always 1.0
16529
+ * - `repeat`/`mirror-repeat` + `round`: nearest integer count (≥ 1)
16530
+ * - `repeat`/`mirror-repeat` + `clip`: exact fraction `destLen / srcLen`
16531
+ * @internal
16532
+ */
16533
+ function computeShaderTiling(srcLen, destLen, mode, fit) {
16534
+ if (mode === 'stretch' || srcLen <= 0 || destLen <= 0) {
16535
+ return 1;
16536
+ }
16537
+ if (fit === 'round') {
16538
+ return Math.max(1, Math.round(destLen / srcLen));
16539
+ }
16540
+ return destLen / srcLen;
16541
+ }
16542
+ // ---------------------------------------------------------------------------
16543
+ // Geometry-path quad builder
16544
+ // ---------------------------------------------------------------------------
16545
+ const halfTexelInset = 0.5;
16546
+ /**
16547
+ * Build the geometry quads for the atlas-region repeat path.
16548
+ *
16549
+ * Runs {@link planRepeat} independently on both X and Y axes with optional
16550
+ * phase offsets, then generates the Cartesian-product quad list. Each quad
16551
+ * carries UV coordinates clamped inside the region's UV bounds, with
16552
+ * extrusion-aware outer insets.
16553
+ * @internal
16554
+ */
16555
+ function buildRepeatingSpriteQuads(region, width, height, modeX, modeY, fitX, fitY, offsetX, offsetY) {
16556
+ if (width === 0 || height === 0 || region.width <= 0 || region.height <= 0) {
16557
+ return [];
16558
+ }
16559
+ const tw = region.texture.width;
16560
+ const th = region.texture.height;
16561
+ const ext = region.extrusion;
16562
+ const outerInsetU = (ext.left > 0 || ext.right > 0) ? 0 : halfTexelInset / tw;
16563
+ const outerInsetV = (ext.top > 0 || ext.bottom > 0) ? 0 : halfTexelInset / th;
16564
+ const uMin = region.u0 + outerInsetU;
16565
+ const uMax = region.u1 - outerInsetU;
16566
+ const vMin = region.v0 + outerInsetV;
16567
+ const vMax = region.v1 - outerInsetV;
16568
+ const srcW = region.width;
16569
+ const srcH = region.height;
16570
+ const uRange = uMax - uMin;
16571
+ const vRange = vMax - vMin;
16572
+ const segsX = buildAxisSegmentsWithOffset(srcW, width, modeX, fitX, offsetX);
16573
+ const segsY = buildAxisSegmentsWithOffset(srcH, height, modeY, fitY, offsetY);
16574
+ const quads = [];
16575
+ for (const sy of segsY) {
16576
+ const qy0 = sy.destinationStart;
16577
+ const qy1 = sy.destinationStart + sy.destinationLength;
16578
+ const qv0 = vMin + sy.sourceStart * vRange;
16579
+ const qv1 = vMin + sy.sourceEnd * vRange;
16580
+ for (const sx of segsX) {
16581
+ const qx0 = sx.destinationStart;
16582
+ const qx1 = sx.destinationStart + sx.destinationLength;
16583
+ const qu0 = uMin + sx.sourceStart * uRange;
16584
+ const qu1 = uMin + sx.sourceEnd * uRange;
16585
+ quads.push({ x0: qx0, y0: qy0, x1: qx1, y1: qy1, u0: qu0, v0: qv0, u1: qu1, v1: qv1 });
16586
+ }
16587
+ }
16588
+ return quads;
16589
+ }
16590
+ /**
16591
+ * Build repeat segments for one axis with an optional phase offset.
16592
+ *
16593
+ * The offset shifts the starting phase of the repeat in source-pixel units.
16594
+ * For `stretch` mode the offset is silently ignored.
16595
+ *
16596
+ * Implementation: generates a plan for `(destLen + phase)`, shifts all
16597
+ * segment destinations back by `phase`, and clips to `[0, destLen]`. This
16598
+ * is correct for `clip` fit; for `round` fit the extended-span count may
16599
+ * differ slightly from the zero-offset count — atlas scrolling is
16600
+ * discouraged by design.
16601
+ * @internal
16602
+ */
16603
+ function buildAxisSegmentsWithOffset(srcLen, destLen, mode, fit, offset) {
16604
+ if (destLen === 0 || srcLen <= 0) {
16605
+ return [];
16606
+ }
16607
+ if (mode === 'stretch') {
16608
+ return [...planRepeat(srcLen, destLen, mode, fit).segments];
16609
+ }
16610
+ const phase = ((offset % srcLen) + srcLen) % srcLen;
16611
+ if (phase === 0) {
16612
+ return [...planRepeat(srcLen, destLen, mode, fit).segments];
16613
+ }
16614
+ const extendedPlan = planRepeat(srcLen, destLen + phase, mode, fit);
16615
+ const result = [];
16616
+ for (const seg of extendedPlan.segments) {
16617
+ const dStart = seg.destinationStart - phase;
16618
+ const dEnd = dStart + seg.destinationLength;
16619
+ if (dEnd <= 0 || dStart >= destLen) {
16620
+ continue;
16621
+ }
16622
+ const clippedStart = Math.max(0, dStart);
16623
+ const clippedEnd = Math.min(destLen, dEnd);
16624
+ const clippedLen = clippedEnd - clippedStart;
16625
+ if (clippedLen <= 0) {
16626
+ continue;
16627
+ }
16628
+ const t0 = (clippedStart - dStart) / seg.destinationLength;
16629
+ const t1 = (clippedEnd - dStart) / seg.destinationLength;
16630
+ const srcRange = seg.sourceEnd - seg.sourceStart;
16631
+ result.push({
16632
+ destinationStart: clippedStart,
16633
+ destinationLength: clippedLen,
16634
+ sourceStart: seg.sourceStart + t0 * srcRange,
16635
+ sourceEnd: seg.sourceStart + t1 * srcRange,
16636
+ mirrored: seg.mirrored,
16637
+ });
16638
+ }
16639
+ return result;
16640
+ }
16641
+
16642
+ /**
16643
+ * A sprite that fills its destination by repeating, mirroring, or stretching
16644
+ * a source texture or atlas region.
16645
+ *
16646
+ * Supports independent per-axis modes (`modeX`, `modeY`) and fit strategies
16647
+ * (`fitX`, `fitY`).
16648
+ *
16649
+ * ## Internal rendering strategy (selected automatically)
16650
+ *
16651
+ * | Source type | Internal path |
16652
+ * |-------------------|-------------------------------------------------------|
16653
+ * | Bare `Texture` | **Shader path** — one quad, GPU sampler repeat wrap. |
16654
+ * | `TextureRegion` | **Geometry path** — Cartesian-product quads, clamped. |
16655
+ *
16656
+ * The public class identity and API do not change based on which path the
16657
+ * renderer uses.
16658
+ *
16659
+ * @stable
16660
+ */
16661
+ class RepeatingSprite extends Drawable {
16662
+ _source;
16663
+ _region;
16664
+ _width;
16665
+ _height;
16666
+ _modeX;
16667
+ _modeY;
16668
+ _fitX;
16669
+ _fitY;
16670
+ _offsetX;
16671
+ _offsetY;
16672
+ _quads = [];
16673
+ _geometryDirty = true;
16674
+ _renderQuads = [];
16675
+ constructor(source, options) {
16676
+ super();
16677
+ this._source = source;
16678
+ this._region = source instanceof TextureRegion
16679
+ ? source
16680
+ : new TextureRegion(source, { x: 0, y: 0, width: source.width, height: source.height });
16681
+ const region = this._region;
16682
+ const opts = options ?? {};
16683
+ const modeX = opts.modeX ?? 'repeat';
16684
+ const modeY = opts.modeY ?? 'repeat';
16685
+ validateMode(modeX, 'modeX');
16686
+ validateMode(modeY, 'modeY');
16687
+ this._modeX = modeX;
16688
+ this._modeY = modeY;
16689
+ const fitX = opts.fitX ?? 'round';
16690
+ const fitY = opts.fitY ?? 'round';
16691
+ validateFit(fitX, 'fitX');
16692
+ validateFit(fitY, 'fitY');
16693
+ this._fitX = fitX;
16694
+ this._fitY = fitY;
16695
+ const offsetX = opts.offsetX ?? 0;
16696
+ const offsetY = opts.offsetY ?? 0;
16697
+ validateOffset(offsetX, 'offsetX');
16698
+ validateOffset(offsetY, 'offsetY');
16699
+ this._offsetX = offsetX;
16700
+ this._offsetY = offsetY;
16701
+ const width = opts.width ?? region.width;
16702
+ const height = opts.height ?? region.height;
16703
+ validateSizeInput(width, height);
16704
+ this._width = width;
16705
+ this._height = height;
16706
+ }
16707
+ // -----------------------------------------------------------------------
16708
+ // Accessors
16709
+ // -----------------------------------------------------------------------
16710
+ /** The original source passed to the constructor. */
16711
+ get source() {
16712
+ return this._source;
16713
+ }
16714
+ /** TextureRegion wrapping the source (bare Texture sources auto-wrapped). */
16715
+ get region() {
16716
+ return this._region;
16717
+ }
16718
+ /** Convenience accessor: the underlying Texture. */
16719
+ get texture() {
16720
+ return this._region.texture;
16721
+ }
16722
+ /** Destination width in local units. */
16723
+ get width() {
16724
+ return this._width;
16725
+ }
16726
+ set width(value) {
16727
+ this.setSize(value, this._height);
16728
+ }
16729
+ /** Destination height in local units. */
16730
+ get height() {
16731
+ return this._height;
16732
+ }
16733
+ set height(value) {
16734
+ this.setSize(this._width, value);
16735
+ }
16736
+ /** Horizontal repeat mode. */
16737
+ get modeX() {
16738
+ return this._modeX;
16739
+ }
16740
+ set modeX(value) {
16741
+ validateMode(value, 'modeX');
16742
+ if (this._modeX !== value) {
16743
+ this._modeX = value;
16744
+ this._geometryDirty = true;
16745
+ this.invalidateCache();
16746
+ }
16747
+ }
16748
+ /** Vertical repeat mode. */
16749
+ get modeY() {
16750
+ return this._modeY;
16751
+ }
16752
+ set modeY(value) {
16753
+ validateMode(value, 'modeY');
16754
+ if (this._modeY !== value) {
16755
+ this._modeY = value;
16756
+ this._geometryDirty = true;
16757
+ this.invalidateCache();
16758
+ }
16759
+ }
16760
+ /** Horizontal fit mode (how partial repeats are resolved). */
16761
+ get fitX() {
16762
+ return this._fitX;
16763
+ }
16764
+ set fitX(value) {
16765
+ validateFit(value, 'fitX');
16766
+ if (this._fitX !== value) {
16767
+ this._fitX = value;
16768
+ this._geometryDirty = true;
16769
+ this.invalidateCache();
16770
+ }
16771
+ }
16772
+ /** Vertical fit mode (how partial repeats are resolved). */
16773
+ get fitY() {
16774
+ return this._fitY;
16775
+ }
16776
+ set fitY(value) {
16777
+ validateFit(value, 'fitY');
16778
+ if (this._fitY !== value) {
16779
+ this._fitY = value;
16780
+ this._geometryDirty = true;
16781
+ this.invalidateCache();
16782
+ }
16783
+ }
16784
+ /**
16785
+ * Horizontal scroll offset in source-pixel units.
16786
+ *
16787
+ * On the shader path (bare `Texture` source), changing the offset does
16788
+ * **not** trigger a geometry rebuild — the value is forwarded as
16789
+ * per-instance data at render time. On the geometry path
16790
+ * (`TextureRegion` source), offset changes mark quads dirty.
16791
+ */
16792
+ get offsetX() {
16793
+ return this._offsetX;
16794
+ }
16795
+ set offsetX(value) {
16796
+ this.setOffset(value, this._offsetY);
16797
+ }
16798
+ /** Vertical scroll offset in source-pixel units. See {@link offsetX}. */
16799
+ get offsetY() {
16800
+ return this._offsetY;
16801
+ }
16802
+ set offsetY(value) {
16803
+ this.setOffset(this._offsetX, value);
16804
+ }
16805
+ // -----------------------------------------------------------------------
16806
+ // Fluent mutators
16807
+ // -----------------------------------------------------------------------
16808
+ /** Set destination size atomically. Preserves prior state on invalid input. */
16809
+ setSize(width, height) {
16810
+ validateSizeInput(width, height);
16811
+ if (this._width !== width || this._height !== height) {
16812
+ this._width = width;
16813
+ this._height = height;
16814
+ this._geometryDirty = true;
16815
+ this.invalidateCache();
16816
+ }
16817
+ return this;
16818
+ }
16819
+ /**
16820
+ * Set scroll offset atomically in source-pixel units.
16821
+ * On the shader path this does not rebuild geometry.
16822
+ * On the geometry path this marks geometry dirty.
16823
+ */
16824
+ setOffset(offsetX, offsetY) {
16825
+ validateOffset(offsetX, 'offsetX');
16826
+ validateOffset(offsetY, 'offsetY');
16827
+ if (this._offsetX !== offsetX || this._offsetY !== offsetY) {
16828
+ this._offsetX = offsetX;
16829
+ this._offsetY = offsetY;
16830
+ if (this.resolvedStrategy === 'geometry') {
16831
+ this._geometryDirty = true;
16832
+ }
16833
+ this.invalidateCache();
16834
+ }
16835
+ return this;
16836
+ }
16837
+ // -----------------------------------------------------------------------
16838
+ // Bounds
16839
+ // -----------------------------------------------------------------------
16840
+ getLocalBounds() {
16841
+ const bounds = super.getLocalBounds();
16842
+ bounds.set(0, 0, this._width, this._height);
16843
+ return bounds;
16844
+ }
16845
+ // -----------------------------------------------------------------------
16846
+ // Internal renderer interface
16847
+ // -----------------------------------------------------------------------
16848
+ /**
16849
+ * Rendering strategy determined by the source type:
16850
+ * - `'shader'` — bare `Texture`; renderer uses GPU sampler wrap.
16851
+ * - `'geometry'` — `TextureRegion`; renderer builds repeat quads.
16852
+ * @internal
16853
+ */
16854
+ get resolvedStrategy() {
16855
+ return this._source instanceof TextureRegion ? 'geometry' : 'shader';
16856
+ }
16857
+ /**
16858
+ * Lazily-built geometry quads for the geometry path.
16859
+ * Returns an empty array on the shader path.
16860
+ * @internal
16861
+ */
16862
+ get quads() {
16863
+ if (this.resolvedStrategy === 'geometry' && this._geometryDirty) {
16864
+ this._rebuildGeometry();
16865
+ }
16866
+ return this._quads;
16867
+ }
16868
+ /**
16869
+ * Render-time quads for the **geometry strategy**, device-pixel-snapped in
16870
+ * `'geometry'` pixel-snap mode (axis-aligned only). Like NineSlice, every
16871
+ * shared repeat-segment boundary is snapped once by {@link snapQuadsInto}, so
16872
+ * adjacent segments stay gap-free; the content cache ({@link quads}) is never
16873
+ * rebuilt by snapping. Returns the unsnapped quads otherwise.
16874
+ * @internal
16875
+ */
16876
+ getRenderQuads(view, targetPxWidth, targetPxHeight) {
16877
+ const base = this.quads;
16878
+ if (this.pixelSnapMode !== 'geometry' || base.length === 0) {
16879
+ return base;
16880
+ }
16881
+ const ctx = buildPixelSnapContext(this.getGlobalTransform(), view, targetPxWidth, targetPxHeight);
16882
+ if (!ctx.axisAligned) {
16883
+ return base;
16884
+ }
16885
+ return snapQuadsInto(base, ctx, this._renderQuads);
16886
+ }
16887
+ /**
16888
+ * Render-time destination bounds for the **shader strategy**, written into
16889
+ * `out`. In `'geometry'` pixel-snap mode (axis-aligned only) the destination
16890
+ * quad edges are snapped to the device grid; repetition stays shader-based, so
16891
+ * only the outer rectangle moves. Returns the logical local bounds otherwise.
16892
+ * @internal
16893
+ */
16894
+ getRenderBounds(view, targetPxWidth, targetPxHeight, out) {
16895
+ const base = this.getLocalBounds();
16896
+ if (this.pixelSnapMode !== 'geometry') {
16897
+ return base;
16898
+ }
16899
+ const ctx = buildPixelSnapContext(this.getGlobalTransform(), view, targetPxWidth, targetPxHeight);
16900
+ if (!ctx.axisAligned) {
16901
+ return base;
16902
+ }
16903
+ return snapBoundsInto(base, ctx, out);
16904
+ }
16905
+ // -----------------------------------------------------------------------
16906
+ // Private helpers
16907
+ // -----------------------------------------------------------------------
16908
+ _rebuildGeometry() {
16909
+ this._quads = buildRepeatingSpriteQuads(this._region, this._width, this._height, this._modeX, this._modeY, this._fitX, this._fitY, this._offsetX, this._offsetY);
16910
+ this._geometryDirty = false;
16911
+ }
16912
+ }
16913
+
15390
16914
  /**
15391
16915
  * Internal dirty-flag bitmask used by {@link Sprite} to lazily recompute
15392
16916
  * derived data (vertices, normals, texture coordinates, bounding boxes).
@@ -15501,6 +17025,28 @@ class Sprite extends Drawable {
15501
17025
  }
15502
17026
  return this._vertices;
15503
17027
  }
17028
+ /**
17029
+ * Local-space quad bounds for the active pass, written into `out`. In
17030
+ * `'geometry'` pixel-snap mode (and only when the combined node+view transform
17031
+ * is axis-aligned) the quad edges are snapped to the render target's
17032
+ * device-pixel grid via the shared {@link snapLocalBoundary} helper — combined
17033
+ * with the device-snapped origin from the transform seam, all four corners
17034
+ * land on whole device pixels. The logical local bounds (used by collision /
17035
+ * `getBounds`) are never changed. Returns the unsnapped local bounds for
17036
+ * `'none'`/`'position'` or under a rotation/skew downgrade.
17037
+ * @internal
17038
+ */
17039
+ getRenderBounds(view, targetPxWidth, targetPxHeight, out) {
17040
+ const base = this.getLocalBounds();
17041
+ if (this.pixelSnapMode !== 'geometry') {
17042
+ return base;
17043
+ }
17044
+ const ctx = buildPixelSnapContext(this.getGlobalTransform(), view, targetPxWidth, targetPxHeight);
17045
+ if (!ctx.axisAligned) {
17046
+ return base;
17047
+ }
17048
+ return snapBoundsInto(base, ctx, out);
17049
+ }
15504
17050
  /**
15505
17051
  * Packed UV coordinates for the four quad corners, encoded as two
15506
17052
  * 16-bit fixed-point values per element (low 16 bits = U, high 16 bits = V,
@@ -18308,16 +19854,16 @@ const initialIndexCapacity$2 = 192;
18308
19854
  const initialNodeIndexCapacity = 64;
18309
19855
  const defaultVertexColor = 0xffffffff; // white, full alpha
18310
19856
  const maxCustomTextureSlots$2 = 8;
18311
- const transformTextureUnit$1 = 8;
19857
+ const transformTextureUnit$3 = 8;
18312
19858
  class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
18313
19859
  _defaultShader = new Shader(vertexSource$3, fragmentSource$3);
18314
19860
  _customShaders = new Map();
18315
19861
  _compatibilityCache = new Map();
18316
19862
  _textureUnitScratch = new Int32Array([0]);
18317
- _transformUnitScratch = new Int32Array([transformTextureUnit$1]);
19863
+ _transformUnitScratch = new Int32Array([transformTextureUnit$3]);
18318
19864
  // Pre-built texture-unit indices used for custom-shader sampler bindings;
18319
19865
  // pre-allocated so the per-frame uniform path stays allocation-free.
18320
- _slotScratches = Array.from({ length: Math.max(transformTextureUnit$1 + 1, maxCustomTextureSlots$2 + 1) }, (_, i) => new Int32Array([i]));
19866
+ _slotScratches = Array.from({ length: Math.max(transformTextureUnit$3 + 1, maxCustomTextureSlots$2 + 1) }, (_, i) => new Int32Array([i]));
18321
19867
  _vertexCapacity = initialVertexCapacity$2;
18322
19868
  _indexCapacity = initialIndexCapacity$2;
18323
19869
  _nodeIndexCapacity = initialNodeIndexCapacity;
@@ -18544,7 +20090,7 @@ class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
18544
20090
  shader.getUniform('u_projection').setValue(backend.view.getTransform().toArray(false));
18545
20091
  }
18546
20092
  if (shader.uniforms.has('u_transforms')) {
18547
- backend.bindTransformBufferTexture(transformTextureUnit$1, maxNodeIndex + 1);
20093
+ backend.bindTransformBufferTexture(transformTextureUnit$3, maxNodeIndex + 1);
18548
20094
  shader.getUniform('u_transforms').setValue(this._transformUnitScratch);
18549
20095
  }
18550
20096
  if (shader.uniforms.has('u_texture')) {
@@ -18895,6 +20441,828 @@ class WebGl2MeshRenderer extends AbstractWebGl2Renderer {
18895
20441
  }
18896
20442
  }
18897
20443
 
20444
+ const nineSliceVertexSource = `#version 300 es
20445
+ precision lowp float;
20446
+ precision highp int;
20447
+
20448
+ // Per-instance attributes (divisor = 1). One entry per nine-slice quad.
20449
+ // gl_VertexID 0..3 selects which corner of the quad this invocation computes.
20450
+ layout(location = 0) in vec4 a_quadBounds; // x0, y0, x1, y1 (local space)
20451
+ layout(location = 1) in vec4 a_uvBounds; // u0, v0, u1, v1 (normalised, flipY pre-applied)
20452
+ layout(location = 2) in vec4 a_color; // RGBA tint
20453
+ layout(location = 3) in uint a_nodeIndex; // row into the shared transform buffer
20454
+
20455
+ uniform mat3 u_projection;
20456
+ uniform sampler2D u_transforms; // shared per-frame transform buffer (3 texels/row)
20457
+
20458
+ out vec2 v_texcoord;
20459
+ out vec4 v_color;
20460
+
20461
+ void main(void) {
20462
+ // gl_VertexID 0..3 -> corner: 0=TL, 1=TR, 2=BL, 3=BR (TRIANGLE_STRIP order)
20463
+ int vid = gl_VertexID;
20464
+ int cornerX = vid & 1;
20465
+ int cornerY = (vid >> 1) & 1;
20466
+
20467
+ float localX = (cornerX == 0) ? a_quadBounds.x : a_quadBounds.z;
20468
+ float localY = (cornerY == 0) ? a_quadBounds.y : a_quadBounds.w;
20469
+
20470
+ int row = int(a_nodeIndex);
20471
+ vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0); // a, b, c, d
20472
+ vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0); // tx, ty, 0, 0
20473
+
20474
+ float worldX = (m0.x * localX) + (m0.y * localY) + m1.x;
20475
+ float worldY = (m0.z * localX) + (m0.w * localY) + m1.y;
20476
+
20477
+ gl_Position = vec4((u_projection * vec3(worldX, worldY, 1.0)).xy, 0.0, 1.0);
20478
+
20479
+ float u = (cornerX == 0) ? a_uvBounds.x : a_uvBounds.z;
20480
+ float v = (cornerY == 0) ? a_uvBounds.y : a_uvBounds.w;
20481
+ v_texcoord = vec2(u, v);
20482
+
20483
+ v_color = vec4(a_color.rgb * a_color.a, a_color.a);
20484
+ }`;
20485
+ const nineSliceFragmentSource = `#version 300 es
20486
+ precision lowp float;
20487
+
20488
+ uniform sampler2D u_texture;
20489
+
20490
+ in vec2 v_texcoord;
20491
+ in vec4 v_color;
20492
+
20493
+ layout(location = 0) out vec4 fragColor;
20494
+
20495
+ void main(void) {
20496
+ vec4 sampleColor = texture(u_texture, v_texcoord);
20497
+ fragColor = sampleColor * v_color;
20498
+ }`;
20499
+ const instanceStrideBytes$3 = 32;
20500
+ const wordsPerInstance$3 = instanceStrideBytes$3 / Uint32Array.BYTES_PER_ELEMENT;
20501
+ const transformTextureUnit$2 = 1;
20502
+ /** Instanced renderer for {@link NineSliceSprite} using WebGL2. */
20503
+ class WebGl2NineSliceSpriteRenderer extends AbstractWebGl2Renderer {
20504
+ _shader;
20505
+ _batchSize;
20506
+ _instanceData;
20507
+ _instanceFloat32;
20508
+ _instanceUint32;
20509
+ _transformUnitScratch = new Int32Array([transformTextureUnit$2]);
20510
+ _quadIndex = 0;
20511
+ _maxNodeIndex = 0;
20512
+ _currentBlendMode = null;
20513
+ _currentTexture = null;
20514
+ _currentView = null;
20515
+ _currentViewId = -1;
20516
+ _instanceBuffer = null;
20517
+ _vao = null;
20518
+ _connection = null;
20519
+ constructor(batchSize) {
20520
+ super();
20521
+ this._batchSize = batchSize;
20522
+ this._shader = new Shader(nineSliceVertexSource, nineSliceFragmentSource);
20523
+ this._instanceData = new ArrayBuffer(batchSize * instanceStrideBytes$3);
20524
+ this._instanceFloat32 = new Float32Array(this._instanceData);
20525
+ this._instanceUint32 = new Uint32Array(this._instanceData);
20526
+ }
20527
+ render(sprite) {
20528
+ const backend = this.getBackend();
20529
+ let quads = sprite.quads;
20530
+ if (sprite.pixelSnapMode === 'geometry') {
20531
+ const snap = backend._getSnapPixelSize();
20532
+ quads = sprite.getRenderQuads(backend.view, snap.width, snap.height);
20533
+ }
20534
+ if (quads.length === 0) {
20535
+ return;
20536
+ }
20537
+ const texture = sprite.texture;
20538
+ const blendMode = sprite.blendMode;
20539
+ const tintRgba = sprite.tint.toRgba();
20540
+ const command = backend.activeDrawCommand;
20541
+ const nodeIndex = command !== null ? command.nodeIndex : backend._pushTransform(sprite);
20542
+ const textureChanged = this._currentTexture !== null && texture !== this._currentTexture;
20543
+ const blendModeChanged = blendMode !== this._currentBlendMode;
20544
+ // If the batch would overflow with current quads + new quads, flush first.
20545
+ if (this._quadIndex > 0) {
20546
+ if (blendModeChanged || textureChanged || this._quadIndex + quads.length > this._batchSize) {
20547
+ this.flush();
20548
+ }
20549
+ }
20550
+ // Establish blend and texture state (may have been cleared by flush).
20551
+ if (this._currentBlendMode === null || this._currentBlendMode !== blendMode) {
20552
+ this._currentBlendMode = blendMode;
20553
+ backend.setBlendMode(blendMode);
20554
+ }
20555
+ if (this._currentTexture !== texture) {
20556
+ this._currentTexture = texture;
20557
+ backend.bindTexture(texture, 0);
20558
+ }
20559
+ // A single sprite may produce more quads than the fixed batch buffer can hold.
20560
+ // Process in [start, end) chunks, flushing between each. Iterating by index
20561
+ // range (rather than `quads.slice(...)`) keeps the overflow path allocation-free.
20562
+ let offset = 0;
20563
+ while (offset < quads.length) {
20564
+ const chunkEnd = Math.min(offset + this._batchSize, quads.length);
20565
+ this._writeQuadChunk(quads, offset, chunkEnd, texture, tintRgba, nodeIndex);
20566
+ offset = chunkEnd;
20567
+ if (offset < quads.length) {
20568
+ this.flush();
20569
+ // Re-establish state after flush
20570
+ this._currentBlendMode = blendMode;
20571
+ backend.setBlendMode(blendMode);
20572
+ this._currentTexture = texture;
20573
+ backend.bindTexture(texture, 0);
20574
+ }
20575
+ }
20576
+ }
20577
+ _writeQuadChunk(quads, start, end, texture, tintRgba, nodeIndex) {
20578
+ const f32 = this._instanceFloat32;
20579
+ const u32 = this._instanceUint32;
20580
+ const flipY = texture.flipY;
20581
+ for (let i = start; i < end; i++) {
20582
+ const q = quads[i];
20583
+ const idx = this._quadIndex * wordsPerInstance$3;
20584
+ f32[idx + 0] = q.x0;
20585
+ f32[idx + 1] = q.y0;
20586
+ f32[idx + 2] = q.x1;
20587
+ f32[idx + 3] = q.y1;
20588
+ const uMin = (q.u0 * 0xffff) & 0xffff;
20589
+ const uMax = (q.u1 * 0xffff) & 0xffff;
20590
+ const v0Raw = (q.v0 * 0xffff) & 0xffff;
20591
+ const v1Raw = (q.v1 * 0xffff) & 0xffff;
20592
+ const vMin = flipY ? v1Raw : v0Raw;
20593
+ const vMax = flipY ? v0Raw : v1Raw;
20594
+ u32[idx + 4] = uMin | (vMin << 16);
20595
+ u32[idx + 5] = uMax | (vMax << 16);
20596
+ u32[idx + 6] = tintRgba;
20597
+ u32[idx + 7] = nodeIndex >>> 0;
20598
+ this._quadIndex++;
20599
+ if (nodeIndex > this._maxNodeIndex) {
20600
+ this._maxNodeIndex = nodeIndex;
20601
+ }
20602
+ }
20603
+ }
20604
+ flush() {
20605
+ const backend = this.getBackendOrNull();
20606
+ const instanceBuffer = this._instanceBuffer;
20607
+ const vao = this._vao;
20608
+ if (this._quadIndex === 0 || backend === null || instanceBuffer === null || vao === null) {
20609
+ this._quadIndex = 0;
20610
+ this._maxNodeIndex = 0;
20611
+ return;
20612
+ }
20613
+ const view = backend.view;
20614
+ if (this._currentView !== view || this._currentViewId !== view.updateId) {
20615
+ this._currentView = view;
20616
+ this._currentViewId = view.updateId;
20617
+ this._shader.getUniform('u_projection').setValue(view.getTransform().toArray(false));
20618
+ }
20619
+ if (this._currentTexture !== null) {
20620
+ this._shader.getUniform('u_texture').setValue(new Int32Array([0]));
20621
+ }
20622
+ backend.bindTransformBufferTexture(transformTextureUnit$2, this._maxNodeIndex + 1);
20623
+ this._shader.getUniform('u_transforms').setValue(this._transformUnitScratch);
20624
+ this._shader.sync();
20625
+ backend.bindVertexArrayObject(vao);
20626
+ instanceBuffer.upload(this._instanceFloat32.subarray(0, this._quadIndex * wordsPerInstance$3));
20627
+ vao.drawInstanced(4, 0, this._quadIndex, RenderingPrimitives.TriangleStrip);
20628
+ backend.stats.batches++;
20629
+ backend.stats.drawCalls++;
20630
+ this._quadIndex = 0;
20631
+ this._maxNodeIndex = 0;
20632
+ }
20633
+ onConnect(backend) {
20634
+ const gl = backend.context;
20635
+ this._shader.connect(createWebGl2ShaderProgram(gl));
20636
+ this._connection = this._createConnection(gl);
20637
+ this._instanceBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._instanceData, BufferUsage.DynamicDraw).connect(this._createBufferRuntime(this._connection));
20638
+ this._shader.sync();
20639
+ this._vao = new WebGl2VertexArrayObject(RenderingPrimitives.TriangleStrip)
20640
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_quadBounds'), gl.FLOAT, false, instanceStrideBytes$3, 0, false, 1)
20641
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_uvBounds'), gl.UNSIGNED_SHORT, true, instanceStrideBytes$3, 16, false, 1)
20642
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, instanceStrideBytes$3, 24, false, 1)
20643
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_nodeIndex'), gl.UNSIGNED_INT, false, instanceStrideBytes$3, 28, true, 1)
20644
+ .connect(this._createVaoRuntime(this._connection));
20645
+ }
20646
+ onDisconnect() {
20647
+ this._shader.disconnect();
20648
+ this._instanceBuffer?.destroy();
20649
+ this._instanceBuffer = null;
20650
+ this._vao?.destroy();
20651
+ this._vao = null;
20652
+ this._connection = null;
20653
+ this._currentBlendMode = null;
20654
+ this._currentTexture = null;
20655
+ this._currentView = null;
20656
+ this._currentViewId = -1;
20657
+ this._quadIndex = 0;
20658
+ this._maxNodeIndex = 0;
20659
+ }
20660
+ destroy() {
20661
+ this.disconnect();
20662
+ this._shader.destroy();
20663
+ }
20664
+ _createConnection(gl) {
20665
+ const vaoHandle = gl.createVertexArray();
20666
+ if (vaoHandle === null) {
20667
+ throw new Error('WebGl2NineSliceSpriteRenderer: could not create vertex array object.');
20668
+ }
20669
+ return { gl, buffers: new Map(), vaoHandle };
20670
+ }
20671
+ _createBufferRuntime(connection) {
20672
+ const handle = connection.gl.createBuffer();
20673
+ if (handle === null) {
20674
+ throw new Error('WebGl2NineSliceSpriteRenderer: could not create render buffer.');
20675
+ }
20676
+ return {
20677
+ bind: (buffer) => {
20678
+ connection.gl.bindBuffer(buffer.type, handle);
20679
+ },
20680
+ upload: (buffer, offset) => {
20681
+ const gl = connection.gl;
20682
+ const data = buffer.data;
20683
+ const state = connection.buffers.get(buffer);
20684
+ gl.bindBuffer(buffer.type, handle);
20685
+ if (state && state.dataByteLength >= data.byteLength) {
20686
+ gl.bufferSubData(buffer.type, offset, data);
20687
+ state.dataByteLength = data.byteLength;
20688
+ }
20689
+ else {
20690
+ gl.bufferData(buffer.type, data, buffer.usage);
20691
+ connection.buffers.set(buffer, { handle, dataByteLength: data.byteLength });
20692
+ }
20693
+ },
20694
+ destroy: (buffer) => {
20695
+ connection.gl.deleteBuffer(handle);
20696
+ connection.buffers.delete(buffer);
20697
+ buffer.disconnect();
20698
+ },
20699
+ };
20700
+ }
20701
+ _createVaoRuntime(connection) {
20702
+ let appliedVersion = -1;
20703
+ return {
20704
+ bind: (vao) => {
20705
+ const gl = connection.gl;
20706
+ gl.bindVertexArray(connection.vaoHandle);
20707
+ if (appliedVersion !== vao.version) {
20708
+ let lastBuffer = null;
20709
+ for (const attribute of vao.attributes) {
20710
+ if (lastBuffer !== attribute.buffer) {
20711
+ attribute.buffer.bind();
20712
+ lastBuffer = attribute.buffer;
20713
+ }
20714
+ if (attribute.integer) {
20715
+ gl.vertexAttribIPointer(attribute.location, attribute.size, attribute.type, attribute.stride, attribute.start);
20716
+ }
20717
+ else {
20718
+ gl.vertexAttribPointer(attribute.location, attribute.size, attribute.type, attribute.normalized, attribute.stride, attribute.start);
20719
+ }
20720
+ gl.enableVertexAttribArray(attribute.location);
20721
+ gl.vertexAttribDivisor(attribute.location, attribute.divisor);
20722
+ }
20723
+ appliedVersion = vao.version;
20724
+ }
20725
+ },
20726
+ unbind: () => {
20727
+ connection.gl.bindVertexArray(null);
20728
+ },
20729
+ draw: (_vao, size, start, type) => {
20730
+ connection.gl.drawArrays(type, start, size);
20731
+ },
20732
+ drawInstanced: (_vao, count, start, instanceCount, type) => {
20733
+ connection.gl.drawArraysInstanced(type, start, count, instanceCount);
20734
+ },
20735
+ destroy: (vao) => {
20736
+ connection.gl.deleteVertexArray(connection.vaoHandle);
20737
+ vao.disconnect();
20738
+ },
20739
+ };
20740
+ }
20741
+ }
20742
+
20743
+ // ---------------------------------------------------------------------------
20744
+ // Shader path: one quad per sprite, UVs computed in vertex shader.
20745
+ // ---------------------------------------------------------------------------
20746
+ const shaderPathVertSource = `#version 300 es
20747
+ precision mediump float;
20748
+ precision highp int;
20749
+
20750
+ layout(location = 0) in vec4 a_quadBounds; // x0,y0,x1,y1 (local space)
20751
+ layout(location = 1) in vec4 a_uvParams; // tilingX, tilingY, offsetU, offsetV
20752
+ layout(location = 2) in vec4 a_color; // RGBA tint (normalised)
20753
+ layout(location = 3) in uint a_nodeIndex; // transform row
20754
+
20755
+ uniform mat3 u_projection;
20756
+ uniform sampler2D u_transforms;
20757
+
20758
+ out vec2 v_texcoord;
20759
+ out vec4 v_color;
20760
+
20761
+ void main(void) {
20762
+ int vid = gl_VertexID;
20763
+ int cx = vid & 1;
20764
+ int cy = (vid >> 1) & 1;
20765
+
20766
+ float lx = (cx == 0) ? a_quadBounds.x : a_quadBounds.z;
20767
+ float ly = (cy == 0) ? a_quadBounds.y : a_quadBounds.w;
20768
+
20769
+ float destW = a_quadBounds.z - a_quadBounds.x;
20770
+ float destH = a_quadBounds.w - a_quadBounds.y;
20771
+
20772
+ int row = int(a_nodeIndex);
20773
+ vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0);
20774
+ vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0);
20775
+
20776
+ float wx = m0.x * lx + m0.y * ly + m1.x;
20777
+ float wy = m0.z * lx + m0.w * ly + m1.y;
20778
+ gl_Position = vec4((u_projection * vec3(wx, wy, 1.0)).xy, 0.0, 1.0);
20779
+
20780
+ float u = (destW > 0.0)
20781
+ ? ((lx - a_quadBounds.x) / destW) * a_uvParams.x + a_uvParams.z
20782
+ : a_uvParams.z;
20783
+ float v = (destH > 0.0)
20784
+ ? ((ly - a_quadBounds.y) / destH) * a_uvParams.y + a_uvParams.w
20785
+ : a_uvParams.w;
20786
+ v_texcoord = vec2(u, v);
20787
+
20788
+ v_color = vec4(a_color.rgb * a_color.a, a_color.a);
20789
+ }`;
20790
+ // ---------------------------------------------------------------------------
20791
+ // Geometry path: N quads per sprite, UVs pre-computed in CPU (like NineSlice).
20792
+ // ---------------------------------------------------------------------------
20793
+ const geoPathVertSource = `#version 300 es
20794
+ precision lowp float;
20795
+ precision highp int;
20796
+
20797
+ layout(location = 0) in vec4 a_quadBounds; // x0,y0,x1,y1 (local space)
20798
+ layout(location = 1) in vec4 a_uvBounds; // u0,v0,u1,v1 (normalised, flipY pre-applied)
20799
+ layout(location = 2) in vec4 a_color; // RGBA tint
20800
+ layout(location = 3) in uint a_nodeIndex; // transform row
20801
+
20802
+ uniform mat3 u_projection;
20803
+ uniform sampler2D u_transforms;
20804
+
20805
+ out vec2 v_texcoord;
20806
+ out vec4 v_color;
20807
+
20808
+ void main(void) {
20809
+ int vid = gl_VertexID;
20810
+ int cx = vid & 1;
20811
+ int cy = (vid >> 1) & 1;
20812
+
20813
+ float lx = (cx == 0) ? a_quadBounds.x : a_quadBounds.z;
20814
+ float ly = (cy == 0) ? a_quadBounds.y : a_quadBounds.w;
20815
+
20816
+ int row = int(a_nodeIndex);
20817
+ vec4 m0 = texelFetch(u_transforms, ivec2(0, row), 0);
20818
+ vec4 m1 = texelFetch(u_transforms, ivec2(1, row), 0);
20819
+
20820
+ float wx = m0.x * lx + m0.y * ly + m1.x;
20821
+ float wy = m0.z * lx + m0.w * ly + m1.y;
20822
+ gl_Position = vec4((u_projection * vec3(wx, wy, 1.0)).xy, 0.0, 1.0);
20823
+
20824
+ float u = (cx == 0) ? a_uvBounds.x : a_uvBounds.z;
20825
+ float v = (cy == 0) ? a_uvBounds.y : a_uvBounds.w;
20826
+ v_texcoord = vec2(u, v);
20827
+
20828
+ v_color = vec4(a_color.rgb * a_color.a, a_color.a);
20829
+ }`;
20830
+ const sharedFragSource = `#version 300 es
20831
+ precision lowp float;
20832
+
20833
+ uniform sampler2D u_texture;
20834
+
20835
+ in vec2 v_texcoord;
20836
+ in vec4 v_color;
20837
+
20838
+ layout(location = 0) out vec4 fragColor;
20839
+
20840
+ void main(void) {
20841
+ fragColor = texture(u_texture, v_texcoord) * v_color;
20842
+ }`;
20843
+ // ---------------------------------------------------------------------------
20844
+ // Layout constants
20845
+ // ---------------------------------------------------------------------------
20846
+ const shaderStrideBytes$1 = 40; // 10 × float32
20847
+ const shaderWordsPerInstance = shaderStrideBytes$1 / Uint32Array.BYTES_PER_ELEMENT;
20848
+ const geoStrideBytes$1 = 32; // 8 × uint32 (matches NineSlice layout)
20849
+ const geoWordsPerInstance = geoStrideBytes$1 / Uint32Array.BYTES_PER_ELEMENT;
20850
+ const transformTextureUnit$1 = 1;
20851
+ // ---------------------------------------------------------------------------
20852
+ // Sampler cache helper
20853
+ // ---------------------------------------------------------------------------
20854
+ function repeatModeToWrap(mode) {
20855
+ if (mode === 'repeat')
20856
+ return WrapModes.Repeat;
20857
+ if (mode === 'mirror-repeat')
20858
+ return WrapModes.MirroredRepeat;
20859
+ return WrapModes.ClampToEdge;
20860
+ }
20861
+ /** Instanced renderer for {@link RepeatingSprite} using WebGL2. Handles both shader and geometry paths internally. */
20862
+ class WebGl2RepeatingSpriteRenderer extends AbstractWebGl2Renderer {
20863
+ _shaderPathShader;
20864
+ _geoPathShader;
20865
+ _batchSize;
20866
+ // Shader-path buffers
20867
+ _shaderData;
20868
+ _shaderF32;
20869
+ _shaderU32;
20870
+ _shaderBuf = null;
20871
+ _shaderVao = null;
20872
+ _shaderQuadCount = 0;
20873
+ // Geometry-path buffers
20874
+ _geoData;
20875
+ _geoF32;
20876
+ _geoU32;
20877
+ _geoBuf = null;
20878
+ _geoVao = null;
20879
+ _geoQuadCount = 0;
20880
+ // Sampler cache keyed by "wrapS:wrapT:scaleMode"
20881
+ _samplers = new Map();
20882
+ // Shared batch state
20883
+ _maxNodeIndex = 0;
20884
+ _currentTexture = null;
20885
+ _currentBlendMode = null;
20886
+ _currentModeX = null;
20887
+ _currentModeY = null;
20888
+ _currentPath = null;
20889
+ _connection = null;
20890
+ _transformUnitScratch = new Int32Array([transformTextureUnit$1]);
20891
+ _snapBounds = new Rectangle();
20892
+ _currentView = null;
20893
+ _currentViewId = -1;
20894
+ constructor(batchSize) {
20895
+ super();
20896
+ this._batchSize = batchSize;
20897
+ this._shaderPathShader = new Shader(shaderPathVertSource, sharedFragSource);
20898
+ this._geoPathShader = new Shader(geoPathVertSource, sharedFragSource);
20899
+ this._shaderData = new ArrayBuffer(batchSize * shaderStrideBytes$1);
20900
+ this._shaderF32 = new Float32Array(this._shaderData);
20901
+ this._shaderU32 = new Uint32Array(this._shaderData);
20902
+ this._geoData = new ArrayBuffer(batchSize * geoStrideBytes$1);
20903
+ this._geoF32 = new Float32Array(this._geoData);
20904
+ this._geoU32 = new Uint32Array(this._geoData);
20905
+ }
20906
+ render(sprite) {
20907
+ const strategy = sprite.resolvedStrategy;
20908
+ const texture = sprite.texture;
20909
+ const blendMode = sprite.blendMode;
20910
+ const modeX = sprite.modeX;
20911
+ const modeY = sprite.modeY;
20912
+ const hasData = this._shaderQuadCount > 0 || this._geoQuadCount > 0;
20913
+ if (hasData) {
20914
+ const pathChanged = this._currentPath !== strategy;
20915
+ const texChanged = this._currentTexture !== texture;
20916
+ const blendChanged = this._currentBlendMode !== blendMode;
20917
+ const modeChanged = strategy === 'shader'
20918
+ && (this._currentModeX !== modeX || this._currentModeY !== modeY);
20919
+ if (pathChanged || texChanged || blendChanged || modeChanged) {
20920
+ this.flush();
20921
+ }
20922
+ }
20923
+ const backend = this.getBackend();
20924
+ if (this._currentTexture !== texture) {
20925
+ this._currentTexture = texture;
20926
+ backend.bindTexture(texture, 0);
20927
+ }
20928
+ if (this._currentBlendMode !== blendMode) {
20929
+ this._currentBlendMode = blendMode;
20930
+ backend.setBlendMode(blendMode);
20931
+ }
20932
+ this._currentPath = strategy;
20933
+ const command = backend.activeDrawCommand;
20934
+ const nodeIndex = command !== null ? command.nodeIndex : backend._pushTransform(sprite);
20935
+ if (nodeIndex > this._maxNodeIndex) {
20936
+ this._maxNodeIndex = nodeIndex;
20937
+ }
20938
+ if (strategy === 'shader') {
20939
+ this._currentModeX = modeX;
20940
+ this._currentModeY = modeY;
20941
+ this._writeShaderInstance(sprite, nodeIndex);
20942
+ }
20943
+ else {
20944
+ this._writeGeoQuads(sprite, nodeIndex);
20945
+ }
20946
+ }
20947
+ _writeShaderInstance(sprite, nodeIndex) {
20948
+ const texture = sprite.texture;
20949
+ const srcW = sprite.region.width;
20950
+ const srcH = sprite.region.height;
20951
+ let destW = sprite.width;
20952
+ let destH = sprite.height;
20953
+ const flipY = texture instanceof Texture && texture.flipY;
20954
+ // 'geometry' mode: snap the destination quad to the device grid. Repetition
20955
+ // stays shader-based; only the outer rectangle (and the tiling derived from
20956
+ // it) moves. Position/none leave the destination unchanged.
20957
+ if (sprite.pixelSnapMode === 'geometry') {
20958
+ const backend = this.getBackend();
20959
+ const snap = backend._getSnapPixelSize();
20960
+ const rb = sprite.getRenderBounds(backend.view, snap.width, snap.height, this._snapBounds);
20961
+ destW = rb.width;
20962
+ destH = rb.height;
20963
+ }
20964
+ const tilingX = computeShaderTiling(srcW, destW, sprite.modeX, sprite.fitX);
20965
+ const tilingY = computeShaderTiling(srcH, destH, sprite.modeY, sprite.fitY);
20966
+ const offsetU = sprite.offsetX / (srcW > 0 ? srcW : 1);
20967
+ const offsetV = sprite.offsetY / (srcH > 0 ? srcH : 1);
20968
+ // When flipY, negate tilingY and start from tilingY so V runs top→bottom.
20969
+ const uvParamY = flipY ? -tilingY : tilingY;
20970
+ const uvParamW = flipY ? tilingY + offsetV : offsetV;
20971
+ if (this._shaderQuadCount >= this._batchSize) {
20972
+ this.flush();
20973
+ }
20974
+ const idx = this._shaderQuadCount * shaderWordsPerInstance;
20975
+ const f32 = this._shaderF32;
20976
+ const u32 = this._shaderU32;
20977
+ f32[idx + 0] = 0;
20978
+ f32[idx + 1] = 0;
20979
+ f32[idx + 2] = destW;
20980
+ f32[idx + 3] = destH;
20981
+ f32[idx + 4] = tilingX;
20982
+ f32[idx + 5] = uvParamY;
20983
+ f32[idx + 6] = offsetU;
20984
+ f32[idx + 7] = uvParamW;
20985
+ u32[idx + 8] = sprite.tint.toRgba();
20986
+ u32[idx + 9] = nodeIndex >>> 0;
20987
+ this._shaderQuadCount++;
20988
+ }
20989
+ _writeGeoQuads(sprite, nodeIndex) {
20990
+ let quads = sprite.quads;
20991
+ // 'geometry' mode: snap shared segment boundaries once (gap-free), like NineSlice.
20992
+ if (sprite.pixelSnapMode === 'geometry') {
20993
+ const backend = this.getBackend();
20994
+ const snap = backend._getSnapPixelSize();
20995
+ quads = sprite.getRenderQuads(backend.view, snap.width, snap.height);
20996
+ }
20997
+ const flipY = (sprite.texture instanceof Texture) && sprite.texture.flipY;
20998
+ const tint = sprite.tint.toRgba();
20999
+ let offset = 0;
21000
+ while (offset < quads.length) {
21001
+ const remaining = quads.length - offset;
21002
+ const chunk = Math.min(remaining, this._batchSize - this._geoQuadCount);
21003
+ if (chunk <= 0) {
21004
+ this.flush();
21005
+ // Re-establish texture/blend after flush
21006
+ const backend = this.getBackend();
21007
+ backend.bindTexture(sprite.texture, 0);
21008
+ backend.setBlendMode(sprite.blendMode);
21009
+ this._currentTexture = sprite.texture;
21010
+ this._currentBlendMode = sprite.blendMode;
21011
+ this._currentPath = 'geometry';
21012
+ if (nodeIndex > this._maxNodeIndex) {
21013
+ this._maxNodeIndex = nodeIndex;
21014
+ }
21015
+ continue;
21016
+ }
21017
+ const f32 = this._geoF32;
21018
+ const u32 = this._geoU32;
21019
+ for (let i = 0; i < chunk; i++) {
21020
+ const q = quads[offset + i];
21021
+ const idx = (this._geoQuadCount + i) * geoWordsPerInstance;
21022
+ f32[idx + 0] = q.x0;
21023
+ f32[idx + 1] = q.y0;
21024
+ f32[idx + 2] = q.x1;
21025
+ f32[idx + 3] = q.y1;
21026
+ const uMin = (q.u0 * 0xffff) & 0xffff;
21027
+ const uMax = (q.u1 * 0xffff) & 0xffff;
21028
+ const v0Raw = (q.v0 * 0xffff) & 0xffff;
21029
+ const v1Raw = (q.v1 * 0xffff) & 0xffff;
21030
+ const vMin = flipY ? v1Raw : v0Raw;
21031
+ const vMax = flipY ? v0Raw : v1Raw;
21032
+ u32[idx + 4] = uMin | (vMin << 16);
21033
+ u32[idx + 5] = uMax | (vMax << 16);
21034
+ u32[idx + 6] = tint;
21035
+ u32[idx + 7] = nodeIndex >>> 0;
21036
+ }
21037
+ this._geoQuadCount += chunk;
21038
+ offset += chunk;
21039
+ }
21040
+ }
21041
+ flush() {
21042
+ const backend = this.getBackendOrNull();
21043
+ if (backend === null) {
21044
+ this._resetBatchState();
21045
+ return;
21046
+ }
21047
+ const view = backend.view;
21048
+ if (this._currentView !== view || this._currentViewId !== view.updateId) {
21049
+ this._currentView = view;
21050
+ this._currentViewId = view.updateId;
21051
+ const proj = view.getTransform().toArray(false);
21052
+ this._shaderPathShader.getUniform('u_projection').setValue(proj);
21053
+ this._geoPathShader.getUniform('u_projection').setValue(proj);
21054
+ }
21055
+ if (this._shaderQuadCount > 0) {
21056
+ this._flushShaderBatch(backend);
21057
+ }
21058
+ if (this._geoQuadCount > 0) {
21059
+ this._flushGeoBatch(backend);
21060
+ }
21061
+ this._resetBatchState();
21062
+ }
21063
+ _flushShaderBatch(backend) {
21064
+ const conn = this._connection;
21065
+ const buf = this._shaderBuf;
21066
+ const vao = this._shaderVao;
21067
+ if (!conn || !buf || !vao || this._shaderQuadCount === 0)
21068
+ return;
21069
+ const gl = conn.gl;
21070
+ const texture = this._currentTexture;
21071
+ const scaleMode = (texture instanceof Texture) ? texture.scaleMode : ScaleModes.Linear;
21072
+ const wrapS = repeatModeToWrap(this._currentModeX ?? 'repeat');
21073
+ const wrapT = repeatModeToWrap(this._currentModeY ?? 'repeat');
21074
+ // Bind repeat sampler (overrides texture's own wrap params for this unit).
21075
+ const samplerHandle = this._getOrCreateSampler(gl, wrapS, wrapT, scaleMode);
21076
+ gl.bindSampler(0, samplerHandle);
21077
+ backend.bindTransformBufferTexture(transformTextureUnit$1, this._maxNodeIndex + 1);
21078
+ this._shaderPathShader.getUniform('u_texture').setValue(new Int32Array([0]));
21079
+ this._shaderPathShader.getUniform('u_transforms').setValue(this._transformUnitScratch);
21080
+ this._shaderPathShader.sync();
21081
+ backend.bindVertexArrayObject(vao);
21082
+ buf.upload(this._shaderF32.subarray(0, this._shaderQuadCount * shaderWordsPerInstance));
21083
+ vao.drawInstanced(4, 0, this._shaderQuadCount, RenderingPrimitives.TriangleStrip);
21084
+ backend.stats.batches++;
21085
+ backend.stats.drawCalls++;
21086
+ // Unbind sampler so subsequent draws use the texture's own wrap params.
21087
+ gl.bindSampler(0, null);
21088
+ this._shaderQuadCount = 0;
21089
+ }
21090
+ _flushGeoBatch(backend) {
21091
+ const conn = this._connection;
21092
+ const buf = this._geoBuf;
21093
+ const vao = this._geoVao;
21094
+ if (!conn || !buf || !vao || this._geoQuadCount === 0)
21095
+ return;
21096
+ backend.bindTransformBufferTexture(transformTextureUnit$1, this._maxNodeIndex + 1);
21097
+ this._geoPathShader.getUniform('u_texture').setValue(new Int32Array([0]));
21098
+ this._geoPathShader.getUniform('u_transforms').setValue(this._transformUnitScratch);
21099
+ this._geoPathShader.sync();
21100
+ backend.bindVertexArrayObject(vao);
21101
+ buf.upload(this._geoF32.subarray(0, this._geoQuadCount * geoWordsPerInstance));
21102
+ vao.drawInstanced(4, 0, this._geoQuadCount, RenderingPrimitives.TriangleStrip);
21103
+ backend.stats.batches++;
21104
+ backend.stats.drawCalls++;
21105
+ this._geoQuadCount = 0;
21106
+ }
21107
+ _resetBatchState() {
21108
+ this._shaderQuadCount = 0;
21109
+ this._geoQuadCount = 0;
21110
+ this._maxNodeIndex = 0;
21111
+ this._currentTexture = null;
21112
+ this._currentBlendMode = null;
21113
+ this._currentModeX = null;
21114
+ this._currentModeY = null;
21115
+ this._currentPath = null;
21116
+ }
21117
+ _getOrCreateSampler(gl, wrapS, wrapT, scaleMode) {
21118
+ const key = `${wrapS}:${wrapT}:${scaleMode}`;
21119
+ const existing = this._samplers.get(key);
21120
+ if (existing !== undefined)
21121
+ return existing;
21122
+ const sampler = gl.createSampler();
21123
+ if (sampler === null)
21124
+ throw new Error('WebGl2RepeatingSpriteRenderer: could not create sampler.');
21125
+ gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_S, wrapS);
21126
+ gl.samplerParameteri(sampler, gl.TEXTURE_WRAP_T, wrapT);
21127
+ gl.samplerParameteri(sampler, gl.TEXTURE_MAG_FILTER, scaleMode);
21128
+ gl.samplerParameteri(sampler, gl.TEXTURE_MIN_FILTER, scaleMode);
21129
+ this._samplers.set(key, sampler);
21130
+ return sampler;
21131
+ }
21132
+ onConnect(backend) {
21133
+ const gl = backend.context;
21134
+ this._shaderPathShader.connect(createWebGl2ShaderProgram(gl));
21135
+ this._geoPathShader.connect(createWebGl2ShaderProgram(gl));
21136
+ // sync() triggers finalize() which compiles the shaders and populates the
21137
+ // attributes/uniforms maps — must happen before any getAttribute() call.
21138
+ this._shaderPathShader.sync();
21139
+ this._geoPathShader.sync();
21140
+ const conn = this._createConnection(gl);
21141
+ this._connection = conn;
21142
+ // Shader-path VAO (uses float4 uvParams, not packed unorm16)
21143
+ this._shaderBuf = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._shaderData, BufferUsage.DynamicDraw)
21144
+ .connect(this._createBufRuntime(conn, 'shader'));
21145
+ this._shaderVao = new WebGl2VertexArrayObject(RenderingPrimitives.TriangleStrip)
21146
+ .addAttribute(this._shaderBuf, this._shaderPathShader.getAttribute('a_quadBounds'), gl.FLOAT, false, shaderStrideBytes$1, 0, false, 1)
21147
+ .addAttribute(this._shaderBuf, this._shaderPathShader.getAttribute('a_uvParams'), gl.FLOAT, false, shaderStrideBytes$1, 16, false, 1)
21148
+ .addAttribute(this._shaderBuf, this._shaderPathShader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, shaderStrideBytes$1, 32, false, 1)
21149
+ .addAttribute(this._shaderBuf, this._shaderPathShader.getAttribute('a_nodeIndex'), gl.UNSIGNED_INT, false, shaderStrideBytes$1, 36, true, 1)
21150
+ .connect(this._createVaoRuntime(conn, 'shader'));
21151
+ // Geometry-path VAO (packed unorm16 UVs, same layout as NineSlice)
21152
+ this._geoBuf = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._geoData, BufferUsage.DynamicDraw)
21153
+ .connect(this._createBufRuntime(conn, 'geo'));
21154
+ this._geoVao = new WebGl2VertexArrayObject(RenderingPrimitives.TriangleStrip)
21155
+ .addAttribute(this._geoBuf, this._geoPathShader.getAttribute('a_quadBounds'), gl.FLOAT, false, geoStrideBytes$1, 0, false, 1)
21156
+ .addAttribute(this._geoBuf, this._geoPathShader.getAttribute('a_uvBounds'), gl.UNSIGNED_SHORT, true, geoStrideBytes$1, 16, false, 1)
21157
+ .addAttribute(this._geoBuf, this._geoPathShader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, geoStrideBytes$1, 24, false, 1)
21158
+ .addAttribute(this._geoBuf, this._geoPathShader.getAttribute('a_nodeIndex'), gl.UNSIGNED_INT, false, geoStrideBytes$1, 28, true, 1)
21159
+ .connect(this._createVaoRuntime(conn, 'geo'));
21160
+ }
21161
+ onDisconnect() {
21162
+ const gl = this._connection?.gl;
21163
+ if (gl !== undefined) {
21164
+ for (const sampler of this._samplers.values()) {
21165
+ gl.deleteSampler(sampler);
21166
+ }
21167
+ }
21168
+ this._samplers.clear();
21169
+ this._shaderPathShader.disconnect();
21170
+ this._geoPathShader.disconnect();
21171
+ this._shaderBuf?.destroy();
21172
+ this._shaderBuf = null;
21173
+ this._shaderVao?.destroy();
21174
+ this._shaderVao = null;
21175
+ this._geoBuf?.destroy();
21176
+ this._geoBuf = null;
21177
+ this._geoVao?.destroy();
21178
+ this._geoVao = null;
21179
+ this._connection = null;
21180
+ this._currentView = null;
21181
+ this._currentViewId = -1;
21182
+ this._resetBatchState();
21183
+ }
21184
+ destroy() {
21185
+ this.disconnect();
21186
+ this._shaderPathShader.destroy();
21187
+ this._geoPathShader.destroy();
21188
+ }
21189
+ // -----------------------------------------------------------------------
21190
+ // Private GL helpers
21191
+ // -----------------------------------------------------------------------
21192
+ _createConnection(gl) {
21193
+ const shaderVaoHandle = gl.createVertexArray();
21194
+ const geoVaoHandle = gl.createVertexArray();
21195
+ if (shaderVaoHandle === null || geoVaoHandle === null) {
21196
+ throw new Error('WebGl2RepeatingSpriteRenderer: could not create vertex array object.');
21197
+ }
21198
+ return { gl, buffers: new Map(), shaderVaoHandle, geoVaoHandle };
21199
+ }
21200
+ _createBufRuntime(conn, _kind) {
21201
+ const handle = conn.gl.createBuffer();
21202
+ if (handle === null)
21203
+ throw new Error('WebGl2RepeatingSpriteRenderer: could not create render buffer.');
21204
+ return {
21205
+ bind: (buffer) => { conn.gl.bindBuffer(buffer.type, handle); },
21206
+ upload: (buffer, offset) => {
21207
+ const gl = conn.gl;
21208
+ const data = buffer.data;
21209
+ const state = conn.buffers.get(buffer);
21210
+ gl.bindBuffer(buffer.type, handle);
21211
+ if (state && state.dataByteLength >= data.byteLength) {
21212
+ gl.bufferSubData(buffer.type, offset, data);
21213
+ state.dataByteLength = data.byteLength;
21214
+ }
21215
+ else {
21216
+ gl.bufferData(buffer.type, data, buffer.usage);
21217
+ conn.buffers.set(buffer, { handle, dataByteLength: data.byteLength });
21218
+ }
21219
+ },
21220
+ destroy: (buffer) => {
21221
+ conn.gl.deleteBuffer(handle);
21222
+ conn.buffers.delete(buffer);
21223
+ buffer.disconnect();
21224
+ },
21225
+ };
21226
+ }
21227
+ _createVaoRuntime(conn, kind) {
21228
+ const vaoHandle = kind === 'shader' ? conn.shaderVaoHandle : conn.geoVaoHandle;
21229
+ let appliedVersion = -1;
21230
+ return {
21231
+ bind: (vao) => {
21232
+ const gl = conn.gl;
21233
+ gl.bindVertexArray(vaoHandle);
21234
+ if (appliedVersion !== vao.version) {
21235
+ let lastBuffer = null;
21236
+ for (const attr of vao.attributes) {
21237
+ if (lastBuffer !== attr.buffer) {
21238
+ attr.buffer.bind();
21239
+ lastBuffer = attr.buffer;
21240
+ }
21241
+ if (attr.integer) {
21242
+ gl.vertexAttribIPointer(attr.location, attr.size, attr.type, attr.stride, attr.start);
21243
+ }
21244
+ else {
21245
+ gl.vertexAttribPointer(attr.location, attr.size, attr.type, attr.normalized, attr.stride, attr.start);
21246
+ }
21247
+ gl.enableVertexAttribArray(attr.location);
21248
+ gl.vertexAttribDivisor(attr.location, attr.divisor);
21249
+ }
21250
+ appliedVersion = vao.version;
21251
+ }
21252
+ },
21253
+ unbind: () => { conn.gl.bindVertexArray(null); },
21254
+ draw: (_vao, size, start, type) => { conn.gl.drawArrays(type, start, size); },
21255
+ drawInstanced: (_vao, count, start, instanceCount, type) => {
21256
+ conn.gl.drawArraysInstanced(type, start, count, instanceCount);
21257
+ },
21258
+ destroy: (vao) => {
21259
+ conn.gl.deleteVertexArray(vaoHandle);
21260
+ vao.disconnect();
21261
+ },
21262
+ };
21263
+ }
21264
+ }
21265
+
18898
21266
  /**
18899
21267
  * Canonical engine vertex stage for the custom {@link SpriteMaterial} path.
18900
21268
  *
@@ -19087,8 +21455,8 @@ const maxBatchTextures$1 = 8;
19087
21455
  // Sprite base textures occupy units 0..7; the shared transform buffer texture
19088
21456
  // binds on unit 8, matching the mesh renderer's convention.
19089
21457
  const transformTextureUnit = 8;
19090
- const instanceStrideBytes$1 = 36;
19091
- const wordsPerInstance$1 = instanceStrideBytes$1 / Uint32Array.BYTES_PER_ELEMENT;
21458
+ const instanceStrideBytes$2 = 36;
21459
+ const wordsPerInstance$2 = instanceStrideBytes$2 / Uint32Array.BYTES_PER_ELEMENT;
19092
21460
  class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
19093
21461
  _shader;
19094
21462
  _batchSize;
@@ -19108,6 +21476,10 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
19108
21476
  _transformUnitScratch = new Int32Array([transformTextureUnit]);
19109
21477
  _currentMaterial = null;
19110
21478
  _currentBaseTexture = null;
21479
+ // Reusable scratch for device-snapped local bounds ('geometry' mode), and the
21480
+ // bounds resolved for the sprite currently being packed (snapped or logical).
21481
+ _snapBounds = new Rectangle();
21482
+ _activeBounds = null;
19111
21483
  _instanceCount = 0;
19112
21484
  // Highest transform-buffer row referenced by the pending batch; drives the
19113
21485
  // minimum row count uploaded for the transform texture at flush time.
@@ -19122,7 +21494,7 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
19122
21494
  super();
19123
21495
  this._batchSize = batchSize;
19124
21496
  this._shader = new Shader(vertexSource$2, fragmentSource$2);
19125
- this._instanceData = new ArrayBuffer(batchSize * instanceStrideBytes$1);
21497
+ this._instanceData = new ArrayBuffer(batchSize * instanceStrideBytes$2);
19126
21498
  this._instanceFloat32 = new Float32Array(this._instanceData);
19127
21499
  this._instanceUint32 = new Uint32Array(this._instanceData);
19128
21500
  }
@@ -19139,6 +21511,7 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
19139
21511
  // sprite's transform into the buffer and use the freshly-allocated slot.
19140
21512
  const command = backend.activeDrawCommand;
19141
21513
  const nodeIndex = command !== null ? command.nodeIndex : backend._pushTransform(sprite);
21514
+ this._activeBounds = this._resolveBounds(sprite, backend);
19142
21515
  if (material === null) {
19143
21516
  this._renderDefault(sprite, texture, backend, nodeIndex);
19144
21517
  }
@@ -19147,6 +21520,19 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
19147
21520
  }
19148
21521
  return this;
19149
21522
  }
21523
+ /**
21524
+ * Local bounds to upload for `sprite` this draw: device-pixel-snapped in
21525
+ * `'geometry'` pixel-snap mode (axis-aligned only), otherwise the sprite's
21526
+ * logical local bounds. Reuses a scratch rectangle and never mutates logical
21527
+ * state. Consumed synchronously by {@link _packInstance}.
21528
+ */
21529
+ _resolveBounds(sprite, backend) {
21530
+ if (sprite.pixelSnapMode !== 'geometry') {
21531
+ return sprite.getLocalBounds();
21532
+ }
21533
+ const snap = backend._getSnapPixelSize();
21534
+ return sprite.getRenderBounds(backend.view, snap.width, snap.height, this._snapBounds);
21535
+ }
19150
21536
  flush() {
19151
21537
  const backend = this.getBackendOrNull();
19152
21538
  const instanceBuffer = this._instanceBuffer;
@@ -19190,7 +21576,7 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
19190
21576
  }
19191
21577
  shader.sync();
19192
21578
  backend.bindVertexArrayObject(vao);
19193
- instanceBuffer.upload(this._instanceFloat32.subarray(0, this._instanceCount * wordsPerInstance$1));
21579
+ instanceBuffer.upload(this._instanceFloat32.subarray(0, this._instanceCount * wordsPerInstance$2));
19194
21580
  vao.drawInstanced(4, 0, this._instanceCount, RenderingPrimitives.TriangleStrip);
19195
21581
  backend.stats.batches++;
19196
21582
  backend.stats.drawCalls++;
@@ -19205,11 +21591,11 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
19205
21591
  this._instanceBuffer = new WebGl2RenderBuffer(BufferTypes.ArrayBuffer, this._instanceData, BufferUsage.DynamicDraw).connect(this._createBufferRuntime(this._connection));
19206
21592
  this._shader.sync();
19207
21593
  this._vao = new WebGl2VertexArrayObject(RenderingPrimitives.TriangleStrip)
19208
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_localBounds'), gl.FLOAT, false, instanceStrideBytes$1, 0, false, 1)
19209
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_uvBounds'), gl.UNSIGNED_SHORT, true, instanceStrideBytes$1, 16, false, 1)
19210
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, instanceStrideBytes$1, 24, false, 1)
19211
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_textureSlot'), gl.UNSIGNED_INT, false, instanceStrideBytes$1, 28, true, 1)
19212
- .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_nodeIndex'), gl.UNSIGNED_INT, false, instanceStrideBytes$1, 32, true, 1)
21594
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_localBounds'), gl.FLOAT, false, instanceStrideBytes$2, 0, false, 1)
21595
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_uvBounds'), gl.UNSIGNED_SHORT, true, instanceStrideBytes$2, 16, false, 1)
21596
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_color'), gl.UNSIGNED_BYTE, true, instanceStrideBytes$2, 24, false, 1)
21597
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_textureSlot'), gl.UNSIGNED_INT, false, instanceStrideBytes$2, 28, true, 1)
21598
+ .addAttribute(this._instanceBuffer, this._shader.getAttribute('a_nodeIndex'), gl.UNSIGNED_INT, false, instanceStrideBytes$2, 32, true, 1)
19213
21599
  .connect(this._createVaoRuntime(this._connection));
19214
21600
  // Pin the per-slot sampler uniforms to texture units 0..N-1.
19215
21601
  const samplerUnit = new Int32Array(1);
@@ -19290,11 +21676,12 @@ class WebGl2SpriteRenderer extends AbstractWebGl2Renderer {
19290
21676
  this._instanceCount++;
19291
21677
  }
19292
21678
  _packInstance(sprite, texture, slot, nodeIndex) {
19293
- const offset = this._instanceCount * wordsPerInstance$1;
21679
+ const offset = this._instanceCount * wordsPerInstance$2;
19294
21680
  const f32 = this._instanceFloat32;
19295
21681
  const u32 = this._instanceUint32;
19296
- // localBounds: left, top, right, bottom (offset 0..3)
19297
- const bounds = sprite.getLocalBounds();
21682
+ // localBounds: left, top, right, bottom (offset 0..3) — device-snapped in
21683
+ // 'geometry' pixel-snap mode, otherwise the logical local bounds.
21684
+ const bounds = this._activeBounds ?? sprite.getLocalBounds();
19298
21685
  f32[offset + 0] = bounds.left;
19299
21686
  f32[offset + 1] = bounds.top;
19300
21687
  f32[offset + 2] = bounds.right;
@@ -21814,6 +24201,902 @@ function collectTextureBindings$1(material) {
21814
24201
  return result;
21815
24202
  }
21816
24203
 
24204
+ /// <reference types="@webgpu/types" />
24205
+ const nineSliceShaderSource = `
24206
+ struct ProjectionUniforms {
24207
+ matrix: mat4x4<f32>,
24208
+ };
24209
+
24210
+ struct TransformSlot {
24211
+ m0: vec4<f32>,
24212
+ m1: vec4<f32>,
24213
+ m2: vec4<f32>,
24214
+ };
24215
+
24216
+ @group(0) @binding(0)
24217
+ var<uniform> projection: ProjectionUniforms;
24218
+ @group(0) @binding(1)
24219
+ var<storage, read> transforms: array<TransformSlot>;
24220
+
24221
+ @group(1) @binding(0)
24222
+ var nineSliceTexture: texture_2d<f32>;
24223
+ @group(1) @binding(1)
24224
+ var nineSliceSampler: sampler;
24225
+
24226
+ struct VertexInput {
24227
+ @location(0) quadBounds: vec4<f32>, // x0, y0, x1, y1
24228
+ @location(1) uvBounds: vec4<f32>, // u0, v0, u1, v1 (normalised, flipY pre-applied)
24229
+ @location(2) color: vec4<f32>, // RGBA tint
24230
+ @location(3) nodeIndex: u32, // transform buffer row
24231
+ };
24232
+
24233
+ struct VertexOutput {
24234
+ @builtin(position) position: vec4<f32>,
24235
+ @location(0) texcoord: vec2<f32>,
24236
+ @location(1) color: vec4<f32>,
24237
+ };
24238
+
24239
+ @vertex
24240
+ fn vertexMain(input: VertexInput, @builtin(vertex_index) vid: u32) -> VertexOutput {
24241
+ var output: VertexOutput;
24242
+
24243
+ // vid 0..3 → TL, TR, BR, BL (matches static index buffer [0,1,2,0,2,3])
24244
+ let cornerX = ((vid + 1u) >> 1u) & 1u;
24245
+ let cornerY = vid >> 1u;
24246
+
24247
+ let localX = select(input.quadBounds.x, input.quadBounds.z, cornerX == 1u);
24248
+ let localY = select(input.quadBounds.y, input.quadBounds.w, cornerY == 1u);
24249
+
24250
+ let slot = transforms[input.nodeIndex];
24251
+ let worldX = slot.m0.x * localX + slot.m0.y * localY + slot.m1.x;
24252
+ let worldY = slot.m0.z * localX + slot.m0.w * localY + slot.m1.y;
24253
+
24254
+ output.position = projection.matrix * vec4<f32>(worldX, worldY, 0.0, 1.0);
24255
+
24256
+ let u = select(input.uvBounds.x, input.uvBounds.z, cornerX == 1u);
24257
+ let v = select(input.uvBounds.y, input.uvBounds.w, cornerY == 1u);
24258
+ output.texcoord = vec2<f32>(u, v);
24259
+
24260
+ output.color = vec4(input.color.rgb * input.color.a, input.color.a);
24261
+
24262
+ return output;
24263
+ }
24264
+
24265
+ @fragment
24266
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4<f32> {
24267
+ let sample = textureSample(nineSliceTexture, nineSliceSampler, input.texcoord);
24268
+ return sample * input.color;
24269
+ }
24270
+ `;
24271
+ const instanceStrideBytes$1 = 32;
24272
+ const wordsPerInstance$1 = instanceStrideBytes$1 / Uint32Array.BYTES_PER_ELEMENT; // = 8
24273
+ const projectionByteLength$2 = 64;
24274
+ const initialBatchCapacity$2 = 32;
24275
+ const indicesPerInstance$1 = 6;
24276
+ const quadIndices$3 = new Uint16Array([0, 1, 2, 0, 2, 3]);
24277
+ /** Instanced renderer for {@link NineSliceSprite} using WebGPU. */
24278
+ class WebGpuNineSliceSpriteRenderer extends AbstractWebGpuRenderer {
24279
+ _projectionData = new Float32Array(projectionByteLength$2 / Float32Array.BYTES_PER_ELEMENT);
24280
+ _device = null;
24281
+ _shaderModule = null;
24282
+ _uniformBindGroupLayout = null;
24283
+ _textureBindGroupLayout = null;
24284
+ _pipelineLayout = null;
24285
+ _uniformBuffer = null;
24286
+ _transformBindGroup = null;
24287
+ _transformStorageBuffer = null;
24288
+ _indexBuffer = null;
24289
+ _instanceBuffer = null;
24290
+ _instanceCapacity = 0;
24291
+ _instanceData = new ArrayBuffer(0);
24292
+ _instanceFloat32 = new Float32Array(this._instanceData);
24293
+ _instanceUint32 = new Uint32Array(this._instanceData);
24294
+ _pipelines = new Map();
24295
+ _quadIndex = 0;
24296
+ _maxNodeIndex = 0;
24297
+ _currentBlendMode = null;
24298
+ _currentTexture = null;
24299
+ onConnect(backend) {
24300
+ if (this._device) {
24301
+ return;
24302
+ }
24303
+ this._device = backend.device;
24304
+ this._shaderModule = this._device.createShaderModule({ code: nineSliceShaderSource });
24305
+ this._uniformBindGroupLayout = this._device.createBindGroupLayout({
24306
+ entries: [
24307
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
24308
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
24309
+ ],
24310
+ });
24311
+ this._textureBindGroupLayout = this._device.createBindGroupLayout({
24312
+ entries: [
24313
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
24314
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
24315
+ ],
24316
+ });
24317
+ this._pipelineLayout = this._device.createPipelineLayout({
24318
+ bindGroupLayouts: [this._uniformBindGroupLayout, this._textureBindGroupLayout],
24319
+ });
24320
+ this._uniformBuffer = this._device.createBuffer({
24321
+ size: projectionByteLength$2,
24322
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
24323
+ });
24324
+ this._indexBuffer = this._device.createBuffer({
24325
+ size: quadIndices$3.byteLength,
24326
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
24327
+ });
24328
+ this._device.queue.writeBuffer(this._indexBuffer, 0, quadIndices$3.buffer, quadIndices$3.byteOffset, quadIndices$3.byteLength);
24329
+ }
24330
+ onDisconnect() {
24331
+ this._instanceBuffer?.destroy();
24332
+ this._indexBuffer?.destroy();
24333
+ this._uniformBuffer?.destroy();
24334
+ this._pipelines.clear();
24335
+ this._instanceBuffer = null;
24336
+ this._indexBuffer = null;
24337
+ this._transformBindGroup = null;
24338
+ this._transformStorageBuffer = null;
24339
+ this._uniformBuffer = null;
24340
+ this._pipelineLayout = null;
24341
+ this._textureBindGroupLayout = null;
24342
+ this._uniformBindGroupLayout = null;
24343
+ this._shaderModule = null;
24344
+ this._device = null;
24345
+ this._backend = null;
24346
+ this._instanceCapacity = 0;
24347
+ this._instanceData = new ArrayBuffer(0);
24348
+ this._instanceFloat32 = new Float32Array(this._instanceData);
24349
+ this._instanceUint32 = new Uint32Array(this._instanceData);
24350
+ this._quadIndex = 0;
24351
+ this._maxNodeIndex = 0;
24352
+ this._currentBlendMode = null;
24353
+ this._currentTexture = null;
24354
+ }
24355
+ render(sprite) {
24356
+ const backend = this._backend;
24357
+ if (backend === null) {
24358
+ return;
24359
+ }
24360
+ let quads = sprite.quads;
24361
+ if (sprite.pixelSnapMode === 'geometry') {
24362
+ const snap = backend._getSnapPixelSize();
24363
+ quads = sprite.getRenderQuads(backend.view, snap.width, snap.height);
24364
+ }
24365
+ if (quads.length === 0) {
24366
+ return;
24367
+ }
24368
+ const texture = sprite.texture;
24369
+ if (texture.width === 0 || texture.height === 0) {
24370
+ return;
24371
+ }
24372
+ if (texture instanceof Texture && texture.source === null) {
24373
+ return;
24374
+ }
24375
+ const blendMode = sprite.blendMode;
24376
+ const command = backend.activeDrawCommand;
24377
+ const nodeIndex = command !== null ? command.nodeIndex : backend._pushTransform(sprite);
24378
+ const blendModeChanged = this._currentBlendMode !== null && blendMode !== this._currentBlendMode;
24379
+ const textureChanged = this._currentTexture !== null && texture !== this._currentTexture;
24380
+ const willExceed = this._quadIndex + quads.length > this._instanceCapacity && this._instanceCapacity > 0;
24381
+ if ((blendModeChanged || textureChanged || willExceed) && this._quadIndex > 0) {
24382
+ this.flush();
24383
+ }
24384
+ this._currentBlendMode = blendMode;
24385
+ this._currentTexture = texture;
24386
+ backend.setBlendMode(blendMode);
24387
+ this._ensureInstanceCapacity(this._quadIndex + quads.length);
24388
+ const f32 = this._instanceFloat32;
24389
+ const u32 = this._instanceUint32;
24390
+ const flipY = texture instanceof Texture && texture.flipY;
24391
+ for (const q of quads) {
24392
+ const offset = this._quadIndex * wordsPerInstance$1;
24393
+ f32[offset + 0] = q.x0;
24394
+ f32[offset + 1] = q.y0;
24395
+ f32[offset + 2] = q.x1;
24396
+ f32[offset + 3] = q.y1;
24397
+ const uMin = (q.u0 * 0xffff) & 0xffff;
24398
+ const uMax = (q.u1 * 0xffff) & 0xffff;
24399
+ const v0Raw = (q.v0 * 0xffff) & 0xffff;
24400
+ const v1Raw = (q.v1 * 0xffff) & 0xffff;
24401
+ const vMin = flipY ? v1Raw : v0Raw;
24402
+ const vMax = flipY ? v0Raw : v1Raw;
24403
+ u32[offset + 4] = uMin | (vMin << 16);
24404
+ u32[offset + 5] = uMax | (vMax << 16);
24405
+ u32[offset + 6] = sprite.tint.toRgba();
24406
+ u32[offset + 7] = nodeIndex >>> 0;
24407
+ this._quadIndex++;
24408
+ if (nodeIndex > this._maxNodeIndex) {
24409
+ this._maxNodeIndex = nodeIndex;
24410
+ }
24411
+ }
24412
+ }
24413
+ flush() {
24414
+ const backend = this._backend;
24415
+ const device = this._device;
24416
+ const uniformBuffer = this._uniformBuffer;
24417
+ if (!backend || !device || !uniformBuffer) {
24418
+ return;
24419
+ }
24420
+ if (this._quadIndex === 0 && !backend.clearRequested) {
24421
+ return;
24422
+ }
24423
+ const viewMatrix = backend.view.getTransform();
24424
+ this._projectionData.set([viewMatrix.a, viewMatrix.c, 0, 0, viewMatrix.b, viewMatrix.d, 0, 0, 0, 0, 1, 0, viewMatrix.x, viewMatrix.y, 0, viewMatrix.z]);
24425
+ device.queue.writeBuffer(uniformBuffer, 0, this._projectionData.buffer, this._projectionData.byteOffset, this._projectionData.byteLength);
24426
+ const scissor = backend.getScissorRect();
24427
+ const maskClipsAll = scissor !== null && (scissor.width <= 0 || scissor.height <= 0);
24428
+ const pass = backend._passCoordinator.acquirePass().pass;
24429
+ if (this._quadIndex > 0 && !maskClipsAll && this._instanceBuffer !== null && this._indexBuffer !== null && this._currentBlendMode !== null && this._currentTexture !== null) {
24430
+ device.queue.writeBuffer(this._instanceBuffer, 0, this._instanceData, 0, this._quadIndex * instanceStrideBytes$1);
24431
+ const storage = backend.getTransformStorageBuffer(this._maxNodeIndex + 1);
24432
+ const transformBindGroup = this._getOrCreateTransformBindGroup(device, uniformBuffer, storage.buffer);
24433
+ const textureBindGroup = this._createTextureBindGroup(device, backend, this._currentTexture);
24434
+ const stencil = backend._passCoordinator.stencilActive;
24435
+ const pipeline = this._getPipeline(this._currentBlendMode, backend.renderTargetFormat, stencil);
24436
+ pass.setPipeline(pipeline);
24437
+ pass.setBindGroup(0, transformBindGroup);
24438
+ pass.setBindGroup(1, textureBindGroup);
24439
+ pass.setVertexBuffer(0, this._instanceBuffer);
24440
+ pass.setIndexBuffer(this._indexBuffer, 'uint16');
24441
+ pass.drawIndexed(indicesPerInstance$1, this._quadIndex, 0, 0, 0);
24442
+ backend.stats.batches++;
24443
+ backend.stats.drawCalls++;
24444
+ }
24445
+ backend._passCoordinator.endPass();
24446
+ this._quadIndex = 0;
24447
+ this._maxNodeIndex = 0;
24448
+ this._currentBlendMode = null;
24449
+ this._currentTexture = null;
24450
+ }
24451
+ destroy() {
24452
+ this.disconnect();
24453
+ }
24454
+ _getOrCreateTransformBindGroup(device, uniformBuffer, storageBuffer) {
24455
+ if (this._transformBindGroup !== null && this._transformStorageBuffer === storageBuffer) {
24456
+ return this._transformBindGroup;
24457
+ }
24458
+ this._transformStorageBuffer = storageBuffer;
24459
+ this._transformBindGroup = device.createBindGroup({
24460
+ layout: this._uniformBindGroupLayout,
24461
+ entries: [
24462
+ { binding: 0, resource: { buffer: uniformBuffer } },
24463
+ { binding: 1, resource: { buffer: storageBuffer } },
24464
+ ],
24465
+ });
24466
+ return this._transformBindGroup;
24467
+ }
24468
+ _createTextureBindGroup(device, backend, texture) {
24469
+ const binding = backend.getTextureBinding(texture);
24470
+ return device.createBindGroup({
24471
+ layout: this._textureBindGroupLayout,
24472
+ entries: [
24473
+ { binding: 0, resource: binding.view },
24474
+ { binding: 1, resource: binding.sampler },
24475
+ ],
24476
+ });
24477
+ }
24478
+ _getPipeline(blendMode, format, stencil) {
24479
+ const key = `${blendMode}:${format}:${stencil ? 's' : 'n'}`;
24480
+ const existing = this._pipelines.get(key);
24481
+ if (existing) {
24482
+ return existing;
24483
+ }
24484
+ if (!this._device || !this._shaderModule || !this._pipelineLayout) {
24485
+ throw new Error('WebGpuNineSliceSpriteRenderer: renderer must be connected first.');
24486
+ }
24487
+ const descriptor = {
24488
+ layout: this._pipelineLayout,
24489
+ vertex: {
24490
+ module: this._shaderModule,
24491
+ entryPoint: 'vertexMain',
24492
+ buffers: [
24493
+ {
24494
+ arrayStride: instanceStrideBytes$1,
24495
+ stepMode: 'instance',
24496
+ attributes: [
24497
+ { shaderLocation: 0, offset: 0, format: 'float32x4' },
24498
+ { shaderLocation: 1, offset: 16, format: 'unorm16x4' },
24499
+ { shaderLocation: 2, offset: 24, format: 'unorm8x4' },
24500
+ { shaderLocation: 3, offset: 28, format: 'uint32' },
24501
+ ],
24502
+ },
24503
+ ],
24504
+ },
24505
+ fragment: {
24506
+ module: this._shaderModule,
24507
+ entryPoint: 'fragmentMain',
24508
+ targets: [
24509
+ {
24510
+ format,
24511
+ blend: getWebGpuBlendState(blendMode),
24512
+ writeMask: GPUColorWrite.ALL,
24513
+ },
24514
+ ],
24515
+ },
24516
+ primitive: {
24517
+ topology: 'triangle-list',
24518
+ },
24519
+ };
24520
+ if (stencil) {
24521
+ descriptor.depthStencil = stencilContentDepthStencilState();
24522
+ }
24523
+ const pipeline = this._device.createRenderPipeline(descriptor);
24524
+ this._pipelines.set(key, pipeline);
24525
+ return pipeline;
24526
+ }
24527
+ _ensureInstanceCapacity(instanceCount) {
24528
+ if (!this._device || instanceCount <= this._instanceCapacity) {
24529
+ return;
24530
+ }
24531
+ let nextCapacity = Math.max(this._instanceCapacity, initialBatchCapacity$2);
24532
+ while (nextCapacity < instanceCount) {
24533
+ nextCapacity *= 2;
24534
+ }
24535
+ const oldData = this._instanceData;
24536
+ const carryBytes = Math.min(this._quadIndex * instanceStrideBytes$1, oldData.byteLength);
24537
+ const instanceData = new ArrayBuffer(nextCapacity * instanceStrideBytes$1);
24538
+ if (carryBytes > 0) {
24539
+ new Uint8Array(instanceData).set(new Uint8Array(oldData, 0, carryBytes));
24540
+ }
24541
+ const instanceBuffer = this._device.createBuffer({
24542
+ size: instanceData.byteLength,
24543
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
24544
+ });
24545
+ this._instanceBuffer?.destroy();
24546
+ this._instanceCapacity = nextCapacity;
24547
+ this._instanceData = instanceData;
24548
+ this._instanceFloat32 = new Float32Array(instanceData);
24549
+ this._instanceUint32 = new Uint32Array(instanceData);
24550
+ this._instanceBuffer = instanceBuffer;
24551
+ }
24552
+ }
24553
+
24554
+ /// <reference types="@webgpu/types" />
24555
+ // ---------------------------------------------------------------------------
24556
+ // Shared WGSL declarations — structs, bindings, and output struct used by
24557
+ // both the shader path and the geometry path entry points.
24558
+ // ---------------------------------------------------------------------------
24559
+ const commonWgsl = `
24560
+ struct ProjectionUniforms {
24561
+ matrix: mat4x4<f32>,
24562
+ };
24563
+ struct TransformSlot {
24564
+ m0: vec4<f32>,
24565
+ m1: vec4<f32>,
24566
+ m2: vec4<f32>,
24567
+ };
24568
+
24569
+ @group(0) @binding(0) var<uniform> projection: ProjectionUniforms;
24570
+ @group(0) @binding(1) var<storage, read> transforms: array<TransformSlot>;
24571
+ @group(1) @binding(0) var spriteTexture: texture_2d<f32>;
24572
+ @group(1) @binding(1) var spriteSampler: sampler;
24573
+
24574
+ struct VOut {
24575
+ @builtin(position) pos: vec4<f32>,
24576
+ @location(0) uv: vec2<f32>,
24577
+ @location(1) color: vec4<f32>,
24578
+ };
24579
+ `;
24580
+ // ---------------------------------------------------------------------------
24581
+ // Shader path WGSL — one quad per sprite, UVs computed in vertex shader.
24582
+ // ---------------------------------------------------------------------------
24583
+ const shaderPathEntries = `
24584
+ struct ShaderVIn {
24585
+ @location(0) quadBounds: vec4<f32>, // x0,y0,x1,y1
24586
+ @location(1) uvParams: vec4<f32>, // tilingX, tilingY, offsetU, offsetV
24587
+ @location(2) color: vec4<f32>, // RGBA tint
24588
+ @location(3) nodeIndex: u32,
24589
+ };
24590
+
24591
+ @vertex
24592
+ fn shaderVert(input: ShaderVIn, @builtin(vertex_index) vid: u32) -> VOut {
24593
+ var out: VOut;
24594
+ let cx = ((vid + 1u) >> 1u) & 1u;
24595
+ let cy = vid >> 1u;
24596
+ let lx = select(input.quadBounds.x, input.quadBounds.z, cx == 1u);
24597
+ let ly = select(input.quadBounds.y, input.quadBounds.w, cy == 1u);
24598
+
24599
+ let destW = input.quadBounds.z - input.quadBounds.x;
24600
+ let destH = input.quadBounds.w - input.quadBounds.y;
24601
+
24602
+ let slot = transforms[input.nodeIndex];
24603
+ let wx = slot.m0.x * lx + slot.m0.y * ly + slot.m1.x;
24604
+ let wy = slot.m0.z * lx + slot.m0.w * ly + slot.m1.y;
24605
+ out.pos = projection.matrix * vec4<f32>(wx, wy, 0.0, 1.0);
24606
+
24607
+ let u = select(input.uvParams.z, ((lx - input.quadBounds.x) / destW) * input.uvParams.x + input.uvParams.z, destW > 0.0);
24608
+ let v = select(input.uvParams.w, ((ly - input.quadBounds.y) / destH) * input.uvParams.y + input.uvParams.w, destH > 0.0);
24609
+ out.uv = vec2<f32>(u, v);
24610
+ out.color = vec4<f32>(input.color.rgb * input.color.a, input.color.a);
24611
+ return out;
24612
+ }
24613
+
24614
+ @fragment
24615
+ fn shaderFrag(input: VOut) -> @location(0) vec4<f32> {
24616
+ return textureSample(spriteTexture, spriteSampler, input.uv) * input.color;
24617
+ }
24618
+ `;
24619
+ // ---------------------------------------------------------------------------
24620
+ // Geometry path WGSL — N quads per sprite, UVs pre-computed in CPU.
24621
+ // ---------------------------------------------------------------------------
24622
+ const geoPathEntries = `
24623
+ struct GeoVIn {
24624
+ @location(0) quadBounds: vec4<f32>, // x0,y0,x1,y1
24625
+ @location(1) uvBounds: vec4<f32>, // u0,v0,u1,v1 (normalised, flipY pre-applied)
24626
+ @location(2) color: vec4<f32>, // RGBA tint
24627
+ @location(3) nodeIndex: u32,
24628
+ };
24629
+
24630
+ @vertex
24631
+ fn geoVert(input: GeoVIn, @builtin(vertex_index) vid: u32) -> VOut {
24632
+ var out: VOut;
24633
+ let cx = ((vid + 1u) >> 1u) & 1u;
24634
+ let cy = vid >> 1u;
24635
+ let lx = select(input.quadBounds.x, input.quadBounds.z, cx == 1u);
24636
+ let ly = select(input.quadBounds.y, input.quadBounds.w, cy == 1u);
24637
+
24638
+ let slot = transforms[input.nodeIndex];
24639
+ let wx = slot.m0.x * lx + slot.m0.y * ly + slot.m1.x;
24640
+ let wy = slot.m0.z * lx + slot.m0.w * ly + slot.m1.y;
24641
+ out.pos = projection.matrix * vec4<f32>(wx, wy, 0.0, 1.0);
24642
+
24643
+ let u = select(input.uvBounds.x, input.uvBounds.z, cx == 1u);
24644
+ let v = select(input.uvBounds.y, input.uvBounds.w, cy == 1u);
24645
+ out.uv = vec2<f32>(u, v);
24646
+ out.color = vec4<f32>(input.color.rgb * input.color.a, input.color.a);
24647
+ return out;
24648
+ }
24649
+
24650
+ @fragment
24651
+ fn geoFrag(input: VOut) -> @location(0) vec4<f32> {
24652
+ return textureSample(spriteTexture, spriteSampler, input.uv) * input.color;
24653
+ }
24654
+ `;
24655
+ // ---------------------------------------------------------------------------
24656
+ // Layout constants
24657
+ // ---------------------------------------------------------------------------
24658
+ const shaderStrideBytes = 40; // float32x4 bounds + float32x4 uvParams + unorm8x4 + uint32
24659
+ const geoStrideBytes = 32; // float32x4 bounds + unorm16x4 + unorm8x4 + uint32 (NineSlice layout)
24660
+ const projectionByteLength$1 = 64;
24661
+ const initialBatchCapacity$1 = 32;
24662
+ const indicesPerInstance = 6;
24663
+ const quadIndices$2 = new Uint16Array([0, 1, 2, 0, 2, 3]);
24664
+ // ---------------------------------------------------------------------------
24665
+ // Sampler address mode helper
24666
+ // ---------------------------------------------------------------------------
24667
+ function repeatModeToAddressMode(mode) {
24668
+ if (mode === 'repeat')
24669
+ return 'repeat';
24670
+ if (mode === 'mirror-repeat')
24671
+ return 'mirror-repeat';
24672
+ return 'clamp-to-edge';
24673
+ }
24674
+ /** Instanced renderer for {@link RepeatingSprite} using WebGPU. */
24675
+ class WebGpuRepeatingSpriteRenderer extends AbstractWebGpuRenderer {
24676
+ _projData = new Float32Array(projectionByteLength$1 / Float32Array.BYTES_PER_ELEMENT);
24677
+ // Shared GPU objects
24678
+ _device = null;
24679
+ _uniformBindGroupLayout = null;
24680
+ _textureBindGroupLayout = null;
24681
+ _shaderModule = null;
24682
+ _uniformBuffer = null;
24683
+ _indexBuffer = null;
24684
+ _transformBindGroup = null;
24685
+ _transformStorageBuf = null;
24686
+ _pipelines = new Map();
24687
+ _samplers = new Map();
24688
+ // Shader-path instance buffer
24689
+ _shaderInstBuf = null;
24690
+ _shaderInstCapacity = 0;
24691
+ _shaderInstData = new ArrayBuffer(0);
24692
+ _shaderInstF32 = new Float32Array(this._shaderInstData);
24693
+ _shaderInstU32 = new Uint32Array(this._shaderInstData);
24694
+ _shaderQuadCount = 0;
24695
+ // Geometry-path instance buffer
24696
+ _geoInstBuf = null;
24697
+ _geoInstCapacity = 0;
24698
+ _geoInstData = new ArrayBuffer(0);
24699
+ _geoInstF32 = new Float32Array(this._geoInstData);
24700
+ _geoInstU32 = new Uint32Array(this._geoInstData);
24701
+ _geoQuadCount = 0;
24702
+ // Shared batch state
24703
+ _maxNodeIndex = 0;
24704
+ _currentTexture = null;
24705
+ _currentBlendMode = null;
24706
+ _currentModeX = null;
24707
+ _currentModeY = null;
24708
+ _currentPath = null;
24709
+ // Reusable scratch for device-snapped bounds ('geometry' mode).
24710
+ _snapBounds = new Rectangle();
24711
+ onConnect(backend) {
24712
+ if (this._device)
24713
+ return;
24714
+ const device = backend.device;
24715
+ this._device = device;
24716
+ this._shaderModule = device.createShaderModule({ code: commonWgsl + shaderPathEntries + geoPathEntries });
24717
+ this._uniformBindGroupLayout = device.createBindGroupLayout({
24718
+ entries: [
24719
+ { binding: 0, visibility: GPUShaderStage.VERTEX, buffer: { type: 'uniform' } },
24720
+ { binding: 1, visibility: GPUShaderStage.VERTEX, buffer: { type: 'read-only-storage' } },
24721
+ ],
24722
+ });
24723
+ this._textureBindGroupLayout = device.createBindGroupLayout({
24724
+ entries: [
24725
+ { binding: 0, visibility: GPUShaderStage.FRAGMENT, texture: { sampleType: 'float' } },
24726
+ { binding: 1, visibility: GPUShaderStage.FRAGMENT, sampler: { type: 'filtering' } },
24727
+ ],
24728
+ });
24729
+ this._uniformBuffer = device.createBuffer({
24730
+ size: projectionByteLength$1,
24731
+ usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
24732
+ });
24733
+ this._indexBuffer = device.createBuffer({
24734
+ size: quadIndices$2.byteLength,
24735
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
24736
+ });
24737
+ device.queue.writeBuffer(this._indexBuffer, 0, quadIndices$2.buffer, quadIndices$2.byteOffset, quadIndices$2.byteLength);
24738
+ }
24739
+ onDisconnect() {
24740
+ this._shaderInstBuf?.destroy();
24741
+ this._geoInstBuf?.destroy();
24742
+ this._indexBuffer?.destroy();
24743
+ this._uniformBuffer?.destroy();
24744
+ this._pipelines.clear();
24745
+ this._samplers.clear();
24746
+ this._shaderInstBuf = null;
24747
+ this._geoInstBuf = null;
24748
+ this._indexBuffer = null;
24749
+ this._uniformBuffer = null;
24750
+ this._transformBindGroup = null;
24751
+ this._transformStorageBuf = null;
24752
+ this._textureBindGroupLayout = null;
24753
+ this._uniformBindGroupLayout = null;
24754
+ this._shaderModule = null;
24755
+ this._device = null;
24756
+ this._backend = null;
24757
+ this._shaderInstCapacity = 0;
24758
+ this._shaderInstData = new ArrayBuffer(0);
24759
+ this._shaderInstF32 = new Float32Array(this._shaderInstData);
24760
+ this._shaderInstU32 = new Uint32Array(this._shaderInstData);
24761
+ this._geoInstCapacity = 0;
24762
+ this._geoInstData = new ArrayBuffer(0);
24763
+ this._geoInstF32 = new Float32Array(this._geoInstData);
24764
+ this._geoInstU32 = new Uint32Array(this._geoInstData);
24765
+ this._shaderQuadCount = 0;
24766
+ this._geoQuadCount = 0;
24767
+ this._maxNodeIndex = 0;
24768
+ this._currentTexture = null;
24769
+ this._currentBlendMode = null;
24770
+ this._currentModeX = null;
24771
+ this._currentModeY = null;
24772
+ this._currentPath = null;
24773
+ }
24774
+ render(sprite) {
24775
+ const backend = this._backend;
24776
+ if (!backend)
24777
+ return;
24778
+ const texture = sprite.texture;
24779
+ if (texture instanceof Texture && texture.source === null)
24780
+ return;
24781
+ if (texture.width === 0 || texture.height === 0)
24782
+ return;
24783
+ const strategy = sprite.resolvedStrategy;
24784
+ const blendMode = sprite.blendMode;
24785
+ const modeX = sprite.modeX;
24786
+ const modeY = sprite.modeY;
24787
+ const hasData = this._shaderQuadCount > 0 || this._geoQuadCount > 0;
24788
+ if (hasData) {
24789
+ const pathChanged = this._currentPath !== strategy;
24790
+ const texChanged = this._currentTexture !== texture;
24791
+ const blendChanged = this._currentBlendMode !== blendMode;
24792
+ const modeChanged = strategy === 'shader'
24793
+ && (this._currentModeX !== modeX || this._currentModeY !== modeY);
24794
+ if (pathChanged || texChanged || blendChanged || modeChanged) {
24795
+ this.flush();
24796
+ }
24797
+ }
24798
+ this._currentTexture = texture;
24799
+ this._currentBlendMode = blendMode;
24800
+ this._currentPath = strategy;
24801
+ backend.setBlendMode(blendMode);
24802
+ const command = backend.activeDrawCommand;
24803
+ const nodeIndex = command !== null ? command.nodeIndex : backend._pushTransform(sprite);
24804
+ if (nodeIndex > this._maxNodeIndex)
24805
+ this._maxNodeIndex = nodeIndex;
24806
+ if (strategy === 'shader') {
24807
+ this._currentModeX = modeX;
24808
+ this._currentModeY = modeY;
24809
+ this._writeShaderInstance(sprite, nodeIndex);
24810
+ }
24811
+ else {
24812
+ this._writeGeoQuads(sprite, nodeIndex);
24813
+ }
24814
+ }
24815
+ _writeShaderInstance(sprite, nodeIndex) {
24816
+ const texture = sprite.texture;
24817
+ const srcW = sprite.region.width;
24818
+ const srcH = sprite.region.height;
24819
+ let destW = sprite.width;
24820
+ let destH = sprite.height;
24821
+ const flipY = texture instanceof Texture && texture.flipY;
24822
+ // 'geometry' mode: snap the destination quad to the device grid. Repetition
24823
+ // stays shader-based; only the outer rectangle (and the tiling derived from
24824
+ // it) moves. Position/none leave the destination unchanged.
24825
+ if (sprite.pixelSnapMode === 'geometry') {
24826
+ const backend = this.getBackend();
24827
+ const snap = backend._getSnapPixelSize();
24828
+ const rb = sprite.getRenderBounds(backend.view, snap.width, snap.height, this._snapBounds);
24829
+ destW = rb.width;
24830
+ destH = rb.height;
24831
+ }
24832
+ const tilingX = computeShaderTiling(srcW, destW, sprite.modeX, sprite.fitX);
24833
+ const tilingY = computeShaderTiling(srcH, destH, sprite.modeY, sprite.fitY);
24834
+ const offsetU = sprite.offsetX / (srcW > 0 ? srcW : 1);
24835
+ const offsetV = sprite.offsetY / (srcH > 0 ? srcH : 1);
24836
+ const uvParamY = flipY ? -tilingY : tilingY;
24837
+ const uvParamW = flipY ? tilingY + offsetV : offsetV;
24838
+ this._ensureShaderCapacity(this._shaderQuadCount + 1);
24839
+ const words = shaderStrideBytes / 4;
24840
+ const offset = this._shaderQuadCount * words;
24841
+ const f32 = this._shaderInstF32;
24842
+ const u32 = this._shaderInstU32;
24843
+ f32[offset + 0] = 0;
24844
+ f32[offset + 1] = 0;
24845
+ f32[offset + 2] = destW;
24846
+ f32[offset + 3] = destH;
24847
+ f32[offset + 4] = tilingX;
24848
+ f32[offset + 5] = uvParamY;
24849
+ f32[offset + 6] = offsetU;
24850
+ f32[offset + 7] = uvParamW;
24851
+ u32[offset + 8] = sprite.tint.toRgba();
24852
+ u32[offset + 9] = nodeIndex >>> 0;
24853
+ this._shaderQuadCount++;
24854
+ }
24855
+ _writeGeoQuads(sprite, nodeIndex) {
24856
+ let quads = sprite.quads;
24857
+ // 'geometry' mode: snap shared segment boundaries once (gap-free), like NineSlice.
24858
+ if (sprite.pixelSnapMode === 'geometry') {
24859
+ const backend = this.getBackend();
24860
+ const snap = backend._getSnapPixelSize();
24861
+ quads = sprite.getRenderQuads(backend.view, snap.width, snap.height);
24862
+ }
24863
+ if (quads.length === 0)
24864
+ return;
24865
+ const flipY = (sprite.texture instanceof Texture) && sprite.texture.flipY;
24866
+ const tint = sprite.tint.toRgba();
24867
+ const words = geoStrideBytes / 4;
24868
+ this._ensureGeoCapacity(this._geoQuadCount + quads.length);
24869
+ const f32 = this._geoInstF32;
24870
+ const u32 = this._geoInstU32;
24871
+ for (let i = 0; i < quads.length; i++) {
24872
+ const q = quads[i];
24873
+ const offset = (this._geoQuadCount + i) * words;
24874
+ f32[offset + 0] = q.x0;
24875
+ f32[offset + 1] = q.y0;
24876
+ f32[offset + 2] = q.x1;
24877
+ f32[offset + 3] = q.y1;
24878
+ const uMin = (q.u0 * 0xffff) & 0xffff;
24879
+ const uMax = (q.u1 * 0xffff) & 0xffff;
24880
+ const v0Raw = (q.v0 * 0xffff) & 0xffff;
24881
+ const v1Raw = (q.v1 * 0xffff) & 0xffff;
24882
+ const vMin = flipY ? v1Raw : v0Raw;
24883
+ const vMax = flipY ? v0Raw : v1Raw;
24884
+ u32[offset + 4] = uMin | (vMin << 16);
24885
+ u32[offset + 5] = uMax | (vMax << 16);
24886
+ u32[offset + 6] = tint;
24887
+ u32[offset + 7] = nodeIndex >>> 0;
24888
+ }
24889
+ this._geoQuadCount += quads.length;
24890
+ }
24891
+ flush() {
24892
+ const backend = this._backend;
24893
+ const device = this._device;
24894
+ const uniform = this._uniformBuffer;
24895
+ if (!backend || !device || !uniform)
24896
+ return;
24897
+ if (this._shaderQuadCount === 0 && this._geoQuadCount === 0 && !backend.clearRequested) {
24898
+ return;
24899
+ }
24900
+ const vm = backend.view.getTransform();
24901
+ this._projData.set([vm.a, vm.c, 0, 0, vm.b, vm.d, 0, 0, 0, 0, 1, 0, vm.x, vm.y, 0, vm.z]);
24902
+ device.queue.writeBuffer(uniform, 0, this._projData.buffer, this._projData.byteOffset, this._projData.byteLength);
24903
+ const scissor = backend.getScissorRect();
24904
+ const maskClipsAll = scissor !== null && (scissor.width <= 0 || scissor.height <= 0);
24905
+ const pass = backend._passCoordinator.acquirePass().pass;
24906
+ const stencil = backend._passCoordinator.stencilActive;
24907
+ if (this._shaderQuadCount > 0 && !maskClipsAll) {
24908
+ this._drawShaderBatch(device, backend, pass, stencil);
24909
+ }
24910
+ if (this._geoQuadCount > 0 && !maskClipsAll) {
24911
+ this._drawGeoBatch(device, backend, pass, stencil);
24912
+ }
24913
+ backend._passCoordinator.endPass();
24914
+ this._shaderQuadCount = 0;
24915
+ this._geoQuadCount = 0;
24916
+ this._maxNodeIndex = 0;
24917
+ this._currentTexture = null;
24918
+ this._currentBlendMode = null;
24919
+ this._currentModeX = null;
24920
+ this._currentModeY = null;
24921
+ this._currentPath = null;
24922
+ }
24923
+ _drawShaderBatch(device, backend, pass, stencil) {
24924
+ if (!this._shaderInstBuf || !this._indexBuffer || this._currentBlendMode === null || this._currentTexture === null)
24925
+ return;
24926
+ device.queue.writeBuffer(this._shaderInstBuf, 0, this._shaderInstData, 0, this._shaderQuadCount * shaderStrideBytes);
24927
+ const storage = backend.getTransformStorageBuffer(this._maxNodeIndex + 1);
24928
+ const uniformBindGroup = this._getOrCreateTransformBindGroup(device, this._uniformBuffer, storage.buffer);
24929
+ const modeX = this._currentModeX ?? 'repeat';
24930
+ const modeY = this._currentModeY ?? 'repeat';
24931
+ const sampler = this._getOrCreateSampler(device, modeX, modeY);
24932
+ const texView = backend.getTextureBinding(this._currentTexture).view;
24933
+ const textureBindGroup = device.createBindGroup({
24934
+ layout: this._textureBindGroupLayout,
24935
+ entries: [{ binding: 0, resource: texView }, { binding: 1, resource: sampler }],
24936
+ });
24937
+ const pipeline = this._getPipeline('shader', this._currentBlendMode, backend.renderTargetFormat, stencil);
24938
+ pass.setPipeline(pipeline);
24939
+ pass.setBindGroup(0, uniformBindGroup);
24940
+ pass.setBindGroup(1, textureBindGroup);
24941
+ pass.setVertexBuffer(0, this._shaderInstBuf);
24942
+ pass.setIndexBuffer(this._indexBuffer, 'uint16');
24943
+ pass.drawIndexed(indicesPerInstance, this._shaderQuadCount, 0, 0, 0);
24944
+ backend.stats.batches++;
24945
+ backend.stats.drawCalls++;
24946
+ }
24947
+ _drawGeoBatch(device, backend, pass, stencil) {
24948
+ if (!this._geoInstBuf || !this._indexBuffer || this._currentBlendMode === null || this._currentTexture === null)
24949
+ return;
24950
+ device.queue.writeBuffer(this._geoInstBuf, 0, this._geoInstData, 0, this._geoQuadCount * geoStrideBytes);
24951
+ const storage = backend.getTransformStorageBuffer(this._maxNodeIndex + 1);
24952
+ const uniformBindGroup = this._getOrCreateTransformBindGroup(device, this._uniformBuffer, storage.buffer);
24953
+ // Geometry path: use backend's default (clamp) sampler.
24954
+ const binding = backend.getTextureBinding(this._currentTexture);
24955
+ const textureBindGroup = device.createBindGroup({
24956
+ layout: this._textureBindGroupLayout,
24957
+ entries: [{ binding: 0, resource: binding.view }, { binding: 1, resource: binding.sampler }],
24958
+ });
24959
+ const pipeline = this._getPipeline('geo', this._currentBlendMode, backend.renderTargetFormat, stencil);
24960
+ pass.setPipeline(pipeline);
24961
+ pass.setBindGroup(0, uniformBindGroup);
24962
+ pass.setBindGroup(1, textureBindGroup);
24963
+ pass.setVertexBuffer(0, this._geoInstBuf);
24964
+ pass.setIndexBuffer(this._indexBuffer, 'uint16');
24965
+ pass.drawIndexed(indicesPerInstance, this._geoQuadCount, 0, 0, 0);
24966
+ backend.stats.batches++;
24967
+ backend.stats.drawCalls++;
24968
+ }
24969
+ destroy() {
24970
+ this.disconnect();
24971
+ }
24972
+ // -----------------------------------------------------------------------
24973
+ // Private helpers
24974
+ // -----------------------------------------------------------------------
24975
+ _getOrCreateSampler(device, modeX, modeY) {
24976
+ const key = `${modeX}:${modeY}`;
24977
+ const existing = this._samplers.get(key);
24978
+ if (existing)
24979
+ return existing;
24980
+ const sampler = device.createSampler({
24981
+ addressModeU: repeatModeToAddressMode(modeX),
24982
+ addressModeV: repeatModeToAddressMode(modeY),
24983
+ magFilter: 'linear',
24984
+ minFilter: 'linear',
24985
+ });
24986
+ this._samplers.set(key, sampler);
24987
+ return sampler;
24988
+ }
24989
+ _getOrCreateTransformBindGroup(device, uniformBuf, storageBuf) {
24990
+ if (this._transformBindGroup !== null && this._transformStorageBuf === storageBuf) {
24991
+ return this._transformBindGroup;
24992
+ }
24993
+ this._transformStorageBuf = storageBuf;
24994
+ this._transformBindGroup = device.createBindGroup({
24995
+ layout: this._uniformBindGroupLayout,
24996
+ entries: [
24997
+ { binding: 0, resource: { buffer: uniformBuf } },
24998
+ { binding: 1, resource: { buffer: storageBuf } },
24999
+ ],
25000
+ });
25001
+ return this._transformBindGroup;
25002
+ }
25003
+ _getPipeline(kind, blend, format, stencil) {
25004
+ const key = `${kind}:${blend}:${format}:${stencil ? 's' : 'n'}`;
25005
+ const existing = this._pipelines.get(key);
25006
+ if (existing)
25007
+ return existing;
25008
+ if (!this._device || !this._shaderModule || !this._uniformBindGroupLayout || !this._textureBindGroupLayout) {
25009
+ throw new Error('WebGpuRepeatingSpriteRenderer: not connected.');
25010
+ }
25011
+ const layout = this._device.createPipelineLayout({
25012
+ bindGroupLayouts: [this._uniformBindGroupLayout, this._textureBindGroupLayout],
25013
+ });
25014
+ const isShader = kind === 'shader';
25015
+ const strideBytes = isShader ? shaderStrideBytes : geoStrideBytes;
25016
+ const buffers = isShader
25017
+ ? [{
25018
+ arrayStride: strideBytes,
25019
+ stepMode: 'instance',
25020
+ attributes: [
25021
+ { shaderLocation: 0, offset: 0, format: 'float32x4' },
25022
+ { shaderLocation: 1, offset: 16, format: 'float32x4' },
25023
+ { shaderLocation: 2, offset: 32, format: 'unorm8x4' },
25024
+ { shaderLocation: 3, offset: 36, format: 'uint32' },
25025
+ ],
25026
+ }]
25027
+ : [{
25028
+ arrayStride: strideBytes,
25029
+ stepMode: 'instance',
25030
+ attributes: [
25031
+ { shaderLocation: 0, offset: 0, format: 'float32x4' },
25032
+ { shaderLocation: 1, offset: 16, format: 'unorm16x4' },
25033
+ { shaderLocation: 2, offset: 24, format: 'unorm8x4' },
25034
+ { shaderLocation: 3, offset: 28, format: 'uint32' },
25035
+ ],
25036
+ }];
25037
+ const desc = {
25038
+ layout,
25039
+ vertex: {
25040
+ module: this._shaderModule,
25041
+ entryPoint: isShader ? 'shaderVert' : 'geoVert',
25042
+ buffers,
25043
+ },
25044
+ fragment: {
25045
+ module: this._shaderModule,
25046
+ entryPoint: isShader ? 'shaderFrag' : 'geoFrag',
25047
+ targets: [{ format, blend: getWebGpuBlendState(blend), writeMask: GPUColorWrite.ALL }],
25048
+ },
25049
+ primitive: { topology: 'triangle-list' },
25050
+ };
25051
+ if (stencil) {
25052
+ desc.depthStencil = stencilContentDepthStencilState();
25053
+ }
25054
+ const pipeline = this._device.createRenderPipeline(desc);
25055
+ this._pipelines.set(key, pipeline);
25056
+ return pipeline;
25057
+ }
25058
+ _ensureShaderCapacity(needed) {
25059
+ if (!this._device || needed <= this._shaderInstCapacity)
25060
+ return;
25061
+ this._shaderInstCapacity = this._growCapacity(this._shaderInstCapacity, needed);
25062
+ const oldData = this._shaderInstData;
25063
+ const carry = this._shaderQuadCount * shaderStrideBytes;
25064
+ this._shaderInstData = new ArrayBuffer(this._shaderInstCapacity * shaderStrideBytes);
25065
+ if (carry > 0)
25066
+ new Uint8Array(this._shaderInstData).set(new Uint8Array(oldData, 0, carry));
25067
+ this._shaderInstF32 = new Float32Array(this._shaderInstData);
25068
+ this._shaderInstU32 = new Uint32Array(this._shaderInstData);
25069
+ this._shaderInstBuf?.destroy();
25070
+ this._shaderInstBuf = this._device.createBuffer({
25071
+ size: this._shaderInstData.byteLength,
25072
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
25073
+ });
25074
+ }
25075
+ _ensureGeoCapacity(needed) {
25076
+ if (!this._device || needed <= this._geoInstCapacity)
25077
+ return;
25078
+ this._geoInstCapacity = this._growCapacity(this._geoInstCapacity, needed);
25079
+ const oldData = this._geoInstData;
25080
+ const carry = this._geoQuadCount * geoStrideBytes;
25081
+ this._geoInstData = new ArrayBuffer(this._geoInstCapacity * geoStrideBytes);
25082
+ if (carry > 0)
25083
+ new Uint8Array(this._geoInstData).set(new Uint8Array(oldData, 0, carry));
25084
+ this._geoInstF32 = new Float32Array(this._geoInstData);
25085
+ this._geoInstU32 = new Uint32Array(this._geoInstData);
25086
+ this._geoInstBuf?.destroy();
25087
+ this._geoInstBuf = this._device.createBuffer({
25088
+ size: this._geoInstData.byteLength,
25089
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
25090
+ });
25091
+ }
25092
+ _growCapacity(current, needed) {
25093
+ let cap = Math.max(current, initialBatchCapacity$1);
25094
+ while (cap < needed)
25095
+ cap *= 2;
25096
+ return cap;
25097
+ }
25098
+ }
25099
+
21817
25100
  /// <reference types="@webgpu/types" />
21818
25101
  const spriteShaderSource = `
21819
25102
  struct ProjectionUniforms {
@@ -22006,6 +25289,10 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
22006
25289
  _customBaseTextureLayout = null;
22007
25290
  _currentMaterial = null;
22008
25291
  _currentBaseTexture = null;
25292
+ // Reusable scratch for device-snapped local bounds ('geometry' mode), and the
25293
+ // bounds resolved for the sprite currently being packed (snapped or logical).
25294
+ _snapBounds = new Rectangle();
25295
+ _activeBounds = null;
22009
25296
  onConnect(backend) {
22010
25297
  if (this._device) {
22011
25298
  return;
@@ -22126,6 +25413,7 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
22126
25413
  // sprite's transform into the buffer and use the freshly-allocated slot.
22127
25414
  const command = backend.activeDrawCommand;
22128
25415
  const nodeIndex = command !== null ? command.nodeIndex : backend._pushTransform(sprite);
25416
+ this._activeBounds = this._resolveBounds(sprite, backend);
22129
25417
  if (material === null) {
22130
25418
  this._renderDefault(sprite, texture, backend, nodeIndex);
22131
25419
  }
@@ -22133,6 +25421,19 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
22133
25421
  this._renderCustom(sprite, texture, material, backend, nodeIndex);
22134
25422
  }
22135
25423
  }
25424
+ /**
25425
+ * Local bounds to upload for `sprite` this draw: device-pixel-snapped in
25426
+ * `'geometry'` pixel-snap mode (axis-aligned only), otherwise the sprite's
25427
+ * logical local bounds. Reuses a scratch rectangle and never mutates logical
25428
+ * state. Consumed synchronously by {@link _packInstance}.
25429
+ */
25430
+ _resolveBounds(sprite, backend) {
25431
+ if (sprite.pixelSnapMode !== 'geometry') {
25432
+ return sprite.getLocalBounds();
25433
+ }
25434
+ const snap = backend._getSnapPixelSize();
25435
+ return sprite.getRenderBounds(backend.view, snap.width, snap.height, this._snapBounds);
25436
+ }
22136
25437
  /** Default multi-texture path: rotate the base texture through 8 slots. */
22137
25438
  _renderDefault(sprite, texture, backend, nodeIndex) {
22138
25439
  const blendMode = sprite.blendMode;
@@ -22311,8 +25612,9 @@ class WebGpuSpriteRenderer extends AbstractWebGpuRenderer {
22311
25612
  const offset = this._instanceCount * wordsPerInstance;
22312
25613
  const f32 = this._instanceFloat32;
22313
25614
  const u32 = this._instanceUint32;
22314
- // localBounds: left, top, right, bottom (words 0..3, offset 0)
22315
- const bounds = sprite.getLocalBounds();
25615
+ // localBounds: left, top, right, bottom (words 0..3, offset 0) — device-snapped in
25616
+ // 'geometry' pixel-snap mode, otherwise the logical local bounds.
25617
+ const bounds = this._activeBounds ?? sprite.getLocalBounds();
22316
25618
  f32[offset + 0] = bounds.left;
22317
25619
  f32[offset + 1] = bounds.top;
22318
25620
  f32[offset + 2] = bounds.right;
@@ -23507,6 +26809,14 @@ function buildCoreRendererBindings(options) {
23507
26809
  [RenderBackendType.WebGl2]: () => new WebGl2TextRenderer(),
23508
26810
  [RenderBackendType.WebGpu]: () => new WebGpuTextRenderer(),
23509
26811
  };
26812
+ const nineSliceRenderers = {
26813
+ [RenderBackendType.WebGl2]: () => new WebGl2NineSliceSpriteRenderer(spriteRendererBatchSize),
26814
+ [RenderBackendType.WebGpu]: () => new WebGpuNineSliceSpriteRenderer(),
26815
+ };
26816
+ const repeatingSpriteRenderers = {
26817
+ [RenderBackendType.WebGl2]: () => new WebGl2RepeatingSpriteRenderer(spriteRendererBatchSize),
26818
+ [RenderBackendType.WebGpu]: () => new WebGpuRepeatingSpriteRenderer(),
26819
+ };
23510
26820
  return [
23511
26821
  {
23512
26822
  targets: [Sprite],
@@ -23521,6 +26831,14 @@ function buildCoreRendererBindings(options) {
23521
26831
  targets: [Text, BitmapText],
23522
26832
  create: backend => textRenderers[backend.backendType]?.(),
23523
26833
  },
26834
+ {
26835
+ targets: [NineSliceSprite],
26836
+ create: backend => nineSliceRenderers[backend.backendType]?.(),
26837
+ },
26838
+ {
26839
+ targets: [RepeatingSprite],
26840
+ create: backend => repeatingSpriteRenderers[backend.backendType]?.(),
26841
+ },
23524
26842
  ];
23525
26843
  }
23526
26844
 
@@ -24551,6 +27869,7 @@ class WebGl2Backend {
24551
27869
  _canvas;
24552
27870
  _contextLost;
24553
27871
  _renderTarget;
27872
+ _snapTransform = new Matrix();
24554
27873
  _renderer = null;
24555
27874
  _shader = null;
24556
27875
  _blendMode = null;
@@ -24680,7 +27999,34 @@ class WebGl2Backend {
24680
27999
  */
24681
28000
  _writeTransformCommand(command) {
24682
28001
  const drawable = command.drawable;
24683
- this._transformBuffer.write(command.nodeIndex, drawable.getGlobalTransform(), drawable.tint);
28002
+ this._transformBuffer.write(command.nodeIndex, this._resolveSnapTransform(drawable), drawable.tint);
28003
+ }
28004
+ /**
28005
+ * Resolve the world transform to upload for `drawable`, applying render-only
28006
+ * pixel snapping against the active render target's device-pixel grid when the
28007
+ * drawable opts in. Returns the live (unsnapped) global transform for the
28008
+ * `'none'` default; never mutates logical state.
28009
+ * @internal
28010
+ */
28011
+ _resolveSnapTransform(drawable) {
28012
+ const target = this._renderTarget;
28013
+ const width = target.root ? this._canvas.width : target.width;
28014
+ const height = target.root ? this._canvas.height : target.height;
28015
+ return resolveUploadTransform(drawable, target.view, width, height, this._snapTransform);
28016
+ }
28017
+ /**
28018
+ * Device-pixel dimensions of the active render target — `canvas.width/height`
28019
+ * (css × pixelRatio) for the root, or the target's own size for an offscreen
28020
+ * {@link RenderTexture}. Used by batched renderers to snap shared geometry
28021
+ * boundaries to the same device grid the transform seam snaps the origin to.
28022
+ * @internal
28023
+ */
28024
+ _getSnapPixelSize() {
28025
+ const target = this._renderTarget;
28026
+ return {
28027
+ width: target.root ? this._canvas.width : target.width,
28028
+ height: target.root ? this._canvas.height : target.height,
28029
+ };
24684
28030
  }
24685
28031
  /**
24686
28032
  * Append a drawable's world transform (+ tint) to the shared transform buffer
@@ -24693,7 +28039,7 @@ class WebGl2Backend {
24693
28039
  * @internal
24694
28040
  */
24695
28041
  _pushTransform(drawable) {
24696
- return this._transformBuffer.push(drawable.getGlobalTransform(), drawable.tint);
28042
+ return this._transformBuffer.push(this._resolveSnapTransform(drawable), drawable.tint);
24697
28043
  }
24698
28044
  /** @internal */
24699
28045
  _endDrawPlan() {
@@ -26033,9 +29379,9 @@ class WebGpuTransformStorage {
26033
29379
  begin(nodeCount) {
26034
29380
  this._buffer.begin(nodeCount);
26035
29381
  }
26036
- writeCommand(command) {
29382
+ writeCommand(command, transform) {
26037
29383
  const drawable = command.drawable;
26038
- this._buffer.write(command.nodeIndex, drawable.getGlobalTransform(), drawable.tint);
29384
+ this._buffer.write(command.nodeIndex, transform ?? drawable.getGlobalTransform(), drawable.tint);
26039
29385
  }
26040
29386
  /**
26041
29387
  * Record that a draw command's transform write was skipped because its
@@ -26051,8 +29397,8 @@ class WebGpuTransformStorage {
26051
29397
  * `nodeIndex` — a direct `backend.draw(drawable)` outside the plan player —
26052
29398
  * so a batch of synthetic draws does not collide on a single row.
26053
29399
  */
26054
- push(drawable) {
26055
- return this._buffer.push(drawable.getGlobalTransform(), drawable.tint);
29400
+ push(drawable, transform) {
29401
+ return this._buffer.push(transform ?? drawable.getGlobalTransform(), drawable.tint);
26056
29402
  }
26057
29403
  /**
26058
29404
  * Pre-allocate (or grow) the GPU storage buffer to hold at least
@@ -26170,6 +29516,7 @@ class WebGpuBackend {
26170
29516
  _format = null;
26171
29517
  _initializePromise = null;
26172
29518
  _renderTarget;
29519
+ _snapTransform = new Matrix();
26173
29520
  _renderer = null;
26174
29521
  _texture = null;
26175
29522
  _clearRequested = false;
@@ -26291,7 +29638,7 @@ class WebGpuBackend {
26291
29638
  for (let i = 0; i < instructions.length; i++) {
26292
29639
  const command = instructions[i];
26293
29640
  if (drawCommandUsesSharedTransform(command, this)) {
26294
- storage.writeCommand(command);
29641
+ storage.writeCommand(command, this._resolveSnapTransform(command.drawable));
26295
29642
  }
26296
29643
  else {
26297
29644
  storage.recordSkippedWrite();
@@ -26626,7 +29973,31 @@ class WebGpuBackend {
26626
29973
  * @internal
26627
29974
  */
26628
29975
  _pushTransform(drawable) {
26629
- return this._getTransformStorage().push(drawable);
29976
+ return this._getTransformStorage().push(drawable, this._resolveSnapTransform(drawable));
29977
+ }
29978
+ /**
29979
+ * Resolve the world transform to upload for `drawable`, applying render-only
29980
+ * pixel snapping against the active render target's device-pixel grid when the
29981
+ * drawable opts in. Returns the live (unsnapped) global transform for the
29982
+ * `'none'` default; never mutates logical state.
29983
+ * @internal
29984
+ */
29985
+ _resolveSnapTransform(drawable) {
29986
+ const target = this._renderTarget;
29987
+ const root = target === this._rootRenderTarget;
29988
+ const width = root ? this._canvas.width : target.width;
29989
+ const height = root ? this._canvas.height : target.height;
29990
+ return resolveUploadTransform(drawable, target.view, width, height, this._snapTransform);
29991
+ }
29992
+ /**
29993
+ * Device-pixel dimensions of the active render target — `canvas.width/height`
29994
+ * (css × pixelRatio) for the root, or the target's own size for an offscreen
29995
+ * {@link RenderTexture}. Used by batched renderers to snap shared geometry
29996
+ * boundaries to the same device grid the transform seam snaps the origin to.
29997
+ * @internal
29998
+ */
29999
+ _getSnapPixelSize() {
30000
+ return this._getAttachmentPixelSize(this._renderTarget);
26630
30001
  }
26631
30002
  _setActiveRenderer(renderer) {
26632
30003
  if (this._renderer !== renderer) {
@@ -29736,6 +33107,10 @@ class Loader {
29736
33107
  * Atomically bind all keys for one AssetBinding to a pre-created handler.
29737
33108
  * Validates all keys BEFORE mutating any map. Any already-registered key
29738
33109
  * throws before any mutation (no override in 0.12).
33110
+ *
33111
+ * `Result` and `Options` are inferred from the binding's `AssetBinding<Result, Options>`
33112
+ * contract. A declarative handler's optional `getIdentityKey` is forwarded into
33113
+ * the internal {@link HandlerEntry} so it participates in in-flight deduplication.
29739
33114
  * @internal
29740
33115
  */
29741
33116
  bindAsset(keys, handler) {
@@ -29768,17 +33143,30 @@ class Loader {
29768
33143
  }
29769
33144
  }
29770
33145
  // All validation passed — install atomically.
29771
- // Internally the loader builds a flat config `{ source, ...fields }` (the
29772
- // shape the advanced `registerAssetType` handler form receives). Extension
29773
- // AssetHandlers are typed against the public `AssetLoadRequest`
29774
- // (`{ source, options? }`), so reshape the flat config here: pull `source`
29775
- // out and nest the remaining per-load fields under `options`.
33146
+ //
33147
+ // Localized type-erasure boundary: the internal Loader uses a flat config
33148
+ // `{ source, ...fields }`. The public AssetHandler<Result, Options> interface
33149
+ // uses `AssetLoadRequest<Options> = { source, options? }`. This single `toRequest`
33150
+ // helper is the only place where the erased flat config is cast to the typed
33151
+ // request — justified by the `AssetBinding<Result, Options>` contract that
33152
+ // associates this handler's Options with the registered constructor.
33153
+ //
33154
+ // `options` is intentionally omitted (not set to `undefined`) when the flat
33155
+ // config carries no extra fields, keeping the object compatible with a future
33156
+ // `exactOptionalPropertyTypes` migration.
33157
+ const toRequest = (config) => {
33158
+ const { source, ...rest } = config;
33159
+ if (Object.keys(rest).length === 0) {
33160
+ return { source };
33161
+ }
33162
+ return { source, options: rest };
33163
+ };
33164
+ const boundIdentityKey = handler.getIdentityKey?.bind(handler);
29776
33165
  this._handlerFunctions.set(keys.type, {
29777
- load: (config, ctx) => {
29778
- const { source, ...rest } = config;
29779
- const options = Object.keys(rest).length > 0 ? rest : undefined;
29780
- return handler.load({ source, options }, ctx);
29781
- },
33166
+ load: (config, ctx) => handler.load(toRequest(config), ctx),
33167
+ getIdentityKey: boundIdentityKey
33168
+ ? (config) => boundIdentityKey(toRequest(config))
33169
+ : undefined,
29782
33170
  });
29783
33171
  for (const name of resolvedNames) {
29784
33172
  this._assetTypeMap.set(name, keys.type);
@@ -29786,7 +33174,9 @@ class Loader {
29786
33174
  for (const ext of normalizedExts) {
29787
33175
  this._extensionMap.set(ext, keys.type);
29788
33176
  }
29789
- // Own this handler for lifecycle management
33177
+ // Own this handler for lifecycle management.
33178
+ // Cast to the erased AssetHandler for storage; destroy() is the only method
33179
+ // called on entries in this array.
29790
33180
  this._boundHandlers.push(handler);
29791
33181
  }
29792
33182
  /**
@@ -31642,8 +35032,8 @@ class Application {
31642
35032
  }
31643
35033
 
31644
35034
  const buildInfo = Object.freeze({
31645
- version: "0.12.0",
31646
- revision: "599bc56",
35035
+ version: "0.13.0",
35036
+ revision: "735089b",
31647
35037
  development: false,
31648
35038
  });
31649
35039
 
@@ -37552,5 +40942,5 @@ class TextFactory extends AbstractAssetFactory {
37552
40942
  }
37553
40943
  }
37554
40944
 
37555
- export { AbstractAssetFactory, AbstractMedia, AbstractText, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AnimatedSprite, Application, ApplicationStatus, ArcadeStickGamepadMapping, Asset, AssetImpl, Assets, AssetsImpl, AtlasPage, AudioAnalyser, AudioBus, AudioFilter, AudioListener, AudioManager, BeatDetector, BinaryAsset, BinaryFactory, BitmapText, BlendModes, BlurFilter, BmFont, BmFontAdapter, BmFontLoaderFactory, Bounds, BufferTypes, BufferUsage, BundleLoadError, CacheFirstStrategy, CallbackRenderPass, Camera, Capabilities, ChannelOffset, ChannelSize, ChorusFilter, Circle, Clock, CollisionType, Color, ColorFilter, CompressorFilter, Container, CsvAsset, CsvFactory, DataTexture, DelayFilter, Drawable, DuckingFilter, Ease, Ellipse, Envelope, EqualizerFilter, FactoryRegistry, Filter, Flags, FontAsset, FontFactory, GameCubeGamepadMapping, Gamepad, GamepadAxis, GamepadButton, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, Geometry, GlyphAtlas, GlyphAtlasPool, Gradient, GranularFilter, Graphics, HTMLText, HighpassFilter, ImageAsset, ImageFactory, IndexedDbDatabase, IndexedDbStore, InputBinding, InputManager, InteractionEvent, InteractionManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, JsonStore, Keyboard, Line, LinearGradient, Loader, LoadingQueue, LowpassFilter, LutFilter, Material, Matrix, Mesh, MeshMaterial, Music, MusicFactory, NetworkOnlyStrategy, ObservableSize, ObservableVector, OscillatorSound, PitchShiftFilter, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, RadialGradient, Random, Rectangle, RenderBackendType, RenderNode, RenderNodePass, RenderPass, RenderPipeline, RenderTarget, RenderTexture, RendererRegistry, RenderingContext, RenderingPrimitives, ReverbFilter, SDF_RADIUS, Sampler, ScaleModes, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderSource, ShaderUniform, Signal, Size, Sound, SoundFactory, SoundPoolStrategy, Sprite, SpriteFlags, SpriteMaterial, Spritesheet, SteamControllerGamepadMapping, SteamDeckGamepadMapping, SubtitleAsset, SubtitleFactory, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, Time, Timer, Tween, TweenManager, TweenState, Vector, Video, VideoFactory, View, ViewFlags, VocoderFilter, VoronoiRegion, WasmAsset, WasmFactory, WebGl2Backend, WebGl2MeshRenderer, WebGl2RenderBuffer, WebGl2ShaderBlock, WebGl2ShaderFilter, WebGl2SpriteRenderer, WebGl2TextRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuComputePipeline, WebGpuMeshRenderer, WebGpuShaderFilter, WebGpuSpriteRenderer, WebGpuStorageBuffer, WebGpuTextRenderer, WorkletFilter, WrapModes, XboxGamepadMapping, XmlAsset, XmlFactory, bezierCurveTo, buildCircle, buildEllipse, buildInfo, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, buildTextPageQuads, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRenderStats, createWebGl2ShaderProgram, crossFade, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, disposeAudioManager, emptyArrayBuffer, getAudioContext, getAudioManager, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, getCollisionEllipseCircle, getCollisionEllipseEllipse, getCollisionEllipseRectangle, getCollisionPolygonCircle, getCollisionRectangleRectangle, getCollisionSat, getDefaultGlyphAtlasPool, getDistance, getOfflineAudioContext, getPreciseTime, getTextureSourceSize, getVoronoiRegion$1 as getVoronoiRegion, getWebGpuBlendState, hours, inRange, intersectionCircleCircle, intersectionCircleEllipse, intersectionCirclePoly, intersectionEllipseEllipse, intersectionEllipsePoly, intersectionLineCircle, intersectionLineEllipse, intersectionLineLine, intersectionLinePoly, intersectionLineRect, intersectionPointCircle, intersectionPointEllipse, intersectionPointLine, intersectionPointPoint, intersectionPointPoly, intersectionPointRect, intersectionPolyPoly, intersectionRectCircle, intersectionRectEllipse, intersectionRectPoly, intersectionRectRect, intersectionSat, isAudioContextReady, isPowerOfTwo, layoutText, lerp, matchesIds, maxPointers, measureText, milliseconds, minutes, noop$1 as noop, onAudioContextReady, parseBmFontText, parseGamepadDescriptor, perfClearMarks, perfClearMeasures, perfMark, perfMeasure, pointerSlotSize, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, registerAudioWorkletProcessor, removeArrayItems, resetDefaultGlyphAtlasPool, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign, stopEvent, substepSweep, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, sweepCircleAgainst, sweepCircleVsCircle, sweepCircleVsRectangle, sweepRectangle, sweepRectangleAgainst, tau, trimRotation, upgradeFragmentShaderToGl300, vibrate, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames };
40945
+ export { AbstractAssetFactory, AbstractMedia, AbstractText, AbstractWebGl2BatchedRenderer, AbstractWebGl2Renderer, AbstractWebGpuRenderer, AnimatedSprite, Application, ApplicationStatus, ArcadeStickGamepadMapping, Asset, AssetImpl, Assets, AssetsImpl, AtlasPage, AudioAnalyser, AudioBus, AudioFilter, AudioListener, AudioManager, BeatDetector, BinaryAsset, BinaryFactory, BitmapText, BlendModes, BlurFilter, BmFont, BmFontAdapter, BmFontLoaderFactory, Bounds, BufferTypes, BufferUsage, BundleLoadError, CacheFirstStrategy, CallbackRenderPass, Camera, Capabilities, ChannelOffset, ChannelSize, ChorusFilter, Circle, Clock, CollisionType, Color, ColorFilter, CompressorFilter, Container, CsvAsset, CsvFactory, DataTexture, DelayFilter, Drawable, DuckingFilter, Ease, Ellipse, Envelope, EqualizerFilter, FactoryRegistry, Filter, Flags, FontAsset, FontFactory, GameCubeGamepadMapping, Gamepad, GamepadAxis, GamepadButton, GamepadMapping, GamepadMappingFamily, GamepadPromptLayouts, GenericDualAnalogGamepadMapping, Geometry, GlyphAtlas, GlyphAtlasPool, Gradient, GranularFilter, Graphics, HTMLText, HighpassFilter, ImageAsset, ImageFactory, IndexedDbDatabase, IndexedDbStore, InputBinding, InputManager, InteractionEvent, InteractionManager, Interval, JoyConLeftGamepadMapping, JoyConRightGamepadMapping, Json, JsonFactory, JsonStore, Keyboard, Line, LinearGradient, Loader, LoadingQueue, LowpassFilter, LutFilter, Material, Matrix, Mesh, MeshMaterial, Music, MusicFactory, NetworkOnlyStrategy, NineSliceSprite, ObservableSize, ObservableVector, OscillatorSound, PitchShiftFilter, PlayStationGamepadMapping, Pointer, PointerState, PointerStateFlag, PolarVector, Polygon, Quadtree, RadialGradient, Random, Rectangle, RenderBackendType, RenderNode, RenderNodePass, RenderPass, RenderPipeline, RenderTarget, RenderTexture, RendererRegistry, RenderingContext, RenderingPrimitives, RepeatingSprite, ReverbFilter, SDF_RADIUS, Sampler, ScaleModes, Scene, SceneManager, SceneNode, Segment, Shader, ShaderAttribute, ShaderPrimitives, ShaderSource, ShaderUniform, Signal, Size, Sound, SoundFactory, SoundPoolStrategy, Sprite, SpriteFlags, SpriteMaterial, Spritesheet, SteamControllerGamepadMapping, SteamDeckGamepadMapping, SubtitleAsset, SubtitleFactory, SvgAsset, SvgFactory, SwitchProGamepadMapping, Text, TextAsset, TextFactory, TextStyle, Texture, TextureFactory, TextureRegion, Time, Timer, Tween, TweenManager, TweenState, Vector, Video, VideoFactory, View, ViewFlags, VocoderFilter, VoronoiRegion, WasmAsset, WasmFactory, WebGl2Backend, WebGl2MeshRenderer, WebGl2NineSliceSpriteRenderer, WebGl2RenderBuffer, WebGl2RepeatingSpriteRenderer, WebGl2ShaderBlock, WebGl2ShaderFilter, WebGl2SpriteRenderer, WebGl2TextRenderer, WebGl2VertexArrayObject, WebGpuBackend, WebGpuComputePipeline, WebGpuMeshRenderer, WebGpuNineSliceSpriteRenderer, WebGpuRepeatingSpriteRenderer, WebGpuShaderFilter, WebGpuSpriteRenderer, WebGpuStorageBuffer, WebGpuTextRenderer, WorkletFilter, WrapModes, XboxGamepadMapping, XmlAsset, XmlFactory, bezierCurveTo, buildCircle, buildEllipse, buildInfo, buildLine, buildPath, buildPolygon, buildRectangle, buildStar, buildTextPageQuads, builtInGamepadDefinitions, canvasSourceToDataUrl, clamp, createRenderStats, createWebGl2ShaderProgram, crossFade, decodeAudioData, defineAssetManifest, degreesPerRadian, degreesToRadians, determineMimeType, disposeAudioManager, emptyArrayBuffer, getAudioContext, getAudioManager, getCanvasSourceSize, getCollisionCircleCircle, getCollisionCircleRectangle, getCollisionEllipseCircle, getCollisionEllipseEllipse, getCollisionEllipseRectangle, getCollisionPolygonCircle, getCollisionRectangleRectangle, getCollisionSat, getDefaultGlyphAtlasPool, getDistance, getOfflineAudioContext, getPreciseTime, getTextureSourceSize, getVoronoiRegion$1 as getVoronoiRegion, getWebGpuBlendState, hours, inRange, intersectionCircleCircle, intersectionCircleEllipse, intersectionCirclePoly, intersectionEllipseEllipse, intersectionEllipsePoly, intersectionLineCircle, intersectionLineEllipse, intersectionLineLine, intersectionLinePoly, intersectionLineRect, intersectionPointCircle, intersectionPointEllipse, intersectionPointLine, intersectionPointPoint, intersectionPointPoly, intersectionPointRect, intersectionPolyPoly, intersectionRectCircle, intersectionRectEllipse, intersectionRectPoly, intersectionRectRect, intersectionSat, isAudioContextReady, isPowerOfTwo, layoutText, lerp, matchesIds, maxPointers, measureText, milliseconds, minutes, noop$1 as noop, onAudioContextReady, parseBmFontText, parseGamepadDescriptor, perfClearMarks, perfClearMeasures, perfMark, perfMeasure, planRepeat, pointerSlotSize, quadraticCurveTo, radiansPerDegree, radiansToDegrees, rand, registerAudioWorkletProcessor, removeArrayItems, resetDefaultGlyphAtlasPool, resetRenderStats, resolveDefinition, resolveGamepadDefinition, seconds, sign, stopEvent, substepSweep, supportsCodec, supportsEventOptions, supportsIndexedDb, supportsPointerEvents, supportsTouchEvents, supportsWebAudio, sweepCircleAgainst, sweepCircleVsCircle, sweepCircleVsRectangle, sweepRectangle, sweepRectangleAgainst, tau, trimRotation, upgradeFragmentShaderToGl300, vibrate, webGl2PrimitiveArrayConstructors, webGl2PrimitiveByteSizeMapping, webGl2PrimitiveTypeNames };
37556
40946
  //# sourceMappingURL=exo.esm.js.map