diff --git a/examples/lib/stories/sprites/sprite_batch_bleed_example.dart b/examples/lib/stories/sprites/sprite_batch_bleed_example.dart new file mode 100644 index 00000000000..0c20a698960 --- /dev/null +++ b/examples/lib/stories/sprites/sprite_batch_bleed_example.dart @@ -0,0 +1,194 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/sprite.dart'; +import 'package:flutter/material.dart'; + +class SpriteBatchBleedExample extends FlameGame { + static const String description = ''' + In this example we show how `bleed` can be used to prevent edge artifacts + (seams) between tiles when rendering with `SpriteBatch`. + + The top rows show tiles rendered without bleed, where slight gaps or + color bleeding may appear at the edges. + + The bottom rows show the same tiles rendered with bleed, which expands + the destination rectangle outward while keeping the source region the + same, eliminating edge artifacts. + + The rotated tiles on the right demonstrate that bleed works correctly + even with rotation, preserving the center point. + '''; + + static const tileSize = 32.0; + static const scale = 3.0; + static const scaledTile = tileSize * scale; + static const bleed = 1.0; + + @override + Future onLoad() async { + final spriteBatch = await SpriteBatch.load('retro_tiles.png'); + + const tile1 = Rect.fromLTWH(0, 0, tileSize, tileSize); + const tile2 = Rect.fromLTWH(tileSize, 0, tileSize, tileSize); + + // --- Section 1: Without bleed (top) --- + const startYNoBleed = 40.0; + const startX = 40.0; + const gap = 16.0; + const startXRotated = startX + scaledTile * 6 + gap * 2; + + _addTileRow( + spriteBatch, + startX: startX, + startY: startYNoBleed, + tile1: tile1, + tile2: tile2, + bleed: 0, + ); + + _addTileRow( + spriteBatch, + startX: startX, + startY: startYNoBleed + scaledTile + gap, + tile1: tile2, + tile2: tile1, + bleed: 0, + ); + + // --- Section 2: With bleed (bottom) --- + const startYBleed = startYNoBleed + (scaledTile + gap) * 2 + gap * 2; + + _addTileRow( + spriteBatch, + startX: startX, + startY: startYBleed, + tile1: tile1, + tile2: tile2, + bleed: bleed, + ); + + _addTileRow( + spriteBatch, + startX: startX, + startY: startYBleed + scaledTile + gap, + tile1: tile2, + tile2: tile1, + bleed: bleed, + ); + + // --- Section 3: Rotated tiles with bleed (right side) --- + final centerY = size.y / 2; + + // Without bleed, rotated + spriteBatch.add( + source: tile1, + offset: Vector2(startXRotated, centerY - scaledTile - gap), + scale: scale, + rotation: pi / 6, + anchor: Vector2(tileSize / 2, tileSize / 2), + ); + + spriteBatch.add( + source: tile2, + offset: Vector2( + startXRotated + scaledTile + gap, + centerY - scaledTile - gap, + ), + scale: scale, + rotation: pi / 6, + anchor: Vector2(tileSize / 2, tileSize / 2), + ); + + // With bleed, rotated + spriteBatch.add( + source: tile1, + offset: Vector2(startXRotated, centerY + gap), + scale: scale, + rotation: pi / 6, + anchor: Vector2(tileSize / 2, tileSize / 2), + bleed: bleed, + ); + + spriteBatch.add( + source: tile2, + offset: Vector2( + startXRotated + scaledTile + gap, + centerY + gap, + ), + scale: scale, + rotation: pi / 6, + anchor: Vector2(tileSize / 2, tileSize / 2), + bleed: bleed, + ); + + add( + SpriteBatchComponent( + spriteBatch: spriteBatch, + blendMode: BlendMode.srcOver, + ), + ); + + // Add labels + add( + TextComponent( + text: 'Without bleed', + position: Vector2(startX, startYNoBleed - 20), + textRenderer: TextPaint( + style: const TextStyle( + color: Colors.red, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + + add( + TextComponent( + text: 'With bleed (bleed=$bleed)', + position: Vector2(startX, startYBleed - 20), + textRenderer: TextPaint( + style: const TextStyle( + color: Colors.green, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + + add( + TextComponent( + text: 'Rotated', + position: Vector2(startXRotated, 20), + textRenderer: TextPaint( + style: const TextStyle( + color: Colors.orange, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + + void _addTileRow( + SpriteBatch batch, { + required double startX, + required double startY, + required Rect tile1, + required Rect tile2, + required double bleed, + }) { + for (var i = 0; i < 6; i++) { + batch.add( + source: i.isEven ? tile1 : tile2, + offset: Vector2(startX + i * scaledTile, startY), + scale: scale, + bleed: bleed, + ); + } + } +} diff --git a/examples/lib/stories/sprites/sprites.dart b/examples/lib/stories/sprites/sprites.dart index e97027a16c0..ba7ea41c307 100644 --- a/examples/lib/stories/sprites/sprites.dart +++ b/examples/lib/stories/sprites/sprites.dart @@ -2,6 +2,7 @@ import 'package:dashbook/dashbook.dart'; import 'package:examples/commons/commons.dart'; import 'package:examples/stories/sprites/base64_sprite_example.dart'; import 'package:examples/stories/sprites/basic_sprite_example.dart'; +import 'package:examples/stories/sprites/sprite_batch_bleed_example.dart'; import 'package:examples/stories/sprites/sprite_batch_example.dart'; import 'package:examples/stories/sprites/sprite_batch_load_example.dart'; import 'package:examples/stories/sprites/sprite_group_example.dart'; @@ -40,6 +41,12 @@ void addSpritesStories(Dashbook dashbook) { codeLink: baseLink('sprites/sprite_batch_load_example.dart'), info: SpriteBatchLoadExample.description, ) + ..add( + 'SpriteBatch Bleed', + (_) => GameWidget(game: SpriteBatchBleedExample()), + codeLink: baseLink('sprites/sprite_batch_bleed_example.dart'), + info: SpriteBatchBleedExample.description, + ) ..add( 'SpriteGroup', (_) => GameWidget(game: SpriteGroupExample()), diff --git a/packages/flame/CHANGELOG.md b/packages/flame/CHANGELOG.md index 8be5ca2858f..91bede3b0af 100644 --- a/packages/flame/CHANGELOG.md +++ b/packages/flame/CHANGELOG.md @@ -1,5 +1,6 @@ ## 1.37.0 + - **FEAT**: Add `bleed` option to `SpriteBatch` to prevent seam artifacts in tilemaps ([#3871](https://github.com/flame-engine/flame/issues/3871)). - **FIX**: Use proper hash combining in CollisionProspect to fix flaky test ([#3864](https://github.com/flame-engine/flame/issues/3864)). ([bff137e5](https://github.com/flame-engine/flame/commit/bff137e5c1c97ae98e867a933f6790aeb349f90f)) - **FIX**: Remove async from flame test helpers ([#3860](https://github.com/flame-engine/flame/issues/3860)). ([4e63e93e](https://github.com/flame-engine/flame/commit/4e63e93eb78d5e6e3c48e0cc02577bf2581b0e87)) - **FEAT**: Add OverlayManager.setActive() ([#3875](https://github.com/flame-engine/flame/issues/3875)). ([86495694](https://github.com/flame-engine/flame/commit/86495694665cc4e85f7d3a94b05766cc6f6b95ba)) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index 2569b04176b..99126c4afd1 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -1,5 +1,5 @@ import 'dart:collection'; -import 'dart:math' show pi; +import 'dart:math' show max, pi; import 'dart:ui'; import 'package:flame/cache.dart'; @@ -41,16 +41,28 @@ class BatchItem { required this.transform, Color? color, this.flip = false, - }) : color = color ?? const Color(0x00000000), + this.bleed = 0, + }) : assert(bleed >= 0, 'Bleed must be non-negative'), + color = color ?? const Color(0x00000000), paint = Paint()..color = color ?? const Color(0x00000000), - destination = Offset.zero & source.size; + destination = bleed > 0 + ? Rect.fromLTWH( + -bleed, + -bleed, + source.width + bleed * 2, + source.height + bleed * 2, + ) + : Offset.zero & source.size; /// The source rectangle on the [SpriteBatch.atlas]. Rect source; - /// The destination rectangle for the Canvas. + /// The destination rectangle for the Canvas, used in the non-atlas rendering + /// path. /// - /// It will be transformed by [matrix]. + /// It will be transformed by [matrix]. When [bleed] is greater than zero, + /// this rect extends [bleed] pixels in each direction beyond the source + /// size to prevent edge artifacts in the non-atlas rendering path. Rect destination; /// The transform values for this batch item. @@ -59,6 +71,19 @@ class BatchItem { /// The flip value for this batch item. bool flip; + /// The bleed value for this batch item in pixels. + /// + /// When greater than 0, the rendered sprite is expanded outward by this + /// amount while keeping the source sampling region the same, which helps + /// prevent edge artifacts (seams between tiles in a tilemap). + /// + /// Note: the atlas rendering path ([Canvas.drawAtlas]) applies a uniform + /// scale derived from `max(bleedScaleX, bleedScaleY)` to preserve rotation. + /// For non-square source rects this means the shorter axis is scaled + /// slightly more than the requested [bleed]. The non-atlas path always + /// expands by exactly [bleed] pixels on every side. + double bleed; + /// The color of the batch item (used for building the drawAtlas color list). Color color; @@ -66,28 +91,31 @@ class BatchItem { /// /// Since [Canvas.drawAtlas] is not supported on the web we also /// build a `Matrix4` based on the [transform] and [flip] values. - late Matrix4 matrix = - Matrix4( - transform.scos, - transform.ssin, - 0, - 0, // - -transform.ssin, - transform.scos, - 0, - 0, // - 0, - 0, - 0, - 0, // - transform.tx, - transform.ty, - 0, - 1, // - ) - ..translateByDouble(source.width / 2, source.height / 2, 1, 1) - ..rotateY(flip ? pi : 0) - ..translateByDouble(-source.width / 2, -source.height / 2, 1, 1); + /// Recomputed lazily; call [_invalidateMatrix] after mutating [transform] or + /// [source]. + Matrix4? _cachedMatrix; + + Matrix4 get matrix { + final cached = _cachedMatrix; + if (cached != null) { + return cached; + } + // dart format off + final result = Matrix4( + transform.scos, transform.ssin, 0, 0, + -transform.ssin, transform.scos, 0, 0, + 0, 0, 0, 0, + transform.tx, transform.ty, 0, 1, + ); + // dart format on + result + ..translateByDouble(source.width / 2, source.height / 2, 1, 1) + ..rotateY(flip ? pi : 0) + ..translateByDouble(-source.width / 2, -source.height / 2, 1, 1); + return _cachedMatrix = result; + } + + void _invalidateMatrix() => _cachedMatrix = null; /// Paint object used for the web. Paint paint; @@ -305,6 +333,51 @@ class SpriteBatch { ); } + /// Computes a transform with bleed applied. + /// + /// The bleed expands the destination rectangle outward by the bleed amount + /// in all directions while keeping the source sampling region unchanged. + /// This helps prevent edge artifacts (seams between tiles in a tilemap). + /// + /// A uniform scale factor derived from `max(bleedScaleX, bleedScaleY)` is + /// used so that rotation is preserved. For non-square [source] rects this + /// means the shorter axis is scaled slightly beyond the requested [bleed]. + static RSTransform _computeBleedTransform( + RSTransform transform, + Rect source, + double bleed, + ) { + if (bleed <= 0) { + return transform; + } + + if (source.width <= 0 || source.height <= 0) { + return transform; + } + + // Scale factors for width and height with bleed; use max for uniform scale + // to preserve rotation when width != height. + final scaleX = (source.width + bleed * 2) / source.width; + final scaleY = (source.height + bleed * 2) / source.height; + final scale = max(scaleX, scaleY); + + final scos = transform.scos * scale; + final ssin = transform.ssin * scale; + + // Compute the local delta needed to keep the center fixed after scaling. + final localDx = -(scale - 1) * source.width / 2; + final localDy = -(scale - 1) * source.height / 2; + + // Transform the local delta to world space using the existing + // scale+rotation. + final tx = + transform.tx + transform.scos * localDx - transform.ssin * localDy; + final ty = + transform.ty + transform.ssin * localDx + transform.scos * localDy; + + return RSTransform(scos, ssin, tx, ty); + } + /// Ensures that the given [handle] exists and returns its slot. int _requireSlot(int handle) { final slot = _handleToSlot[handle]; @@ -316,6 +389,10 @@ class SpriteBatch { /// Replaces the parameters of the batch item at the given [index]. /// At least one of the parameters must be different from null. + /// + /// Note: the `bleed` value of a batch item cannot be changed after creation. + /// To use a different bleed value, remove the item with [removeAt] and add a + /// new one with [add]. void replace( int index, { Rect? source, @@ -330,15 +407,36 @@ class SpriteBatch { final slot = _requireSlot(index); final currentBatchItem = _batchItems[slot]; - currentBatchItem.source = source ?? currentBatchItem.source; - currentBatchItem.transform = transform ?? currentBatchItem.transform; + if (source != null) { + currentBatchItem.source = source; + final bleed = currentBatchItem.bleed; + currentBatchItem.destination = bleed > 0 + ? Rect.fromLTWH( + -bleed, + -bleed, + source.width + bleed * 2, + source.height + bleed * 2, + ) + : Offset.zero & source.size; + currentBatchItem._invalidateMatrix(); + } + if (transform != null) { + currentBatchItem.transform = transform; + currentBatchItem._invalidateMatrix(); + } if (color != null) { currentBatchItem.color = color; currentBatchItem.paint.color = color; } _sources[slot] = _resolveSourceForAtlas(currentBatchItem); - _transforms[slot] = currentBatchItem.transform; + + // Apply bleed to the updated transform + _transforms[slot] = _computeBleedTransform( + currentBatchItem.transform, + currentBatchItem.source, + currentBatchItem.bleed, + ); // If color is not explicitly provided, store transparent. _colors[slot] = color ?? _defaultColor; @@ -360,6 +458,15 @@ class SpriteBatch { /// The [color] parameter allows you to render a color behind the batch item, /// as a background color. /// + /// The [bleed] parameter expands the rendered sprite outward by this amount + /// in all directions while keeping the source sampling region the same. This + /// helps prevent edge artifacts (seams between tiles in a tilemap). For best + /// results, the atlas should have padding between sprites. + /// + /// Note: when [useAtlas] is true, a uniform scale is applied (see + /// [BatchItem.bleed]). For non-square sources the shorter axis is scaled + /// slightly more than the exact [bleed] value. + /// /// The [add] method may be a simpler way to add a batch item to the batch. /// However, if there is a way to factor out the computations of the sine and /// cosine of the rotation so that they can be reused over multiple calls to @@ -370,6 +477,7 @@ class SpriteBatch { RSTransform? transform, bool flip = false, Color? color, + double bleed = 0, }) { final handle = _allocateHandle(); @@ -378,6 +486,7 @@ class SpriteBatch { transform: transform ??= defaultTransform ?? RSTransform(1, 0, 0, 0), flip: flip, color: color ?? defaultColor, + bleed: bleed, ); if (flip && useAtlas && _flippedAtlasStatus.isNone) { @@ -391,7 +500,14 @@ class SpriteBatch { _batchItems.add(batchItem); _sources.add(_resolveSourceForAtlas(batchItem)); - _transforms.add(batchItem.transform); + + // Apply bleed to the transform + final bleedTransform = _computeBleedTransform( + batchItem.transform, + batchItem.source, + batchItem.bleed, + ); + _transforms.add(bleedTransform); // If color is not explicitly provided, store transparent. _colors.add(color ?? _defaultColor); @@ -410,6 +526,15 @@ class SpriteBatch { /// The [color] parameter allows you to render a color behind the batch item, /// as a background color. /// + /// The [bleed] parameter expands the rendered sprite outward by this amount + /// in all directions while keeping the source sampling region the same. This + /// helps prevent edge artifacts (seams between tiles in a tilemap). For best + /// results, the atlas should have padding between sprites. + /// + /// Note: when [useAtlas] is true, a uniform scale is applied (see + /// [BatchItem.bleed]). For non-square sources the shorter axis is scaled + /// slightly more than the exact [bleed] value. + /// /// This method creates a new [RSTransform] based on the given transform /// arguments. If many [RSTransform] objects are being created and there is a /// way to factor out the computations of the sine and cosine of the rotation @@ -425,6 +550,7 @@ class SpriteBatch { Vector2? offset, bool flip = false, Color? color, + double bleed = 0, }) { anchor ??= Vector2.zero(); offset ??= Vector2.zero(); @@ -452,6 +578,7 @@ class SpriteBatch { transform: transform, flip: flip, color: color, + bleed: bleed, ); } @@ -530,10 +657,14 @@ class SpriteBatch { for (final batchItem in _batchItems) { renderPaint.blendMode = blendMode ?? renderPaint.blendMode; + // Use the original (non-expanded) rect for the background color so + // that per-item colors match the atlas path behavior. + final colorRect = Offset.zero & batchItem.source.size; + canvas ..save() ..transform32(batchItem.matrix.storage) - ..drawRect(batchItem.destination, batchItem.paint) + ..drawRect(colorRect, batchItem.paint) ..drawImageRect( atlas, batchItem.source, diff --git a/packages/flame/test/_goldens/sprite_batch_test_3.png b/packages/flame/test/_goldens/sprite_batch_test_3.png new file mode 100644 index 00000000000..981cd23f57f Binary files /dev/null and b/packages/flame/test/_goldens/sprite_batch_test_3.png differ diff --git a/packages/flame/test/sprite_batch_test.dart b/packages/flame/test/sprite_batch_test.dart index 0910bd16208..5d23b3d7875 100644 --- a/packages/flame/test/sprite_batch_test.dart +++ b/packages/flame/test/sprite_batch_test.dart @@ -66,6 +66,239 @@ void main() { ); }); + test('negative bleed throws an assertion error', () { + final image = _MockImage(); + final spriteBatch = SpriteBatch(image); + expect( + () => spriteBatch.add(source: Rect.zero, bleed: -1), + throwsA(isA()), + ); + }); + + test('can add batch item with bleed', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + const source = Rect.fromLTWH(0, 0, 10, 10); + const bleed = 2.0; + + final index = spriteBatch.add( + source: source, + bleed: bleed, + ); + final batchItem = spriteBatch.getBatchItem(index); + + expect(batchItem.bleed, bleed); + }); + + test('bleed expands destination for non-atlas path', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + const source = Rect.fromLTWH(0, 0, 10, 10); + const bleed = 2.0; + + final index = spriteBatch.add(source: source, bleed: bleed); + final batchItem = spriteBatch.getBatchItem(index); + + expect( + batchItem.destination, + const Rect.fromLTWH(-bleed, -bleed, 14, 14), + ); + }); + + test('zero bleed keeps destination at source size', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + const source = Rect.fromLTWH(0, 0, 10, 10); + + final index = spriteBatch.add(source: source); + final batchItem = spriteBatch.getBatchItem(index); + + expect(batchItem.destination, const Rect.fromLTWH(0, 0, 10, 10)); + }); + + test('replace updates destination when source changes with bleed', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + const bleed = 2.0; + + final index = spriteBatch.add( + source: const Rect.fromLTWH(0, 0, 10, 10), + bleed: bleed, + ); + + spriteBatch.replace(index, source: const Rect.fromLTWH(0, 0, 20, 20)); + final batchItem = spriteBatch.getBatchItem(index); + + expect( + batchItem.destination, + const Rect.fromLTWH(-bleed, -bleed, 24, 24), + ); + }); + + test('bleed scales the transform correctly', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + const source = Rect.fromLTWH(0, 0, 10, 10); + const bleed = 1.0; + // Expected scale: (10 + 2*1) / 10 = 1.2 + const expectedScale = 1.2; + + spriteBatch.add( + source: source, + bleed: bleed, + ); + + // The stored transform should be scaled by the bleed factor + final storedTransform = spriteBatch.transforms.first; + expect(storedTransform.scos, closeTo(expectedScale, 0.001)); + expect(storedTransform.ssin, closeTo(0.0, 0.001)); + }); + + test('bleed is preserved when replacing transform', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + const source = Rect.fromLTWH(0, 0, 10, 10); + const bleed = 2.0; + + final index = spriteBatch.add( + source: source, + bleed: bleed, + ); + + // Replace the transform - bleed should be re-applied + spriteBatch.replace(index, transform: RSTransform(2, 0, 5, 5)); + + final batchItem = spriteBatch.getBatchItem(index); + expect(batchItem.bleed, bleed); + + // The new transform should have bleed applied + // Original: scos=2, with bleed scale 1.4 -> scos=2.8 + const expectedScale = (10 + 2 * bleed) / 10; // 1.4 + final storedTransform = spriteBatch.transforms.first; + expect(storedTransform.scos, closeTo(2 * expectedScale, 0.001)); + }); + + test('zero bleed does not affect transform', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + const source = Rect.fromLTWH(0, 0, 10, 10); + + spriteBatch.add(source: source); + + final storedTransform = spriteBatch.transforms.first; + expect(storedTransform.scos, closeTo(1.0, 0.001)); + expect(storedTransform.ssin, closeTo(0.0, 0.001)); + expect(storedTransform.tx, closeTo(0.0, 0.001)); + expect(storedTransform.ty, closeTo(0.0, 0.001)); + }); + + test('bleed on non-square sprite uses max scale for atlas path', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + // 10x20 sprite: scaleX=(12/10)=1.2, scaleY=(22/20)=1.1 -> scale=1.2 + const source = Rect.fromLTWH(0, 0, 10, 20); + const bleed = 1.0; + + spriteBatch.add(source: source, bleed: bleed); + + final storedTransform = spriteBatch.transforms.first; + // Atlas path uses max(scaleX, scaleY) = 1.2 uniformly + expect(storedTransform.scos, closeTo(1.2, 0.001)); + }); + + test('bleed on non-square sprite expands destination exactly', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + const source = Rect.fromLTWH(0, 0, 10, 20); + const bleed = 1.0; + + final index = spriteBatch.add(source: source, bleed: bleed); + final batchItem = spriteBatch.getBatchItem(index); + + // Non-atlas path always expands by exactly bleed on every side + expect( + batchItem.destination, + const Rect.fromLTWH(-bleed, -bleed, 12, 22), + ); + }); + + test('bleed with zero-size source does not corrupt transform', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + const source = Rect.zero; + + spriteBatch.add(source: source, bleed: 1); + + final storedTransform = spriteBatch.transforms.first; + expect(storedTransform.scos.isFinite, isTrue); + expect(storedTransform.ssin.isFinite, isTrue); + }); + + test('replace updates matrix when transform changes', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + const source = Rect.fromLTWH(0, 0, 10, 10); + + final index = spriteBatch.add(source: source); + final batchItem = spriteBatch.getBatchItem(index); + + // Read the matrix to trigger lazy initialization with original transform. + final matrixBefore = batchItem.matrix; + expect(matrixBefore.storage[12], closeTo(0.0, 0.001)); // tx + + spriteBatch.replace(index, transform: RSTransform(1, 0, 50, 60)); + + // After replace the matrix must reflect the new transform. + final matrixAfter = batchItem.matrix; + expect(matrixAfter.storage[12], closeTo(50.0, 0.001)); // tx + expect(matrixAfter.storage[13], closeTo(60.0, 0.001)); // ty + }); + + test('replace updates matrix when source changes', () { + final image = _MockImage(); + when(() => image.width).thenReturn(100); + when(() => image.height).thenReturn(100); + final spriteBatch = SpriteBatch(image); + + final index = spriteBatch.add(source: const Rect.fromLTWH(0, 0, 10, 10)); + final batchItem = spriteBatch.getBatchItem(index); + + // Access matrix to initialize it with source width/height = 10. + final _ = batchItem.matrix; + + // Replace with a wider source; the flip pivot must update. + spriteBatch.replace( + index, + source: const Rect.fromLTWH(0, 0, 20, 20), + ); + + // The matrix is recomputed using the new source size. + // A simple sanity check: accessing matrix must not throw. + expect(() => batchItem.matrix, returnsNormally); + }); + const margin = 2.0; const tileSize = 6.0; @@ -128,5 +361,41 @@ void main() { backgroundColor: const Color(0xFFFFFFFF), goldenFile: '_goldens/sprite_batch_test_2.png', ); + + testGolden( + 'can render a batch with bleed', + (game, tester) async { + final spriteSheet = await loadImage('alphabet.png'); + final spriteBatch = SpriteBatch(spriteSheet); + + // Source is a single tile - we want to see if bleed expands the render + const source = Rect.fromLTWH(3 * tileSize, 0, tileSize, tileSize); + const bleed = 1.0; + + // Add sprite without bleed (left) + spriteBatch.add( + source: source, + offset: Vector2.all(margin), + scale: 2.0, + ); + + // Add sprite with bleed (right) - should appear slightly larger + spriteBatch.add( + source: source, + offset: Vector2(2 * margin + tileSize * 2 + 4, margin), + scale: 2.0, + bleed: bleed, + ); + + game.add( + SpriteBatchComponent( + spriteBatch: spriteBatch, + ), + ); + }, + size: Vector2(4 * margin + 4 * tileSize + 4, 3 * margin + 2 * tileSize), + backgroundColor: const Color(0xFFFFFFFF), + goldenFile: '_goldens/sprite_batch_test_3.png', + ); }); }