From 3babca93e6ca2584737fbf62587f018a57765f1c Mon Sep 17 00:00:00 2001 From: Grayson Erhard Date: Sun, 20 Jul 2025 21:30:58 -0600 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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.';