From 7c4ec659d64157e13eafa38b5f4f883864fb52f4 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 18:31:30 +0200 Subject: [PATCH 1/5] perf: Make render contexts and debug caches lazily allocated --- .../lib/src/components/core/component.dart | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 7fca4bb0510..94a9daf2e91 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -598,19 +598,16 @@ class Component { /// cleans them up afterwards. @protected void renderChild(Canvas canvas, Component child) { - int? originalLength; - final hasContext = _renderContexts.isNotEmpty; - if (hasContext) { - originalLength = child._renderContexts.length; - child._renderContexts.addAll(_renderContexts); + final contexts = _renderContexts; + if (contexts == null || contexts.isEmpty) { + child.renderTree(canvas); + return; } + final childContexts = child._renderContexts ??= []; + final originalLength = childContexts.length; + childContexts.addAll(contexts); child.renderTree(canvas); - if (hasContext) { - child._renderContexts.removeRange( - originalLength!, - child._renderContexts.length, - ); - } + childContexts.removeRange(originalLength, childContexts.length); } /// Called once after all children have been rendered in [renderTree]. @@ -623,7 +620,7 @@ class Component { void renderTree(Canvas canvas) { final context = renderContext; if (context != null) { - _renderContexts.add(context); + (_renderContexts ??= []).add(context); } render(canvas); @@ -641,7 +638,7 @@ class Component { } if (context != null) { - _renderContexts.removeLast(); + _renderContexts!.removeLast(); } } @@ -1217,14 +1214,16 @@ class Component { //#region Context - final QueueList _renderContexts = QueueList(); + /// The stack of render contexts inherited from ancestors during the render + /// pass. Created lazily: most components never provide or receive one. + List? _renderContexts; /// Override this method if you want your component to provide a custom /// render context to all its children (recursively). ComponentRenderContext? get renderContext => null; T? findRenderContext() { - return _renderContexts.whereType().lastOrNull; + return _renderContexts?.whereType().lastOrNull; } //#endregion @@ -1255,8 +1254,9 @@ class Component { /// The color that the debug output should be rendered with. Color debugColor = const Color(0xFFFF00FF); - final ValueCache _debugPaintCache = ValueCache(); - final ValueCache _debugTextPaintCache = ValueCache(); + late final ValueCache _debugPaintCache = ValueCache(); + late final ValueCache _debugTextPaintCache = + ValueCache(); /// The [debugColor] represented as a [Paint] object. Paint get debugPaint { From 264fc664599f72bea48fce91ee322530ea735873 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 16 Aug 2026 15:35:52 +0200 Subject: [PATCH 2/5] refactor: Address review comments on lazy render contexts --- .../flame/lib/src/components/core/component.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 94a9daf2e91..620a7fa624e 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -598,8 +598,8 @@ class Component { /// cleans them up afterwards. @protected void renderChild(Canvas canvas, Component child) { - final contexts = _renderContexts; - if (contexts == null || contexts.isEmpty) { + final contexts = _renderContexts ?? const []; + if (contexts.isEmpty) { child.renderTree(canvas); return; } @@ -619,8 +619,10 @@ class Component { void renderTree(Canvas canvas) { final context = renderContext; + List? renderContexts; if (context != null) { - (_renderContexts ??= []).add(context); + renderContexts = _renderContexts ??= []; + renderContexts.add(context); } render(canvas); @@ -637,9 +639,7 @@ class Component { renderDebugMode(canvas); } - if (context != null) { - _renderContexts!.removeLast(); - } + renderContexts?.removeLast(); } //#endregion From 8d97b08ca0329c34d991a8835dc906ff3aa33b93 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 18:32:09 +0200 Subject: [PATCH 3/5] perf: Replace generator-based removal teardown with an explicit collection pass --- .../lib/src/components/core/component.dart | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/flame/lib/src/components/core/component.dart b/packages/flame/lib/src/components/core/component.dart index 620a7fa624e..be3864d94a8 100644 --- a/packages/flame/lib/src/components/core/component.dart +++ b/packages/flame/lib/src/components/core/component.dart @@ -1183,24 +1183,38 @@ class Component { void _remove(Component parent) { parent._internalChildren.remove(this); - propagateToChildren( - (Component component) { - component - ..onRemove() - .._unregisterKey() - .._clearMountedBit() - .._clearRemovingBit() - .._setRemovedBit() - .._removeCompleter?.complete() - .._removeCompleter = null - .._parent!.onChildrenChanged(component, ChildrenChangeType.removed); - return true; - }, - includeSelf: true, - ); + final buffer = []; + _collectTeardown(buffer); + for (var i = 0; i < buffer.length; i++) { + final component = buffer[i]; + component + ..onRemove() + .._unregisterKey() + .._clearMountedBit() + .._clearRemovingBit() + .._setRemovedBit() + .._removeCompleter?.complete() + .._removeCompleter = null + .._parent!.onChildrenChanged(component, ChildrenChangeType.removed); + } _parent = null; } + /// Collects this component and all its descendants into [out] in teardown + /// order: leaves first, siblings in reverse order, ancestors after their + /// subtrees. This matches the order that + /// `descendants(reversed: true, includeSelf: true)` would produce, without + /// allocating generator frames on every removal. + void _collectTeardown(List out) { + final children = _children; + if (children != null) { + for (final child in children.reversed()) { + child._collectTeardown(out); + } + } + out.add(this); + } + void _unregisterKey() { if (key != null) { final game = findGame(); From 0421745a3643b28dcd26dbb50ea0f390434cccb5 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Wed, 5 Aug 2026 18:32:50 +0200 Subject: [PATCH 4/5] perf: Insertion-sort the sweep broadphase and swap-remove its active list --- .../collisions/broadphase/sweep/sweep.dart | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart index f79cc6e33e0..e446d5ce2b3 100644 --- a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart +++ b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart @@ -17,7 +17,21 @@ class Sweep> extends Broadphase { @override void update() { - items.sort((a, b) => a.aabb.min.x.compareTo(b.aabb.min.x)); + // Between two ticks the hitboxes only move a little, so [items] is + // always nearly sorted: an insertion sort runs in close to linear time + // here, where a general-purpose sort would pay its full O(n log n) on + // every tick. This also avoids allocating a comparator closure per tick. + final items = this.items; + for (var i = 1; i < items.length; i++) { + final item = items[i]; + final minX = item.aabb.min.x; + var previousIndex = i - 1; + while (previousIndex >= 0 && items[previousIndex].aabb.min.x > minX) { + items[previousIndex + 1] = items[previousIndex]; + previousIndex--; + } + items[previousIndex + 1] = item; + } } @override @@ -44,7 +58,10 @@ class Sweep> extends Broadphase { _prospectPool.acquire(item, activeItem); } } else { - _active.remove(activeItem); + // The order of the active list does not matter, so the removal can + // swap in the last element instead of searching and shifting. + _active[i] = _active.last; + _active.removeLast(); } } _active.add(item); From 4dbea34cb63be48e162e7450750e6f27bf5c6d18 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Sun, 16 Aug 2026 15:44:02 +0200 Subject: [PATCH 5/5] refactor: Use insertionSort from package:collection with a static comparator tear-off --- .../collisions/broadphase/sweep/sweep.dart | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart index e446d5ce2b3..42aabb4bb81 100644 --- a/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart +++ b/packages/flame/lib/src/collisions/broadphase/sweep/sweep.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flame/collisions.dart'; class Sweep> extends Broadphase { @@ -20,20 +21,14 @@ class Sweep> extends Broadphase { // Between two ticks the hitboxes only move a little, so [items] is // always nearly sorted: an insertion sort runs in close to linear time // here, where a general-purpose sort would pay its full O(n log n) on - // every tick. This also avoids allocating a comparator closure per tick. - final items = this.items; - for (var i = 1; i < items.length; i++) { - final item = items[i]; - final minX = item.aabb.min.x; - var previousIndex = i - 1; - while (previousIndex >= 0 && items[previousIndex].aabb.min.x > minX) { - items[previousIndex + 1] = items[previousIndex]; - previousIndex--; - } - items[previousIndex + 1] = item; - } + // every tick. The comparator is a static tear-off, so nothing is + // allocated per tick. + insertionSort(items, compare: _compareMinX); } + static int _compareMinX(Hitbox a, Hitbox b) => + a.aabb.min.x.compareTo(b.aabb.min.x); + @override Iterable> query() { _active.clear();