From 3babca93e6ca2584737fbf62587f018a57765f1c Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Sun, 20 Jul 2025 21:30:58 -0600 Subject: [PATCH 01/24] feat: Add optional id to BatchItem and methods for managing items by id in SpriteBatch --- packages/flame/lib/src/sprite_batch.dart | 102 +++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index 3919442ece6..d9c0400961d 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -37,11 +37,15 @@ class BatchItem { BatchItem({ required this.source, required this.transform, + this.id, Color? color, this.flip = false, }) : paint = Paint()..color = color ?? const Color(0x00000000), destination = Offset.zero & source.size; + /// Optional identifier for the batch item. + final String? id; + /// The source rectangle on the [SpriteBatch.atlas]. final Rect source; @@ -144,6 +148,13 @@ class SpriteBatch { FlippedAtlasStatus _flippedAtlasStatus = FlippedAtlasStatus.none; + /// A map to keep track of the index of each batch item by its id. + final Map _idToIndex = {}; + + /// Returns all ids currently in the batch (excluding nulls). + Iterable get ids => + _batchItems.where((item) => item.id != null).map((item) => item.id!); + /// List of all the existing batch items. final _batchItems = []; @@ -300,8 +311,10 @@ class SpriteBatch { RSTransform? transform, bool flip = false, Color? color, + String? id, }) { final batchItem = BatchItem( + id: id, source: source, transform: transform ??= defaultTransform ?? RSTransform(1, 0, 0, 0), flip: flip, @@ -385,12 +398,101 @@ class SpriteBatch { ); } + /// Finds the index of the batch item with the given [id]. + int? findIndexById(String id) { + if (_idToIndex.containsKey(id)) { + return _idToIndex[id]; + } + for (var i = 0; i < _batchItems.length; i++) { + if (_batchItems[i].id == id) { + _idToIndex[id] = i; // repair mapping + return i; + } + } + return null; + } + + /// Removes a batch item by its [id]. + void removeById(String id) { + final index = _idToIndex[id]; + if (index == null) { + return; + } + + removeAt(index); + _idToIndex.remove(id); + + // adjust indices > removed index + _idToIndex.updateAll((key, idx) => idx > index ? idx - 1 : idx); + } + + /// Removes a batch item at the given [index]. + void removeAt(int index) { + if (index < 0 || index >= length) { + throw ArgumentError('Index out of bounds: $index'); + } + + _batchItems.removeAt(index); + _sources.removeAt(index); + _transforms.removeAt(index); + _colors.removeAt(index); + } + + /// Adds a new batch item with the given [id]. + int addWithId( + String id, { + required Rect source, + RSTransform? transform, + bool flip = false, + Color? color, + }) { + final idx = findIndexById(id); + if (idx != null) { + replace(idx, source: source, transform: transform, color: color); + return idx; + } + + final item = BatchItem( + id: id, + source: source, + transform: transform ?? defaultTransform ?? RSTransform(1, 0, 0, 0), + flip: flip, + color: color ?? defaultColor, + ); + + _batchItems.add(item); + _sources.add(item.source); + _transforms.add(item.transform); + _colors.add(color ?? _defaultColor); + + final newIdx = _batchItems.length - 1; + _idToIndex[id] = newIdx; + + return newIdx; + } + + /// Replaces the batch item identified by [id] with new data. + + void replaceById( + String id, { + Rect? source, + Color? color, + RSTransform? transform, + }) { + final index = _idToIndex[id]; + if (index == null) { + throw ArgumentError('No BatchItem found with id: $id'); + } + replace(index, source: source, color: color, transform: transform); + } + /// Clear the SpriteBatch so it can be reused. void clear() { _sources.clear(); _transforms.clear(); _colors.clear(); _batchItems.clear(); + _idToIndex.clear(); } // Used to not create new Paint objects in [render] and From c474ad1ac2cc6e0d7a3580a4db741a8301d3cbff Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Mon, 21 Jul 2025 09:46:34 -0600 Subject: [PATCH 02/24] refactor: Simplify logic for adding and replacing BatchItems --- packages/flame/lib/src/sprite_batch.dart | 66 ++++++------------------ 1 file changed, 17 insertions(+), 49 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index d9c0400961d..3fa5a30abfd 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -263,6 +263,7 @@ class SpriteBatch { /// At least one of the parameters must be different from null. void replace( int index, { + String? id, Rect? source, Color? color, RSTransform? transform, @@ -278,6 +279,7 @@ class SpriteBatch { final currentBatchItem = _batchItems[index]; final newBatchItem = BatchItem( + id: id ?? currentBatchItem.id, source: source ?? currentBatchItem.source, transform: transform ?? currentBatchItem.transform, color: color ?? currentBatchItem.paint.color, @@ -285,10 +287,15 @@ class SpriteBatch { ); _batchItems[index] = newBatchItem; - _sources[index] = newBatchItem.source; _transforms[index] = newBatchItem.transform; _colors[index] = color ?? _defaultColor; + + if (id == null) { + return; + } + + _idToIndex[id] = index; } /// Add a new batch item using a RSTransform. @@ -340,6 +347,13 @@ class SpriteBatch { ); _transforms.add(batchItem.transform); _colors.add(color ?? _defaultColor); + + if (id == null) { + return; + } + + final newIdx = _batchItems.length - 1; + _idToIndex[id] = newIdx; } /// Add a new batch item. @@ -362,6 +376,7 @@ class SpriteBatch { /// method instead. void add({ required Rect source, + String? id, double scale = 1.0, Vector2? anchor, double rotation = 0, @@ -395,6 +410,7 @@ class SpriteBatch { transform: transform, flip: flip, color: color, + id: id, ); } @@ -438,54 +454,6 @@ class SpriteBatch { _colors.removeAt(index); } - /// Adds a new batch item with the given [id]. - int addWithId( - String id, { - required Rect source, - RSTransform? transform, - bool flip = false, - Color? color, - }) { - final idx = findIndexById(id); - if (idx != null) { - replace(idx, source: source, transform: transform, color: color); - return idx; - } - - final item = BatchItem( - id: id, - source: source, - transform: transform ?? defaultTransform ?? RSTransform(1, 0, 0, 0), - flip: flip, - color: color ?? defaultColor, - ); - - _batchItems.add(item); - _sources.add(item.source); - _transforms.add(item.transform); - _colors.add(color ?? _defaultColor); - - final newIdx = _batchItems.length - 1; - _idToIndex[id] = newIdx; - - return newIdx; - } - - /// Replaces the batch item identified by [id] with new data. - - void replaceById( - String id, { - Rect? source, - Color? color, - RSTransform? transform, - }) { - final index = _idToIndex[id]; - if (index == null) { - throw ArgumentError('No BatchItem found with id: $id'); - } - replace(index, source: source, color: color, transform: transform); - } - /// Clear the SpriteBatch so it can be reused. void clear() { _sources.clear(); From 15942a562b5c25d0190d91b2fba362cb907acb61 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Mon, 21 Jul 2025 09:46:55 -0600 Subject: [PATCH 03/24] feat: Add tests for new BatchItem ID management functionality --- packages/flame/test/sprite_batch_test.dart | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/flame/test/sprite_batch_test.dart b/packages/flame/test/sprite_batch_test.dart index 84ec3ee606f..3ce670d299b 100644 --- a/packages/flame/test/sprite_batch_test.dart +++ b/packages/flame/test/sprite_batch_test.dart @@ -61,6 +61,33 @@ void main() { ); }); + test('can add a batch item with an id', () { + final image = _MockImage(); + final spriteBatch = SpriteBatch(image); + spriteBatch.add(source: Rect.zero, id: 'item1'); + + final batchItem = spriteBatch.findIndexById('item1'); + + expect(batchItem, isNotNull); + }); + + test('can replace a batch item with an id', () { + final image = _MockImage(); + final spriteBatch = SpriteBatch(image); + spriteBatch.add(source: Rect.zero, id: 'item1'); + + spriteBatch.replace( + spriteBatch.findIndexById('item1')!, + source: const Rect.fromLTWH(1, 1, 1, 1), + id: 'item2', + ); + + final batchItem = spriteBatch.findIndexById('item2'); + + expect(batchItem, isNotNull); + expect(spriteBatch.sources.first, const Rect.fromLTWH(1, 1, 1, 1)); + }); + const margin = 2.0; const tileSize = 6.0; From ff42f759c14e2ed9ac30f9b7c116eeceec88f6eb Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Mon, 21 Jul 2025 09:52:51 -0600 Subject: [PATCH 04/24] docs: Add documentation about ID management in SpriteBatch section of Images.md --- doc/flame/rendering/images.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/doc/flame/rendering/images.md b/doc/flame/rendering/images.md index 6135c6c7107..3b43c2857b5 100644 --- a/doc/flame/rendering/images.md +++ b/doc/flame/rendering/images.md @@ -316,6 +316,11 @@ A `SpriteBatchComponent` is also available for your convenience. See how to use it in the [SpriteBatch examples](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/sprites/sprite_batch_example.dart) +When using a SpriteBatch to render animations, it's helpful to set a unique ID of the `BatchItem` +related to the frame of your animation to make replacing and removing frames more reliable. When +replacing a `BatchItem`, you can use the `findIndexById` to retrieve the associated index +for replacement. + ## ImageComposition From a1afe6dd09d021cb20fc91321af22a6ec4530da3 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Tue, 22 Jul 2025 08:18:03 -0600 Subject: [PATCH 05/24] perf: Remove redundant lookup --- packages/flame/lib/src/sprite_batch.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index 3fa5a30abfd..cb7eedd0c25 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -152,8 +152,7 @@ class SpriteBatch { final Map _idToIndex = {}; /// Returns all ids currently in the batch (excluding nulls). - Iterable get ids => - _batchItems.where((item) => item.id != null).map((item) => item.id!); + Iterable get ids => _batchItems.map((item) => item.id!); /// List of all the existing batch items. final _batchItems = []; From 1804a888de5aa0002d22e01bc2fd6d9fd626f5eb Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Sun, 27 Jul 2025 11:32:35 -0600 Subject: [PATCH 06/24] fix: Add suggested code change to get keys from _idToIndex map keys --- packages/flame/lib/src/sprite_batch.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index cb7eedd0c25..c2c24ab9696 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -151,8 +151,8 @@ class SpriteBatch { /// A map to keep track of the index of each batch item by its id. final Map _idToIndex = {}; - /// Returns all ids currently in the batch (excluding nulls). - Iterable get ids => _batchItems.map((item) => item.id!); + /// Returns all current ids + Iterable get ids => _idToIndex.keys; /// List of all the existing batch items. final _batchItems = []; From 5c2752c350fef1293976a6d7b62db0572e7dbee2 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Sun, 27 Jul 2025 12:54:51 -0600 Subject: [PATCH 07/24] fix: Add Free List Strategy for managing indices to prevent race conditions and improve performance --- packages/flame/lib/src/sprite_batch.dart | 193 +++++++++++++---------- 1 file changed, 112 insertions(+), 81 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index c2c24ab9696..9026197b7e8 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -148,43 +148,83 @@ class SpriteBatch { FlippedAtlasStatus _flippedAtlasStatus = FlippedAtlasStatus.none; - /// A map to keep track of the index of each batch item by its id. + /// Stack of available (freed) indices using ListQueue as a stack. + final Queue _freeIndices = Queue(); + + /// Returns the total number of indices that have been allocated. + int get allocatedCount => _nextIndex; + + /// Returns the number of currently free indices. + int get freeCount => _freeIndices.length; + + /// The next index to allocate if no free indices are available. + int _nextIndex = 0; + + /// A map to keep track of the logical index of each batch item by its id. final Map _idToIndex = {}; /// Returns all current ids Iterable get ids => _idToIndex.keys; - /// List of all the existing batch items. - final _batchItems = []; + /// Sparse array of batch items, indexed by allocated indices. + final Map _batchItems = {}; + + /// Returns the number of indices currently in use. + int get usedCount => _nextIndex - _freeIndices.length; + + /// Allocates a new index, reusing freed indices when possible. + int _allocateIndex() { + if (_freeIndices.isNotEmpty) { + return _freeIndices.removeFirst(); + } + return _nextIndex++; + } + + /// Frees an index to be reused later. + void _freeIndex(int index) { + _freeIndices.addFirst(index); + } - /// The sources to use on the [atlas]. - final _sources = []; + /// The sources to use on the [atlas], stored sparsely. + final Map _sources = {}; - /// The sources list shouldn't be modified directly, that is why an - /// [UnmodifiableListView] is used. If you want to add sources use the - /// [add] or [addTransform] method. - UnmodifiableListView get sources { - return UnmodifiableListView(_sources); + /// Returns a compact list of sources for rendering. + List get sources { + final result = []; + for (var i = 0; i < _nextIndex; i++) { + if (_sources.containsKey(i)) { + result.add(_sources[i]!); + } + } + return result; } - /// The transforms that should be applied on the [_sources]. - final _transforms = []; + /// The transforms that should be applied on the [_sources], stored sparsely. + final Map _transforms = {}; - /// The transforms list shouldn't be modified directly, that is why an - /// [UnmodifiableListView] is used. If you want to add transforms use the - /// [add] or [addTransform] method. - UnmodifiableListView get transforms { - return UnmodifiableListView(_transforms); + /// Returns a compact list of transforms for rendering. + List get transforms { + final result = []; + for (var i = 0; i < _nextIndex; i++) { + if (_transforms.containsKey(i)) { + result.add(_transforms[i]!); + } + } + return result; } - /// The background color for the [_sources]. - final _colors = []; + /// The background color for the [_sources], stored sparsely. + final Map _colors = {}; - /// The colors list shouldn't be modified directly, that is why an - /// [UnmodifiableListView] is used. If you want to add colors use the - /// [add] or [addTransform] method. - UnmodifiableListView get colors { - return UnmodifiableListView(_colors); + /// Returns a compact list of colors for rendering. + List get colors { + final result = []; + for (var i = 0; i < _nextIndex; i++) { + if (_colors.containsKey(i)) { + result.add(_colors[i]!); + } + } + return result; } /// The atlas used by the [SpriteBatch]. @@ -253,12 +293,13 @@ class SpriteBatch { return picture.toImageSafe(image.width * 2, image.height); } - int get length => _sources.length; + /// Returns the number of active batch items. + int get length => _batchItems.length; /// Replace provided values of a batch item at the [index], when a parameter /// is not provided, the original value of the batch item will be used. /// - /// Throws an [ArgumentError] if the [index] is out of bounds. + /// Throws an [ArgumentError] if the [index] doesn't exist. /// At least one of the parameters must be different from null. void replace( int index, { @@ -272,11 +313,11 @@ class SpriteBatch { 'At least one of the parameters must be different from null.', ); - if (index < 0 || index >= length) { - throw ArgumentError('Index out of bounds: $index'); + if (!_batchItems.containsKey(index)) { + throw ArgumentError('Index does not exist: $index'); } - final currentBatchItem = _batchItems[index]; + final currentBatchItem = _batchItems[index]!; final newBatchItem = BatchItem( id: id ?? currentBatchItem.id, source: source ?? currentBatchItem.source, @@ -290,11 +331,9 @@ class SpriteBatch { _transforms[index] = newBatchItem.transform; _colors[index] = color ?? _defaultColor; - if (id == null) { - return; + if (id != null) { + _idToIndex[id] = index; } - - _idToIndex[id] = index; } /// Add a new batch item using a RSTransform. @@ -312,13 +351,14 @@ class SpriteBatch { /// cosine of the rotation so that they can be reused over multiple calls to /// this constructor, it may be more efficient to directly use this method /// instead. - void addTransform({ + int addTransform({ required Rect source, RSTransform? transform, bool flip = false, Color? color, String? id, }) { + final index = _allocateIndex(); final batchItem = BatchItem( id: id, source: source, @@ -331,28 +371,25 @@ class SpriteBatch { _makeFlippedAtlas(); } - _batchItems.add(batchItem); - _sources.add( - flip - ? Rect.fromLTWH( - // The atlas is twice as wide when the flipped atlas is generated. - (atlas.width * (_flippedAtlasStatus.isGenerated ? 1 : 2)) - - source.right, - source.top, - source.width, - source.height, - ) - : batchItem.source, - ); - _transforms.add(batchItem.transform); - _colors.add(color ?? _defaultColor); + _batchItems[index] = batchItem; + _sources[index] = flip + ? Rect.fromLTWH( + // The atlas is twice as wide when the flipped atlas is generated. + (atlas.width * (_flippedAtlasStatus.isGenerated ? 1 : 2)) - + source.right, + source.top, + source.width, + source.height, + ) + : batchItem.source; + _transforms[index] = batchItem.transform; + _colors[index] = color ?? _defaultColor; - if (id == null) { - return; + if (id != null) { + _idToIndex[id] = index; } - final newIdx = _batchItems.length - 1; - _idToIndex[id] = newIdx; + return index; } /// Add a new batch item. @@ -373,7 +410,7 @@ class SpriteBatch { /// multiple [RSTransform] objects, /// it may be more efficient to directly use the more direct [addTransform] /// method instead. - void add({ + int add({ required Rect source, String? id, double scale = 1.0, @@ -404,7 +441,7 @@ class SpriteBatch { ); } - addTransform( + return addTransform( source: source, transform: transform, flip: flip, @@ -414,18 +451,7 @@ class SpriteBatch { } /// Finds the index of the batch item with the given [id]. - int? findIndexById(String id) { - if (_idToIndex.containsKey(id)) { - return _idToIndex[id]; - } - for (var i = 0; i < _batchItems.length; i++) { - if (_batchItems[i].id == id) { - _idToIndex[id] = i; // repair mapping - return i; - } - } - return null; - } + int? findIndexById(String id) => _idToIndex[id]; /// Removes a batch item by its [id]. void removeById(String id) { @@ -436,21 +462,19 @@ class SpriteBatch { removeAt(index); _idToIndex.remove(id); - - // adjust indices > removed index - _idToIndex.updateAll((key, idx) => idx > index ? idx - 1 : idx); } /// Removes a batch item at the given [index]. void removeAt(int index) { - if (index < 0 || index >= length) { - throw ArgumentError('Index out of bounds: $index'); + if (!_batchItems.containsKey(index)) { + throw ArgumentError('Index does not exist: $index'); } - _batchItems.removeAt(index); - _sources.removeAt(index); - _transforms.removeAt(index); - _colors.removeAt(index); + _batchItems.remove(index); + _sources.remove(index); + _transforms.remove(index); + _colors.remove(index); + _freeIndex(index); } /// Clear the SpriteBatch so it can be reused. @@ -460,6 +484,8 @@ class SpriteBatch { _colors.clear(); _batchItems.clear(); _idToIndex.clear(); + _freeIndices.clear(); + _nextIndex = 0; } // Used to not create new Paint objects in [render] and @@ -478,7 +504,11 @@ class SpriteBatch { final renderPaint = paint ?? _emptyPaint; - final hasNoColors = _colors.every((c) => c == _defaultColor); + final sourcesList = sources; + final transformsList = transforms; + final colorsList = colors; + + final hasNoColors = colorsList.every((c) => c == _defaultColor); final actualBlendMode = blendMode ?? defaultBlendMode; if (!hasNoColors && actualBlendMode == null) { throw 'When setting any colors, a blend mode must be provided.'; @@ -487,15 +517,16 @@ class SpriteBatch { if (useAtlas && !_flippedAtlasStatus.isGenerating) { canvas.drawAtlas( atlas, - _transforms, - _sources, - hasNoColors ? null : _colors, + transformsList, + sourcesList, + hasNoColors ? null : colorsList, actualBlendMode, cullRect, renderPaint, ); } else { - for (final batchItem in _batchItems) { + for (final index in _batchItems.keys) { + final batchItem = _batchItems[index]!; renderPaint.blendMode = blendMode ?? renderPaint.blendMode; canvas From 8996ededeaed2ff1da26149dc9cb4b4814e8802f Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Mon, 4 Aug 2025 11:24:49 -0600 Subject: [PATCH 08/24] perf: optimize getting transforms, sources, and colors list while avoiding concurrent modification errors --- packages/flame/lib/src/sprite_batch.dart | 87 +++++++++++++++--------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index 9026197b7e8..f69d66e23d6 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -185,46 +185,62 @@ class SpriteBatch { _freeIndices.addFirst(index); } - /// The sources to use on the [atlas], stored sparsely. + /// The sources to use on the [atlas]. final Map _sources = {}; - /// Returns a compact list of sources for rendering. + /// The transforms that should be applied on the [_sources]. + final Map _transforms = {}; + + /// The colors to use for each batch item. + final Map _colors = {}; + + /// Whether the render lists are dirty and need to be rebuilt. + bool _dirty = true; + + /// The lists used for rendering the batch items. + final List _sourcesList = []; + + /// The transforms used for rendering the batch items. + final List _transformsList = []; + + /// The colors used for rendering the batch items. + final List _colorsList = []; + List get sources { - final result = []; - for (var i = 0; i < _nextIndex; i++) { - if (_sources.containsKey(i)) { - result.add(_sources[i]!); - } + if (_dirty) { + _rebuildRenderLists(); } - return result; + return _sourcesList; } - /// The transforms that should be applied on the [_sources], stored sparsely. - final Map _transforms = {}; - - /// Returns a compact list of transforms for rendering. List get transforms { - final result = []; - for (var i = 0; i < _nextIndex; i++) { - if (_transforms.containsKey(i)) { - result.add(_transforms[i]!); - } + if (_dirty) { + _rebuildRenderLists(); } - return result; + return _transformsList; } - /// The background color for the [_sources], stored sparsely. - final Map _colors = {}; - - /// Returns a compact list of colors for rendering. List get colors { - final result = []; + if (_dirty) { + _rebuildRenderLists(); + } + return _colorsList; + } + + void _rebuildRenderLists() { + _sourcesList.clear(); + _transformsList.clear(); + _colorsList.clear(); + for (var i = 0; i < _nextIndex; i++) { - if (_colors.containsKey(i)) { - result.add(_colors[i]!); + if (_batchItems.containsKey(i)) { + _sourcesList.add(_sources[i]!); + _transformsList.add(_transforms[i]!); + _colorsList.add(_colors[i]!); } } - return result; + + _dirty = false; } /// The atlas used by the [SpriteBatch]. @@ -334,6 +350,8 @@ class SpriteBatch { if (id != null) { _idToIndex[id] = index; } + + _dirty = true; } /// Add a new batch item using a RSTransform. @@ -389,6 +407,8 @@ class SpriteBatch { _idToIndex[id] = index; } + _dirty = true; + return index; } @@ -462,6 +482,7 @@ class SpriteBatch { removeAt(index); _idToIndex.remove(id); + _dirty = true; } /// Removes a batch item at the given [index]. @@ -475,6 +496,7 @@ class SpriteBatch { _transforms.remove(index); _colors.remove(index); _freeIndex(index); + _dirty = true; } /// Clear the SpriteBatch so it can be reused. @@ -486,6 +508,7 @@ class SpriteBatch { _idToIndex.clear(); _freeIndices.clear(); _nextIndex = 0; + _dirty = true; } // Used to not create new Paint objects in [render] and @@ -504,11 +527,7 @@ class SpriteBatch { final renderPaint = paint ?? _emptyPaint; - final sourcesList = sources; - final transformsList = transforms; - final colorsList = colors; - - final hasNoColors = colorsList.every((c) => c == _defaultColor); + final hasNoColors = colors.every((c) => c == _defaultColor); final actualBlendMode = blendMode ?? defaultBlendMode; if (!hasNoColors && actualBlendMode == null) { throw 'When setting any colors, a blend mode must be provided.'; @@ -517,9 +536,9 @@ class SpriteBatch { if (useAtlas && !_flippedAtlasStatus.isGenerating) { canvas.drawAtlas( atlas, - transformsList, - sourcesList, - hasNoColors ? null : colorsList, + transforms, + sources, + hasNoColors ? null : colors, actualBlendMode, cullRect, renderPaint, From e964abc8ccc0aed64d27d34a42dbf46271c41cf8 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Wed, 6 Aug 2025 12:56:18 -0600 Subject: [PATCH 09/24] refactor: Rip out id functionality and transform, source, and color list tracking to simplify logic --- packages/flame/lib/src/sprite_batch.dart | 142 +++-------------------- 1 file changed, 16 insertions(+), 126 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index f69d66e23d6..7994937f064 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -37,15 +37,11 @@ class BatchItem { BatchItem({ required this.source, required this.transform, - this.id, Color? color, this.flip = false, }) : paint = Paint()..color = color ?? const Color(0x00000000), destination = Offset.zero & source.size; - /// Optional identifier for the batch item. - final String? id; - /// The source rectangle on the [SpriteBatch.atlas]. final Rect source; @@ -160,12 +156,6 @@ class SpriteBatch { /// The next index to allocate if no free indices are available. int _nextIndex = 0; - /// A map to keep track of the logical index of each batch item by its id. - final Map _idToIndex = {}; - - /// Returns all current ids - Iterable get ids => _idToIndex.keys; - /// Sparse array of batch items, indexed by allocated indices. final Map _batchItems = {}; @@ -185,64 +175,6 @@ class SpriteBatch { _freeIndices.addFirst(index); } - /// The sources to use on the [atlas]. - final Map _sources = {}; - - /// The transforms that should be applied on the [_sources]. - final Map _transforms = {}; - - /// The colors to use for each batch item. - final Map _colors = {}; - - /// Whether the render lists are dirty and need to be rebuilt. - bool _dirty = true; - - /// The lists used for rendering the batch items. - final List _sourcesList = []; - - /// The transforms used for rendering the batch items. - final List _transformsList = []; - - /// The colors used for rendering the batch items. - final List _colorsList = []; - - List get sources { - if (_dirty) { - _rebuildRenderLists(); - } - return _sourcesList; - } - - List get transforms { - if (_dirty) { - _rebuildRenderLists(); - } - return _transformsList; - } - - List get colors { - if (_dirty) { - _rebuildRenderLists(); - } - return _colorsList; - } - - void _rebuildRenderLists() { - _sourcesList.clear(); - _transformsList.clear(); - _colorsList.clear(); - - for (var i = 0; i < _nextIndex; i++) { - if (_batchItems.containsKey(i)) { - _sourcesList.add(_sources[i]!); - _transformsList.add(_transforms[i]!); - _colorsList.add(_colors[i]!); - } - } - - _dirty = false; - } - /// The atlas used by the [SpriteBatch]. Image atlas; @@ -319,7 +251,6 @@ class SpriteBatch { /// At least one of the parameters must be different from null. void replace( int index, { - String? id, Rect? source, Color? color, RSTransform? transform, @@ -335,7 +266,6 @@ class SpriteBatch { final currentBatchItem = _batchItems[index]!; final newBatchItem = BatchItem( - id: id ?? currentBatchItem.id, source: source ?? currentBatchItem.source, transform: transform ?? currentBatchItem.transform, color: color ?? currentBatchItem.paint.color, @@ -343,15 +273,6 @@ class SpriteBatch { ); _batchItems[index] = newBatchItem; - _sources[index] = newBatchItem.source; - _transforms[index] = newBatchItem.transform; - _colors[index] = color ?? _defaultColor; - - if (id != null) { - _idToIndex[id] = index; - } - - _dirty = true; } /// Add a new batch item using a RSTransform. @@ -374,12 +295,19 @@ class SpriteBatch { RSTransform? transform, bool flip = false, Color? color, - String? id, }) { final index = _allocateIndex(); final batchItem = BatchItem( - id: id, - source: source, + source: flip + ? Rect.fromLTWH( + // The atlas is twice as wide when the flipped atlas is generated. + (atlas.width * (_flippedAtlasStatus.isGenerated ? 1 : 2)) - + source.right, + source.top, + source.width, + source.height, + ) + : source, transform: transform ??= defaultTransform ?? RSTransform(1, 0, 0, 0), flip: flip, color: color ?? defaultColor, @@ -390,24 +318,6 @@ class SpriteBatch { } _batchItems[index] = batchItem; - _sources[index] = flip - ? Rect.fromLTWH( - // The atlas is twice as wide when the flipped atlas is generated. - (atlas.width * (_flippedAtlasStatus.isGenerated ? 1 : 2)) - - source.right, - source.top, - source.width, - source.height, - ) - : batchItem.source; - _transforms[index] = batchItem.transform; - _colors[index] = color ?? _defaultColor; - - if (id != null) { - _idToIndex[id] = index; - } - - _dirty = true; return index; } @@ -432,7 +342,6 @@ class SpriteBatch { /// method instead. int add({ required Rect source, - String? id, double scale = 1.0, Vector2? anchor, double rotation = 0, @@ -466,25 +375,9 @@ class SpriteBatch { transform: transform, flip: flip, color: color, - id: id, ); } - /// Finds the index of the batch item with the given [id]. - int? findIndexById(String id) => _idToIndex[id]; - - /// Removes a batch item by its [id]. - void removeById(String id) { - final index = _idToIndex[id]; - if (index == null) { - return; - } - - removeAt(index); - _idToIndex.remove(id); - _dirty = true; - } - /// Removes a batch item at the given [index]. void removeAt(int index) { if (!_batchItems.containsKey(index)) { @@ -492,23 +385,14 @@ class SpriteBatch { } _batchItems.remove(index); - _sources.remove(index); - _transforms.remove(index); - _colors.remove(index); _freeIndex(index); - _dirty = true; } /// Clear the SpriteBatch so it can be reused. void clear() { - _sources.clear(); - _transforms.clear(); - _colors.clear(); _batchItems.clear(); - _idToIndex.clear(); _freeIndices.clear(); _nextIndex = 0; - _dirty = true; } // Used to not create new Paint objects in [render] and @@ -526,6 +410,12 @@ class SpriteBatch { } final renderPaint = paint ?? _emptyPaint; + final transforms = + _batchItems.values.map((e) => e.transform).toList(growable: false); + final sources = + _batchItems.values.map((e) => e.source).toList(growable: false); + final colors = + _batchItems.values.map((e) => e.paint.color).toList(growable: false); final hasNoColors = colors.every((c) => c == _defaultColor); final actualBlendMode = blendMode ?? defaultBlendMode; From 05d792b0a43b16f91b52b58934dbf52424b274e8 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Sat, 16 Aug 2025 10:59:53 -0400 Subject: [PATCH 10/24] feat: Add method to retrieve a BatchItem at a given index --- packages/flame/lib/src/sprite_batch.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index 7994937f064..f5c6a100514 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -275,6 +275,14 @@ class SpriteBatch { _batchItems[index] = newBatchItem; } + /// Returns the [BatchItem] at the given [index]. + BatchItem getBatchItem(int index) { + if (!_batchItems.containsKey(index)) { + throw ArgumentError('Index does not exist: $index'); + } + return _batchItems[index]!; + } + /// Add a new batch item using a RSTransform. /// /// The [source] parameter is the source location on the [atlas]. From 33a09dbedf018ac892e7cbe462b0f1fb2108537c Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Sat, 16 Aug 2025 11:00:08 -0400 Subject: [PATCH 11/24] fix: Update SpriteBatch tests --- packages/flame/test/sprite_batch_test.dart | 48 ++++++---------------- 1 file changed, 13 insertions(+), 35 deletions(-) diff --git a/packages/flame/test/sprite_batch_test.dart b/packages/flame/test/sprite_batch_test.dart index 3ce670d299b..0910bd16208 100644 --- a/packages/flame/test/sprite_batch_test.dart +++ b/packages/flame/test/sprite_batch_test.dart @@ -16,9 +16,9 @@ void main() { test('can add to the batch', () { final image = _MockImage(); final spriteBatch = SpriteBatch(image); - spriteBatch.add(source: Rect.zero); + final index = spriteBatch.add(source: Rect.zero); - expect(spriteBatch.transforms, hasLength(1)); + expect(spriteBatch.getBatchItem(index), isNotNull); }); test('can replace the color of a batch', () { @@ -28,8 +28,13 @@ void main() { spriteBatch.replace(0, color: Colors.red); - expect(spriteBatch.colors, hasLength(1)); - expect(spriteBatch.colors.first, Colors.red); + final batchItem = spriteBatch.getBatchItem(0); + + /// Use .closeTo() to avoid floating point rounding errors. + expect(batchItem.paint.color.a, closeTo(Colors.red.a, 0.001)); + expect(batchItem.paint.color.r, closeTo(Colors.red.r, 0.001)); + expect(batchItem.paint.color.g, closeTo(Colors.red.g, 0.001)); + expect(batchItem.paint.color.b, closeTo(Colors.red.b, 0.001)); }); test('can replace the source of a batch', () { @@ -38,9 +43,9 @@ void main() { spriteBatch.add(source: Rect.zero); spriteBatch.replace(0, source: const Rect.fromLTWH(1, 1, 1, 1)); + final batchItem = spriteBatch.getBatchItem(0); - expect(spriteBatch.sources, hasLength(1)); - expect(spriteBatch.sources.first, const Rect.fromLTWH(1, 1, 1, 1)); + expect(batchItem.source, const Rect.fromLTWH(1, 1, 1, 1)); }); test('can replace the transform of a batch', () { @@ -49,10 +54,10 @@ void main() { spriteBatch.add(source: Rect.zero); spriteBatch.replace(0, transform: RSTransform(1, 1, 1, 1)); + final batchItem = spriteBatch.getBatchItem(0); - expect(spriteBatch.transforms, hasLength(1)); expect( - spriteBatch.transforms.first, + batchItem.transform, isA() .having((t) => t.scos, 'scos', 1) .having((t) => t.ssin, 'ssin', 1) @@ -61,33 +66,6 @@ void main() { ); }); - test('can add a batch item with an id', () { - final image = _MockImage(); - final spriteBatch = SpriteBatch(image); - spriteBatch.add(source: Rect.zero, id: 'item1'); - - final batchItem = spriteBatch.findIndexById('item1'); - - expect(batchItem, isNotNull); - }); - - test('can replace a batch item with an id', () { - final image = _MockImage(); - final spriteBatch = SpriteBatch(image); - spriteBatch.add(source: Rect.zero, id: 'item1'); - - spriteBatch.replace( - spriteBatch.findIndexById('item1')!, - source: const Rect.fromLTWH(1, 1, 1, 1), - id: 'item2', - ); - - final batchItem = spriteBatch.findIndexById('item2'); - - expect(batchItem, isNotNull); - expect(spriteBatch.sources.first, const Rect.fromLTWH(1, 1, 1, 1)); - }); - const margin = 2.0; const tileSize = 6.0; From 025cd1154e61136148ec0c6cfc0231427c6148ef Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Sat, 16 Aug 2025 11:03:40 -0400 Subject: [PATCH 12/24] docs: Remove ID reference in docs --- doc/flame/rendering/images.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/doc/flame/rendering/images.md b/doc/flame/rendering/images.md index 3b43c2857b5..6135c6c7107 100644 --- a/doc/flame/rendering/images.md +++ b/doc/flame/rendering/images.md @@ -316,11 +316,6 @@ A `SpriteBatchComponent` is also available for your convenience. See how to use it in the [SpriteBatch examples](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/sprites/sprite_batch_example.dart) -When using a SpriteBatch to render animations, it's helpful to set a unique ID of the `BatchItem` -related to the frame of your animation to make replacing and removing frames more reliable. When -replacing a `BatchItem`, you can use the `findIndexById` to retrieve the associated index -for replacement. - ## ImageComposition From 312dda2f5359d9f69ab291dcac1cfa0ce31e3f10 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 18 Aug 2025 17:24:47 +0200 Subject: [PATCH 13/24] Fix formatting --- packages/flame/lib/src/sprite_batch.dart | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index 6cc67336065..f58fa0070d2 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -430,12 +430,15 @@ class SpriteBatch { } final renderPaint = paint ?? _emptyPaint; - final transforms = - _batchItems.values.map((e) => e.transform).toList(growable: false); - final sources = - _batchItems.values.map((e) => e.source).toList(growable: false); - final colors = - _batchItems.values.map((e) => e.paint.color).toList(growable: false); + final transforms = _batchItems.values + .map((e) => e.transform) + .toList(growable: false); + final sources = _batchItems.values + .map((e) => e.source) + .toList(growable: false); + final colors = _batchItems.values + .map((e) => e.paint.color) + .toList(growable: false); final hasNoColors = colors.every((c) => c == _defaultColor); final actualBlendMode = blendMode ?? defaultBlendMode; From 0c7aaceb51ccf6b06e97f69fdcbd9edec9886fc3 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Mon, 18 Aug 2025 13:25:42 -0400 Subject: [PATCH 14/24] perf: Move list creation inside if statement that uses those objects --- packages/flame/lib/src/sprite_batch.dart | 31 ++++++++++++------------ 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index f58fa0070d2..77d08529e5a 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -430,23 +430,24 @@ class SpriteBatch { } final renderPaint = paint ?? _emptyPaint; - final transforms = _batchItems.values - .map((e) => e.transform) - .toList(growable: false); - final sources = _batchItems.values - .map((e) => e.source) - .toList(growable: false); - final colors = _batchItems.values - .map((e) => e.paint.color) - .toList(growable: false); - - final hasNoColors = colors.every((c) => c == _defaultColor); - final actualBlendMode = blendMode ?? defaultBlendMode; - if (!hasNoColors && actualBlendMode == null) { - throw 'When setting any colors, a blend mode must be provided.'; - } if (useAtlas && !_flippedAtlasStatus.isGenerating) { + final transforms = _batchItems.values + .map((e) => e.transform) + .toList(growable: false); + final sources = _batchItems.values + .map((e) => e.source) + .toList(growable: false); + final colors = _batchItems.values + .map((e) => e.paint.color) + .toList(growable: false); + + final hasNoColors = colors.every((c) => c == _defaultColor); + final actualBlendMode = blendMode ?? defaultBlendMode; + if (!hasNoColors && actualBlendMode == null) { + throw 'When setting any colors, a blend mode must be provided.'; + } + canvas.drawAtlas( atlas, transforms, From a81322d69f357ff5c9141b11fe578bf94e78576e Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Sat, 23 Aug 2025 14:04:32 -0400 Subject: [PATCH 15/24] refactor: Don't create a new paint reference each render cycle, organize properties together, and set defaults similar to the way it previously was --- packages/flame/lib/src/sprite_batch.dart | 38 ++++++++++++------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index 77d08529e5a..d6a8e5bc3f8 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -13,8 +13,8 @@ extension SpriteBatchExtension on Game { /// its options. Future loadSpriteBatch( String path, { - Color? defaultColor, - BlendMode? defaultBlendMode, + Color defaultColor = const Color(0x00000000), + BlendMode defaultBlendMode = BlendMode.srcOver, RSTransform? defaultTransform, Images? imageCache, bool useAtlas = true, @@ -122,10 +122,10 @@ enum FlippedAtlasStatus { class SpriteBatch { SpriteBatch( this.atlas, { + this.defaultColor = const Color(0x00000000), + this.defaultBlendMode = BlendMode.srcOver, this.defaultTransform, this.useAtlas = true, - this.defaultColor, - this.defaultBlendMode, Images? imageCache, String? imageKey, }) : _imageCache = imageCache, @@ -136,10 +136,10 @@ class SpriteBatch { /// When the [images] is omitted, the global [Flame.images] is used. static Future load( String path, { + Color defaultColor = const Color(0x00000000), + BlendMode defaultBlendMode = BlendMode.srcOver, RSTransform? defaultTransform, Images? images, - Color? defaultColor, - BlendMode? defaultBlendMode, bool useAtlas = true, }) async { final imagesCache = images ?? Flame.images; @@ -171,6 +171,9 @@ class SpriteBatch { /// Sparse array of batch items, indexed by allocated indices. final Map _batchItems = {}; + /// Returns the number of active batch items. + int get length => _batchItems.length; + /// Returns the number of indices currently in use. int get usedCount => _nextIndex - _freeIndices.length; @@ -232,6 +235,12 @@ class SpriteBatch { /// Does this batch contain any operations? bool get isEmpty => _batchItems.isEmpty; + // Used to not create new Paint objects in [render] and + // [generateFlippedAtlas]. + final _emptyPaint = Paint(); + + static const _defaultColor = Color(0x00000000); + Future _makeFlippedAtlas() async { _flippedAtlasStatus = FlippedAtlasStatus.generating; final key = '$imageKey#with-flips'; @@ -253,9 +262,6 @@ class SpriteBatch { return picture.toImageSafe(image.width * 2, image.height); } - /// Returns the number of active batch items. - int get length => _batchItems.length; - /// Replace provided values of a batch item at the [index], when a parameter /// is not provided, the original value of the batch item will be used. /// @@ -415,10 +421,6 @@ class SpriteBatch { _nextIndex = 0; } - // Used to not create new Paint objects in [render] and - // [generateFlippedAtlas]. - final _emptyPaint = Paint(); - void render( Canvas canvas, { BlendMode? blendMode, @@ -429,7 +431,7 @@ class SpriteBatch { return; } - final renderPaint = paint ?? _emptyPaint; + paint ??= _emptyPaint; if (useAtlas && !_flippedAtlasStatus.isGenerating) { final transforms = _batchItems.values @@ -455,12 +457,12 @@ class SpriteBatch { hasNoColors ? null : colors, actualBlendMode, cullRect, - renderPaint, + paint, ); } else { for (final index in _batchItems.keys) { final batchItem = _batchItems[index]!; - renderPaint.blendMode = blendMode ?? renderPaint.blendMode; + paint.blendMode = blendMode ?? paint.blendMode; canvas ..save() @@ -470,12 +472,10 @@ class SpriteBatch { atlas, batchItem.source, batchItem.destination, - renderPaint, + paint, ) ..restore(); } } } - - static const _defaultColor = Color(0x00000000); } From 69ae8a4f04345d819956c99a7b99af663081653e Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Sun, 20 Jul 2025 21:30:58 -0600 Subject: [PATCH 16/24] feat: Use a Free List Strategy on BatchItem indexes within SpriteBatch and return index from .add() --- packages/flame/lib/src/sprite_batch.dart | 185 ++++++++++++--------- packages/flame/test/sprite_batch_test.dart | 21 ++- 2 files changed, 116 insertions(+), 90 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index a543112b1eb..d6a8e5bc3f8 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -13,8 +13,8 @@ extension SpriteBatchExtension on Game { /// its options. Future loadSpriteBatch( String path, { - Color? defaultColor, - BlendMode? defaultBlendMode, + Color defaultColor = const Color(0x00000000), + BlendMode defaultBlendMode = BlendMode.srcOver, RSTransform? defaultTransform, Images? imageCache, bool useAtlas = true, @@ -122,10 +122,10 @@ enum FlippedAtlasStatus { class SpriteBatch { SpriteBatch( this.atlas, { + this.defaultColor = const Color(0x00000000), + this.defaultBlendMode = BlendMode.srcOver, this.defaultTransform, this.useAtlas = true, - this.defaultColor, - this.defaultBlendMode, Images? imageCache, String? imageKey, }) : _imageCache = imageCache, @@ -136,10 +136,10 @@ class SpriteBatch { /// When the [images] is omitted, the global [Flame.images] is used. static Future load( String path, { + Color defaultColor = const Color(0x00000000), + BlendMode defaultBlendMode = BlendMode.srcOver, RSTransform? defaultTransform, Images? images, - Color? defaultColor, - BlendMode? defaultBlendMode, bool useAtlas = true, }) async { final imagesCache = images ?? Flame.images; @@ -156,37 +156,38 @@ class SpriteBatch { FlippedAtlasStatus _flippedAtlasStatus = FlippedAtlasStatus.none; - /// List of all the existing batch items. - final _batchItems = []; + /// Stack of available (freed) indices using ListQueue as a stack. + final Queue _freeIndices = Queue(); - /// The sources to use on the [atlas]. - final _sources = []; + /// Returns the total number of indices that have been allocated. + int get allocatedCount => _nextIndex; - /// The sources list shouldn't be modified directly, that is why an - /// [UnmodifiableListView] is used. If you want to add sources use the - /// [add] or [addTransform] method. - UnmodifiableListView get sources { - return UnmodifiableListView(_sources); - } + /// Returns the number of currently free indices. + int get freeCount => _freeIndices.length; - /// The transforms that should be applied on the [_sources]. - final _transforms = []; + /// The next index to allocate if no free indices are available. + int _nextIndex = 0; - /// The transforms list shouldn't be modified directly, that is why an - /// [UnmodifiableListView] is used. If you want to add transforms use the - /// [add] or [addTransform] method. - UnmodifiableListView get transforms { - return UnmodifiableListView(_transforms); - } + /// Sparse array of batch items, indexed by allocated indices. + final Map _batchItems = {}; + + /// Returns the number of active batch items. + int get length => _batchItems.length; - /// The background color for the [_sources]. - final _colors = []; + /// Returns the number of indices currently in use. + int get usedCount => _nextIndex - _freeIndices.length; + + /// Allocates a new index, reusing freed indices when possible. + int _allocateIndex() { + if (_freeIndices.isNotEmpty) { + return _freeIndices.removeFirst(); + } + return _nextIndex++; + } - /// The colors list shouldn't be modified directly, that is why an - /// [UnmodifiableListView] is used. If you want to add colors use the - /// [add] or [addTransform] method. - UnmodifiableListView get colors { - return UnmodifiableListView(_colors); + /// Frees an index to be reused later. + void _freeIndex(int index) { + _freeIndices.addFirst(index); } /// The atlas used by the [SpriteBatch]. @@ -234,6 +235,12 @@ class SpriteBatch { /// Does this batch contain any operations? bool get isEmpty => _batchItems.isEmpty; + // Used to not create new Paint objects in [render] and + // [generateFlippedAtlas]. + final _emptyPaint = Paint(); + + static const _defaultColor = Color(0x00000000); + Future _makeFlippedAtlas() async { _flippedAtlasStatus = FlippedAtlasStatus.generating; final key = '$imageKey#with-flips'; @@ -255,12 +262,10 @@ class SpriteBatch { return picture.toImageSafe(image.width * 2, image.height); } - int get length => _sources.length; - /// Replace provided values of a batch item at the [index], when a parameter /// is not provided, the original value of the batch item will be used. /// - /// Throws an [ArgumentError] if the [index] is out of bounds. + /// Throws an [ArgumentError] if the [index] doesn't exist. /// At least one of the parameters must be different from null. void replace( int index, { @@ -273,11 +278,11 @@ class SpriteBatch { 'At least one of the parameters must be different from null.', ); - if (index < 0 || index >= length) { - throw ArgumentError('Index out of bounds: $index'); + if (!_batchItems.containsKey(index)) { + throw ArgumentError('Index does not exist: $index'); } - final currentBatchItem = _batchItems[index]; + final currentBatchItem = _batchItems[index]!; final newBatchItem = BatchItem( source: source ?? currentBatchItem.source, transform: transform ?? currentBatchItem.transform, @@ -286,10 +291,14 @@ class SpriteBatch { ); _batchItems[index] = newBatchItem; + } - _sources[index] = newBatchItem.source; - _transforms[index] = newBatchItem.transform; - _colors[index] = color ?? _defaultColor; + /// Returns the [BatchItem] at the given [index]. + BatchItem getBatchItem(int index) { + if (!_batchItems.containsKey(index)) { + throw ArgumentError('Index does not exist: $index'); + } + return _batchItems[index]!; } /// Add a new batch item using a RSTransform. @@ -307,26 +316,15 @@ class SpriteBatch { /// cosine of the rotation so that they can be reused over multiple calls to /// this constructor, it may be more efficient to directly use this method /// instead. - void addTransform({ + int addTransform({ required Rect source, RSTransform? transform, bool flip = false, Color? color, }) { + final index = _allocateIndex(); final batchItem = BatchItem( - source: source, - transform: transform ??= defaultTransform ?? RSTransform(1, 0, 0, 0), - flip: flip, - color: color ?? defaultColor, - ); - - if (flip && useAtlas && _flippedAtlasStatus.isNone) { - _makeFlippedAtlas(); - } - - _batchItems.add(batchItem); - _sources.add( - flip + source: flip ? Rect.fromLTWH( // The atlas is twice as wide when the flipped atlas is generated. (atlas.width * (_flippedAtlasStatus.isGenerated ? 1 : 2)) - @@ -335,10 +333,19 @@ class SpriteBatch { source.width, source.height, ) - : batchItem.source, + : source, + transform: transform ??= defaultTransform ?? RSTransform(1, 0, 0, 0), + flip: flip, + color: color ?? defaultColor, ); - _transforms.add(batchItem.transform); - _colors.add(color ?? _defaultColor); + + if (flip && useAtlas && _flippedAtlasStatus.isNone) { + _makeFlippedAtlas(); + } + + _batchItems[index] = batchItem; + + return index; } /// Add a new batch item. @@ -359,7 +366,7 @@ class SpriteBatch { /// multiple [RSTransform] objects, /// it may be more efficient to directly use the more direct [addTransform] /// method instead. - void add({ + int add({ required Rect source, double scale = 1.0, Vector2? anchor, @@ -389,7 +396,7 @@ class SpriteBatch { ); } - addTransform( + return addTransform( source: source, transform: transform, flip: flip, @@ -397,18 +404,23 @@ class SpriteBatch { ); } + /// Removes a batch item at the given [index]. + void removeAt(int index) { + if (!_batchItems.containsKey(index)) { + throw ArgumentError('Index does not exist: $index'); + } + + _batchItems.remove(index); + _freeIndex(index); + } + /// Clear the SpriteBatch so it can be reused. void clear() { - _sources.clear(); - _transforms.clear(); - _colors.clear(); _batchItems.clear(); + _freeIndices.clear(); + _nextIndex = 0; } - // Used to not create new Paint objects in [render] and - // [generateFlippedAtlas]. - final _emptyPaint = Paint(); - void render( Canvas canvas, { BlendMode? blendMode, @@ -419,27 +431,38 @@ class SpriteBatch { return; } - final renderPaint = paint ?? _emptyPaint; - - final hasNoColors = _colors.every((c) => c == _defaultColor); - final actualBlendMode = blendMode ?? defaultBlendMode; - if (!hasNoColors && actualBlendMode == null) { - throw 'When setting any colors, a blend mode must be provided.'; - } + paint ??= _emptyPaint; if (useAtlas && !_flippedAtlasStatus.isGenerating) { + final transforms = _batchItems.values + .map((e) => e.transform) + .toList(growable: false); + final sources = _batchItems.values + .map((e) => e.source) + .toList(growable: false); + final colors = _batchItems.values + .map((e) => e.paint.color) + .toList(growable: false); + + final hasNoColors = colors.every((c) => c == _defaultColor); + final actualBlendMode = blendMode ?? defaultBlendMode; + if (!hasNoColors && actualBlendMode == null) { + throw 'When setting any colors, a blend mode must be provided.'; + } + canvas.drawAtlas( atlas, - _transforms, - _sources, - hasNoColors ? null : _colors, + transforms, + sources, + hasNoColors ? null : colors, actualBlendMode, cullRect, - renderPaint, + paint, ); } else { - for (final batchItem in _batchItems) { - renderPaint.blendMode = blendMode ?? renderPaint.blendMode; + for (final index in _batchItems.keys) { + final batchItem = _batchItems[index]!; + paint.blendMode = blendMode ?? paint.blendMode; canvas ..save() @@ -449,12 +472,10 @@ class SpriteBatch { atlas, batchItem.source, batchItem.destination, - renderPaint, + paint, ) ..restore(); } } } - - static const _defaultColor = Color(0x00000000); } diff --git a/packages/flame/test/sprite_batch_test.dart b/packages/flame/test/sprite_batch_test.dart index 84ec3ee606f..0910bd16208 100644 --- a/packages/flame/test/sprite_batch_test.dart +++ b/packages/flame/test/sprite_batch_test.dart @@ -16,9 +16,9 @@ void main() { test('can add to the batch', () { final image = _MockImage(); final spriteBatch = SpriteBatch(image); - spriteBatch.add(source: Rect.zero); + final index = spriteBatch.add(source: Rect.zero); - expect(spriteBatch.transforms, hasLength(1)); + expect(spriteBatch.getBatchItem(index), isNotNull); }); test('can replace the color of a batch', () { @@ -28,8 +28,13 @@ void main() { spriteBatch.replace(0, color: Colors.red); - expect(spriteBatch.colors, hasLength(1)); - expect(spriteBatch.colors.first, Colors.red); + final batchItem = spriteBatch.getBatchItem(0); + + /// Use .closeTo() to avoid floating point rounding errors. + expect(batchItem.paint.color.a, closeTo(Colors.red.a, 0.001)); + expect(batchItem.paint.color.r, closeTo(Colors.red.r, 0.001)); + expect(batchItem.paint.color.g, closeTo(Colors.red.g, 0.001)); + expect(batchItem.paint.color.b, closeTo(Colors.red.b, 0.001)); }); test('can replace the source of a batch', () { @@ -38,9 +43,9 @@ void main() { spriteBatch.add(source: Rect.zero); spriteBatch.replace(0, source: const Rect.fromLTWH(1, 1, 1, 1)); + final batchItem = spriteBatch.getBatchItem(0); - expect(spriteBatch.sources, hasLength(1)); - expect(spriteBatch.sources.first, const Rect.fromLTWH(1, 1, 1, 1)); + expect(batchItem.source, const Rect.fromLTWH(1, 1, 1, 1)); }); test('can replace the transform of a batch', () { @@ -49,10 +54,10 @@ void main() { spriteBatch.add(source: Rect.zero); spriteBatch.replace(0, transform: RSTransform(1, 1, 1, 1)); + final batchItem = spriteBatch.getBatchItem(0); - expect(spriteBatch.transforms, hasLength(1)); expect( - spriteBatch.transforms.first, + batchItem.transform, isA() .having((t) => t.scos, 'scos', 1) .having((t) => t.ssin, 'ssin', 1) From a9df9e35c12f274fe177011873de07cdd3f3c896 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Thu, 2 Oct 2025 10:43:09 -0600 Subject: [PATCH 17/24] perf: add color property to BatchItem to optimize color getting so we're not using the Paint color getter --- packages/flame/lib/src/sprite_batch.dart | 25 ++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index d6a8e5bc3f8..796abb9123c 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -37,9 +37,9 @@ class BatchItem { BatchItem({ required this.source, required this.transform, - Color? color, + this.color = const Color(0x00000000), this.flip = false, - }) : paint = Paint()..color = color ?? const Color(0x00000000), + }) : paint = Paint()..color = color, destination = Offset.zero & source.size; /// The source rectangle on the [SpriteBatch.atlas]. @@ -85,6 +85,9 @@ class BatchItem { /// Paint object used for the web. final Paint paint; + + /// The color of the batch item. + final Color color; } @internal @@ -122,10 +125,10 @@ enum FlippedAtlasStatus { class SpriteBatch { SpriteBatch( this.atlas, { - this.defaultColor = const Color(0x00000000), - this.defaultBlendMode = BlendMode.srcOver, this.defaultTransform, this.useAtlas = true, + this.defaultColor = const Color(0x00000000), + this.defaultBlendMode, Images? imageCache, String? imageKey, }) : _imageCache = imageCache, @@ -136,17 +139,17 @@ class SpriteBatch { /// When the [images] is omitted, the global [Flame.images] is used. static Future load( String path, { - Color defaultColor = const Color(0x00000000), - BlendMode defaultBlendMode = BlendMode.srcOver, RSTransform? defaultTransform, Images? images, + Color? defaultColor, + BlendMode? defaultBlendMode, bool useAtlas = true, }) async { final imagesCache = images ?? Flame.images; return SpriteBatch( await imagesCache.load(path), defaultTransform: defaultTransform ?? RSTransform(1, 0, 0, 0), - defaultColor: defaultColor, + defaultColor: defaultColor ?? const Color(0x00000000), defaultBlendMode: defaultBlendMode, useAtlas: useAtlas, imageCache: imagesCache, @@ -211,7 +214,7 @@ class SpriteBatch { 'image[${identityHashCode(atlas)}]'; /// The default color, used as a background color for a [BatchItem]. - final Color? defaultColor; + final Color defaultColor; /// The default transform, used when a transform was not supplied for a /// [BatchItem]. @@ -239,8 +242,6 @@ class SpriteBatch { // [generateFlippedAtlas]. final _emptyPaint = Paint(); - static const _defaultColor = Color(0x00000000); - Future _makeFlippedAtlas() async { _flippedAtlasStatus = FlippedAtlasStatus.generating; final key = '$imageKey#with-flips'; @@ -441,10 +442,10 @@ class SpriteBatch { .map((e) => e.source) .toList(growable: false); final colors = _batchItems.values - .map((e) => e.paint.color) + .map((e) => e.color) .toList(growable: false); - final hasNoColors = colors.every((c) => c == _defaultColor); + final hasNoColors = colors.every((c) => c == defaultColor); final actualBlendMode = blendMode ?? defaultBlendMode; if (!hasNoColors && actualBlendMode == null) { throw 'When setting any colors, a blend mode must be provided.'; From b7c15415dc0fa63c9361cd51098897b5767dcfd8 Mon Sep 17 00:00:00 2001 From: Gnarhard Date: Tue, 6 Jan 2026 09:42:23 -0700 Subject: [PATCH 18/24] Update packages/flame/lib/src/sprite_batch.dart Co-authored-by: DevKage <33748002+ufrshubham@users.noreply.github.com> --- packages/flame/lib/src/sprite_batch.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index 796abb9123c..ec47bea6052 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -442,7 +442,16 @@ class SpriteBatch { .map((e) => e.source) .toList(growable: false); final colors = _batchItems.values - .map((e) => e.color) + .map((e) => e.flip + ? Rect.fromLTWH( + // The atlas is twice as wide when the flipped atlas is generated. + (atlas.width * (_flippedAtlasStatus.isGenerated ? 1 : 2)) - + e.source.right, + e.source.top, + e.source.width, + e.source.height, + ) + : e.source) .toList(growable: false); final hasNoColors = colors.every((c) => c == defaultColor); From c62b6bbbcd9baecae336d92fe5d3143f369f8417 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Tue, 6 Jan 2026 10:05:20 -0700 Subject: [PATCH 19/24] refactor: Remove test that was providing duplicate functionality: useAtlas was never set to falst --- packages/flame_tiled/test/tiled_test.dart | 47 +---------------------- 1 file changed, 2 insertions(+), 45 deletions(-) diff --git a/packages/flame_tiled/test/tiled_test.dart b/packages/flame_tiled/test/tiled_test.dart index 8648f536e15..11defd9aa60 100644 --- a/packages/flame_tiled/test/tiled_test.dart +++ b/packages/flame_tiled/test/tiled_test.dart @@ -320,8 +320,8 @@ void main() { final rightTilePixels = []; for ( - var i = 69 * 8 * pixel; - i < ((64 * 23) + (8 * 7)) * pixel; + var i = 69 * oneColorRect * pixel; + i < ((64 * 23) + (oneColorRect * 7)) * pixel; i += 64 * pixel ) { rightTilePixels.addAll( @@ -338,49 +338,6 @@ void main() { } expect(allGreen, true); }); - - test('[useAtlas = false] Green tile pixels are in correct spots', () { - final leftTilePixels = []; - for ( - var i = 65 * 8 * pixel; - i < ((64 * 23) + (8 * 3)) * pixel; - i += 64 * pixel - ) { - leftTilePixels.addAll( - pixelsBeforeFlipApplied.getRange(i, i + (16 * pixel)), - ); - } - - var allGreen = true; - for (var i = 0; i < leftTilePixels.length; i += pixel) { - allGreen &= - leftTilePixels[i] == 0 && - leftTilePixels[i + 1] == 255 && - leftTilePixels[i + 2] == 0 && - leftTilePixels[i + 3] == 255; - } - expect(allGreen, true); - - final rightTilePixels = []; - for ( - var i = 69 * 8 * pixel; - i < ((64 * 23) + (8 * 7)) * pixel; - i += 64 * pixel - ) { - rightTilePixels.addAll( - pixelsBeforeFlipApplied.getRange(i, i + (16 * pixel)), - ); - } - - for (var i = 0; i < rightTilePixels.length; i += pixel) { - allGreen &= - rightTilePixels[i] == 0 && - rightTilePixels[i + 1] == 255 && - rightTilePixels[i + 2] == 0 && - rightTilePixels[i + 3] == 255; - } - expect(allGreen, true); - }); }); group('ignoring flip makes different texture and rendering result', () { From 0ff6ee2edfeaf13e625f8b33cdc2cf2f2e1d56b9 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Tue, 6 Jan 2026 11:05:08 -0700 Subject: [PATCH 20/24] fix: Fix tests by preserving old flipping and color semantics and parallel lists --- packages/flame/lib/src/sprite_batch.dart | 272 ++++++++++++++--------- 1 file changed, 167 insertions(+), 105 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index 796abb9123c..9a6cd49b2ec 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -13,8 +13,8 @@ extension SpriteBatchExtension on Game { /// its options. Future loadSpriteBatch( String path, { - Color defaultColor = const Color(0x00000000), - BlendMode defaultBlendMode = BlendMode.srcOver, + Color? defaultColor, + BlendMode? defaultBlendMode, RSTransform? defaultTransform, Images? imageCache, bool useAtlas = true, @@ -37,30 +37,34 @@ class BatchItem { BatchItem({ required this.source, required this.transform, - this.color = const Color(0x00000000), + Color? color, this.flip = false, - }) : paint = Paint()..color = color, + }) : color = color ?? const Color(0x00000000), + paint = Paint()..color = color ?? const Color(0x00000000), destination = Offset.zero & source.size; /// The source rectangle on the [SpriteBatch.atlas]. - final Rect source; + Rect source; /// The destination rectangle for the Canvas. /// /// It will be transformed by [matrix]. - final Rect destination; + Rect destination; /// The transform values for this batch item. - final RSTransform transform; + RSTransform transform; /// The flip value for this batch item. - final bool flip; + bool flip; + + /// The color of the batch item (used for building the drawAtlas color list). + Color color; /// Fallback matrix for the web. /// /// Since [Canvas.drawAtlas] is not supported on the web we also /// build a `Matrix4` based on the [transform] and [flip] values. - late final Matrix4 matrix = + late Matrix4 matrix = Matrix4( transform.scos, transform.ssin, @@ -79,15 +83,12 @@ class BatchItem { 0, 1, // ) - ..translateByDouble(source.width / 2, source.height / 2, 0.0, 1.0) + ..translateByDouble(source.width / 2, source.height / 2, 1, 1) ..rotateY(flip ? pi : 0) - ..translateByDouble(-source.width / 2, -source.height / 2, 0.0, 1.0); + ..translateByDouble(-source.width / 2, -source.height / 2, 1, 1); /// Paint object used for the web. - final Paint paint; - - /// The color of the batch item. - final Color color; + Paint paint; } @internal @@ -127,7 +128,7 @@ class SpriteBatch { this.atlas, { this.defaultTransform, this.useAtlas = true, - this.defaultColor = const Color(0x00000000), + this.defaultColor, this.defaultBlendMode, Images? imageCache, String? imageKey, @@ -149,7 +150,7 @@ class SpriteBatch { return SpriteBatch( await imagesCache.load(path), defaultTransform: defaultTransform ?? RSTransform(1, 0, 0, 0), - defaultColor: defaultColor ?? const Color(0x00000000), + defaultColor: defaultColor, defaultBlendMode: defaultBlendMode, useAtlas: useAtlas, imageCache: imagesCache, @@ -159,38 +160,55 @@ class SpriteBatch { FlippedAtlasStatus _flippedAtlasStatus = FlippedAtlasStatus.none; - /// Stack of available (freed) indices using ListQueue as a stack. - final Queue _freeIndices = Queue(); + final List _batchItems = []; + final List _sources = []; + final List _transforms = []; + final List _colors = []; - /// Returns the total number of indices that have been allocated. - int get allocatedCount => _nextIndex; + UnmodifiableListView get sources { + return UnmodifiableListView(_sources); + } + + UnmodifiableListView get transforms { + return UnmodifiableListView(_transforms); + } + + UnmodifiableListView get colors { + return UnmodifiableListView(_colors); + } - /// Returns the number of currently free indices. - int get freeCount => _freeIndices.length; + /// Handle/index management (free list strategy). + final Queue _freeHandles = Queue(); - /// The next index to allocate if no free indices are available. - int _nextIndex = 0; + /// The next handle to allocate if there are no free handles. + int _nextHandle = 0; - /// Sparse array of batch items, indexed by allocated indices. - final Map _batchItems = {}; + /// Map handle -> dense slot index. + final Map _handleToSlot = {}; - /// Returns the number of active batch items. - int get length => _batchItems.length; + /// Reverse map: dense slot -> handle. + final List _slotToHandle = []; - /// Returns the number of indices currently in use. - int get usedCount => _nextIndex - _freeIndices.length; + /// The total number of allocated handles. + int get allocatedCount => _nextHandle; - /// Allocates a new index, reusing freed indices when possible. - int _allocateIndex() { - if (_freeIndices.isNotEmpty) { - return _freeIndices.removeFirst(); + /// The number of free handles. + int get freeCount => _freeHandles.length; + + /// The number of used handles. + int get usedCount => _handleToSlot.length; + + /// Allocates a new handle. + int _allocateHandle() { + if (_freeHandles.isNotEmpty) { + return _freeHandles.removeFirst(); } - return _nextIndex++; + return _nextHandle++; } - /// Frees an index to be reused later. - void _freeIndex(int index) { - _freeIndices.addFirst(index); + /// Frees a handle for future reuse. + void _freeHandle(int handle) { + _freeHandles.addFirst(handle); } /// The atlas used by the [SpriteBatch]. @@ -213,8 +231,10 @@ class SpriteBatch { imageCache.findKeyForImage(atlas) ?? 'image[${identityHashCode(atlas)}]'; - /// The default color, used as a background color for a [BatchItem]. - final Color defaultColor; + /// The default color, used as a background color for a [BatchItem] (web path). + /// + /// Note: The drawAtlas color list uses [_defaultColor] unless an explicit per-item color is provided. + final Color? defaultColor; /// The default transform, used when a transform was not supplied for a /// [BatchItem]. @@ -263,10 +283,33 @@ class SpriteBatch { return picture.toImageSafe(image.width * 2, image.height); } - /// Replace provided values of a batch item at the [index], when a parameter - /// is not provided, the original value of the batch item will be used. - /// - /// Throws an [ArgumentError] if the [index] doesn't exist. + /// Resolves the source rectangle for the atlas, taking into account if a + /// flipped atlas is being used. + Rect _resolveSourceForAtlas(BatchItem batchItem) { + if (!batchItem.flip) { + return batchItem.source; + } + + // The atlas is twice as wide when the flipped atlas is generated. + final atlasWidthMultiplier = _flippedAtlasStatus.isGenerated ? 1 : 2; + return Rect.fromLTWH( + (atlas.width * atlasWidthMultiplier) - batchItem.source.right, + batchItem.source.top, + batchItem.source.width, + batchItem.source.height, + ); + } + + /// Ensures that the given [handle] exists and returns its slot. + int _requireSlot(int handle) { + final slot = _handleToSlot[handle]; + if (slot == null) { + throw ArgumentError('Index does not exist: $handle'); + } + return slot; + } + + /// Replaces the parameters of the batch item at the given [index]. /// At least one of the parameters must be different from null. void replace( int index, { @@ -279,27 +322,27 @@ class SpriteBatch { 'At least one of the parameters must be different from null.', ); - if (!_batchItems.containsKey(index)) { - throw ArgumentError('Index does not exist: $index'); + final slot = _requireSlot(index); + final currentBatchItem = _batchItems[slot]; + + currentBatchItem.source = source ?? currentBatchItem.source; + currentBatchItem.transform = transform ?? currentBatchItem.transform; + if (color != null) { + currentBatchItem.color = color; + currentBatchItem.paint.color = color; } - final currentBatchItem = _batchItems[index]!; - final newBatchItem = BatchItem( - source: source ?? currentBatchItem.source, - transform: transform ?? currentBatchItem.transform, - color: color ?? currentBatchItem.paint.color, - flip: currentBatchItem.flip, - ); + _sources[slot] = _resolveSourceForAtlas(currentBatchItem); + _transforms[slot] = currentBatchItem.transform; - _batchItems[index] = newBatchItem; + // Preserve old semantics: only explicit item colors affect the drawAtlas list. + _colors[slot] = color ?? _defaultColor; } - /// Returns the [BatchItem] at the given [index]. + /// Gets the [BatchItem] at the given [index]. BatchItem getBatchItem(int index) { - if (!_batchItems.containsKey(index)) { - throw ArgumentError('Index does not exist: $index'); - } - return _batchItems[index]!; + final slot = _requireSlot(index); + return _batchItems[slot]; } /// Add a new batch item using a RSTransform. @@ -323,18 +366,10 @@ class SpriteBatch { bool flip = false, Color? color, }) { - final index = _allocateIndex(); + final handle = _allocateHandle(); + final batchItem = BatchItem( - source: flip - ? Rect.fromLTWH( - // The atlas is twice as wide when the flipped atlas is generated. - (atlas.width * (_flippedAtlasStatus.isGenerated ? 1 : 2)) - - source.right, - source.top, - source.width, - source.height, - ) - : source, + source: source, transform: transform ??= defaultTransform ?? RSTransform(1, 0, 0, 0), flip: flip, color: color ?? defaultColor, @@ -344,9 +379,19 @@ class SpriteBatch { _makeFlippedAtlas(); } - _batchItems[index] = batchItem; + final slot = _batchItems.length; + + _handleToSlot[handle] = slot; + _slotToHandle.add(handle); + + _batchItems.add(batchItem); + _sources.add(_resolveSourceForAtlas(batchItem)); + _transforms.add(batchItem.transform); - return index; + // Preserve old semantics: if color is not explicitly provided, store transparent. + _colors.add(color ?? _defaultColor); + + return handle; } /// Add a new batch item. @@ -405,21 +450,46 @@ class SpriteBatch { ); } - /// Removes a batch item at the given [index]. + /// Removes the batch item at the given [index]. void removeAt(int index) { - if (!_batchItems.containsKey(index)) { - throw ArgumentError('Index does not exist: $index'); + final slot = _requireSlot(index); + + final lastSlot = _batchItems.length - 1; + final removedHandle = _slotToHandle[slot]; + + if (slot != lastSlot) { + // Move last -> slot. + _batchItems[slot] = _batchItems[lastSlot]; + _sources[slot] = _sources[lastSlot]; + _transforms[slot] = _transforms[lastSlot]; + _colors[slot] = _colors[lastSlot]; + + final movedHandle = _slotToHandle[lastSlot]; + _slotToHandle[slot] = movedHandle; + _handleToSlot[movedHandle] = slot; } - _batchItems.remove(index); - _freeIndex(index); + _batchItems.removeLast(); + _sources.removeLast(); + _transforms.removeLast(); + _colors.removeLast(); + _slotToHandle.removeLast(); + + _handleToSlot.remove(removedHandle); + _freeHandle(removedHandle); } /// Clear the SpriteBatch so it can be reused. void clear() { + _sources.clear(); + _transforms.clear(); + _colors.clear(); _batchItems.clear(); - _freeIndices.clear(); - _nextIndex = 0; + + _handleToSlot.clear(); + _slotToHandle.clear(); + _freeHandles.clear(); + _nextHandle = 0; } void render( @@ -432,38 +502,28 @@ class SpriteBatch { return; } - paint ??= _emptyPaint; + final renderPaint = paint ?? _emptyPaint; - if (useAtlas && !_flippedAtlasStatus.isGenerating) { - final transforms = _batchItems.values - .map((e) => e.transform) - .toList(growable: false); - final sources = _batchItems.values - .map((e) => e.source) - .toList(growable: false); - final colors = _batchItems.values - .map((e) => e.color) - .toList(growable: false); - - final hasNoColors = colors.every((c) => c == defaultColor); - final actualBlendMode = blendMode ?? defaultBlendMode; - if (!hasNoColors && actualBlendMode == null) { - throw 'When setting any colors, a blend mode must be provided.'; - } + final hasNoColors = _colors.every((c) => c == _defaultColor); + final actualBlendMode = blendMode ?? defaultBlendMode; + + if (!hasNoColors && actualBlendMode == null) { + throw 'When setting any colors, a blend mode must be provided.'; + } + if (useAtlas && !_flippedAtlasStatus.isGenerating) { canvas.drawAtlas( atlas, - transforms, - sources, - hasNoColors ? null : colors, + _transforms, + _sources, + hasNoColors ? null : _colors, actualBlendMode, cullRect, - paint, + renderPaint, ); } else { - for (final index in _batchItems.keys) { - final batchItem = _batchItems[index]!; - paint.blendMode = blendMode ?? paint.blendMode; + for (final batchItem in _batchItems) { + renderPaint.blendMode = blendMode ?? renderPaint.blendMode; canvas ..save() @@ -473,10 +533,12 @@ class SpriteBatch { atlas, batchItem.source, batchItem.destination, - paint, + renderPaint, ) ..restore(); } } } + + static const _defaultColor = Color(0x00000000); } From 777a586e725605db9ff114702a4e75f6631636a4 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Tue, 6 Jan 2026 12:00:00 -0700 Subject: [PATCH 21/24] chore: Fix lint errors --- packages/flame/lib/src/sprite_batch.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/flame/lib/src/sprite_batch.dart b/packages/flame/lib/src/sprite_batch.dart index 9a6cd49b2ec..4be72951a3f 100644 --- a/packages/flame/lib/src/sprite_batch.dart +++ b/packages/flame/lib/src/sprite_batch.dart @@ -231,9 +231,10 @@ class SpriteBatch { imageCache.findKeyForImage(atlas) ?? 'image[${identityHashCode(atlas)}]'; - /// The default color, used as a background color for a [BatchItem] (web path). + /// The default color, used as a background color for a [BatchItem] on web. /// - /// Note: The drawAtlas color list uses [_defaultColor] unless an explicit per-item color is provided. + /// Note: The drawAtlas color list uses [_defaultColor] + /// unless an explicit per-item color is provided. final Color? defaultColor; /// The default transform, used when a transform was not supplied for a @@ -335,7 +336,7 @@ class SpriteBatch { _sources[slot] = _resolveSourceForAtlas(currentBatchItem); _transforms[slot] = currentBatchItem.transform; - // Preserve old semantics: only explicit item colors affect the drawAtlas list. + // If color is not explicitly provided, store transparent. _colors[slot] = color ?? _defaultColor; } @@ -388,7 +389,7 @@ class SpriteBatch { _sources.add(_resolveSourceForAtlas(batchItem)); _transforms.add(batchItem.transform); - // Preserve old semantics: if color is not explicitly provided, store transparent. + // If color is not explicitly provided, store transparent. _colors.add(color ?? _defaultColor); return handle; From 980f9f044d2a145f75e129ac72c5122691a7820c Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Tue, 6 Jan 2026 12:01:39 -0700 Subject: [PATCH 22/24] fix: Fix lint warning from unused local variable --- packages/flame_tiled/test/tiled_test.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/flame_tiled/test/tiled_test.dart b/packages/flame_tiled/test/tiled_test.dart index 11defd9aa60..8e1154ca032 100644 --- a/packages/flame_tiled/test/tiled_test.dart +++ b/packages/flame_tiled/test/tiled_test.dart @@ -261,7 +261,6 @@ void main() { }); group('Flipped and rotated tiles render correctly with sprite batch:', () { - late Uint8List pixelsBeforeFlipApplied; late Uint8List pixelsAfterFlipApplied; late RenderableTiledMap overlapMap; From 81b7f04960927da797ec1c87862678b1e27ee8d3 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Tue, 6 Jan 2026 12:08:00 -0700 Subject: [PATCH 23/24] fix: Fix lint warning --- packages/flame_tiled/test/tiled_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flame_tiled/test/tiled_test.dart b/packages/flame_tiled/test/tiled_test.dart index 8e1154ca032..ef3751fcf52 100644 --- a/packages/flame_tiled/test/tiled_test.dart +++ b/packages/flame_tiled/test/tiled_test.dart @@ -289,7 +289,7 @@ void main() { images: Images(bundle: bundle), ); - pixelsBeforeFlipApplied = await renderMap(); + await renderMap(); await Flame.images.ready(); pixelsAfterFlipApplied = await renderMap(); }); From 6d4cc5eabfa3b45c7ec7a4e9f4606d9371719d02 Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Wed, 7 Jan 2026 09:19:31 -0700 Subject: [PATCH 24/24] refactor: Restore removed test --- packages/flame_tiled/test/tiled_test.dart | 50 +++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/flame_tiled/test/tiled_test.dart b/packages/flame_tiled/test/tiled_test.dart index ef3751fcf52..8648f536e15 100644 --- a/packages/flame_tiled/test/tiled_test.dart +++ b/packages/flame_tiled/test/tiled_test.dart @@ -261,6 +261,7 @@ void main() { }); group('Flipped and rotated tiles render correctly with sprite batch:', () { + late Uint8List pixelsBeforeFlipApplied; late Uint8List pixelsAfterFlipApplied; late RenderableTiledMap overlapMap; @@ -289,7 +290,7 @@ void main() { images: Images(bundle: bundle), ); - await renderMap(); + pixelsBeforeFlipApplied = await renderMap(); await Flame.images.ready(); pixelsAfterFlipApplied = await renderMap(); }); @@ -319,8 +320,8 @@ void main() { final rightTilePixels = []; for ( - var i = 69 * oneColorRect * pixel; - i < ((64 * 23) + (oneColorRect * 7)) * pixel; + var i = 69 * 8 * pixel; + i < ((64 * 23) + (8 * 7)) * pixel; i += 64 * pixel ) { rightTilePixels.addAll( @@ -337,6 +338,49 @@ void main() { } expect(allGreen, true); }); + + test('[useAtlas = false] Green tile pixels are in correct spots', () { + final leftTilePixels = []; + for ( + var i = 65 * 8 * pixel; + i < ((64 * 23) + (8 * 3)) * pixel; + i += 64 * pixel + ) { + leftTilePixels.addAll( + pixelsBeforeFlipApplied.getRange(i, i + (16 * pixel)), + ); + } + + var allGreen = true; + for (var i = 0; i < leftTilePixels.length; i += pixel) { + allGreen &= + leftTilePixels[i] == 0 && + leftTilePixels[i + 1] == 255 && + leftTilePixels[i + 2] == 0 && + leftTilePixels[i + 3] == 255; + } + expect(allGreen, true); + + final rightTilePixels = []; + for ( + var i = 69 * 8 * pixel; + i < ((64 * 23) + (8 * 7)) * pixel; + i += 64 * pixel + ) { + rightTilePixels.addAll( + pixelsBeforeFlipApplied.getRange(i, i + (16 * pixel)), + ); + } + + for (var i = 0; i < rightTilePixels.length; i += pixel) { + allGreen &= + rightTilePixels[i] == 0 && + rightTilePixels[i + 1] == 255 && + rightTilePixels[i + 2] == 0 && + rightTilePixels[i + 3] == 255; + } + expect(allGreen, true); + }); }); group('ignoring flip makes different texture and rendering result', () {