diff --git a/packages/flame/benchmark/README.md b/packages/flame/benchmark/README.md new file mode 100644 index 00000000000..16c4521fa2b --- /dev/null +++ b/packages/flame/benchmark/README.md @@ -0,0 +1,67 @@ +# Flame benchmarks + +Micro-benchmarks for the hot paths of the Flame Component System. Use them to +detect regressions and to measure the effect of optimizations to the engine +internals (children containers, lifecycle processing, traversal, hit testing, +collision detection). + + +## Running + +Run the whole suite: + +```console +flutter test benchmark/main.dart +``` + +Each file also has its own `main`, so a single suite can be run in isolation: + +```console +flutter test benchmark/priority_change_benchmark.dart +``` + +The `flutter test` runner executes in JIT mode with asserts enabled, which is +fine for comparing before/after numbers on the same machine. For +release-representative numbers, run on a device instead: + +```console +flutter run --release -t benchmark/main.dart -d +``` + +Note that `flutter test` prints `No tests ran` at the end; that is expected, +the benchmark results are printed above it. + + +## Suites + +- `children_traversal_benchmark.dart`: pure update-pass and render-pass + overhead (container iteration, recursion) over wide, nested, and deep trees + of no-op components, plus a barrier-dense tree where a fraction of the + parents override `updateTree`. +- `component_churn_benchmark.dart`: steady-state add/remove churn + (bullets/particles) at 1k and 10k populations, and bulk add/remove cycles + (level loads) through the lifecycle queue. +- `priority_change_benchmark.dart`: reordering costs: single-child priority + changes across many parents, and the y-sort pattern where a whole container + reorders every tick. +- `type_query_benchmark.dart`: maintenance and read cost of the + `register()`/`query()` type-query caches under mixed-type churn. +- `update_components_benchmark.dart`: end-to-end update pass with game-like + logic and inputs on a two-level tree. +- `render_components_benchmark.dart`: render pass over a randomized tree onto + a mock canvas. +- `components_at_point_benchmark.dart`: pointer hit testing + (`componentsAtPoint`) with and without the hit-test cache. +- `collision_detection_benchmark.dart`: the collision detection system with + flat and nested hitbox hierarchies. + + +## Writing benchmarks + +- Mount the game with `mountGame` from `common.dart`. A `FlameGame` that is + never resized, loaded, and mounted skips `onLoad` and bypasses the lifecycle + queue entirely, silently benchmarking the wrong code path. +- Seed every `Random`, and precompute random sequences in `setup` when `run` + needs them, so that each `run` invocation performs identical work. +- Keep per-run work small enough that the harness gets many samples within its + measurement window (aim for well under ~100ms per `run`). diff --git a/packages/flame/benchmark/children_traversal_benchmark.dart b/packages/flame/benchmark/children_traversal_benchmark.dart new file mode 100644 index 00000000000..b8f7badff1d --- /dev/null +++ b/packages/flame/benchmark/children_traversal_benchmark.dart @@ -0,0 +1,197 @@ +import 'dart:ui'; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:canvas_test/canvas_test.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; + +import 'common.dart'; + +const _dt = 1.0 / 60; + +/// These benchmarks measure the pure engine cost of the update and render +/// passes: iterating the children containers, the `updateTree`/`renderTree` +/// recursion, and the per-tick lifecycle-queue check. All components are +/// plain [Component]s with no-op [Component.update] and [Component.render], +/// so any time measured here is framework overhead rather than game logic. +/// +/// The tree shapes stress different aspects of the traversal: +/// - wide: one container holding many children (container iteration cost), +/// - nested: many small containers (iterator setup cost per parent), +/// - deep: a long parent chain (recursion depth cost), +/// - barrier: a nested tree where a fraction of the parents override +/// `updateTree` (via [HasTimeScale]). This is the realistic case for +/// evaluating flattened traversal designs, since components like +/// `SequenceEffect`, `Route`, and `HasTimeScale` users manage their own +/// subtree traversal and cannot be inlined into a flat list. +/// +/// Each run performs roughly one million component visits, so the reported +/// times are comparable across shapes and between the two passes. +abstract class _TraversalBenchmark extends AsyncBenchmarkBase { + _TraversalBenchmark( + super.name, { + required this.ticks, + required this.renderPass, + }); + + final int ticks; + final bool renderPass; + late final FlameGame _game; + late final Canvas _canvas; + + /// Builds the component tree under [world] and returns the total number of + /// components added. + int buildTree(World world); + + @override + Future setup() async { + _canvas = MockCanvas(); + _game = FlameGame(); + await mountGame(_game); + buildTree(_game.world); + await _game.ready(); + } + + @override + Future run() async { + if (renderPass) { + for (var i = 0; i < ticks; i++) { + _game.render(_canvas); + } + } else { + for (var i = 0; i < ticks; i++) { + _game.update(_dt); + } + } + } +} + +/// 10k components in a single children container. +class WideTreeBenchmark extends _TraversalBenchmark { + static const _amountChildren = 10000; + + WideTreeBenchmark({required super.renderPass}) + : super( + '${renderPass ? 'Render' : 'Update'} wide tree (10k x 1)', + ticks: 100, + ); + + static Future main() async { + await WideTreeBenchmark(renderPass: false).report(); + await WideTreeBenchmark(renderPass: true).report(); + } + + @override + int buildTree(World world) { + world.addAll(List.generate(_amountChildren, (_) => Component())); + return _amountChildren; + } +} + +/// 1k parents with 10 children each: many small children containers. +class NestedTreeBenchmark extends _TraversalBenchmark { + static const _amountParents = 1000; + static const _amountChildren = 10; + + NestedTreeBenchmark({required super.renderPass}) + : super( + '${renderPass ? 'Render' : 'Update'} nested tree (1k x 10)', + ticks: 90, + ); + + static Future main() async { + await NestedTreeBenchmark(renderPass: false).report(); + await NestedTreeBenchmark(renderPass: true).report(); + } + + @override + int buildTree(World world) { + world.addAll( + List.generate( + _amountParents, + (_) => Component( + children: List.generate(_amountChildren, (_) => Component()), + ), + ), + ); + return _amountParents * (_amountChildren + 1); + } +} + +/// A chain 100 levels deep where every level also holds 9 leaf children. +class DeepTreeBenchmark extends _TraversalBenchmark { + static const _depth = 100; + static const _leavesPerLevel = 9; + + DeepTreeBenchmark({required super.renderPass}) + : super( + '${renderPass ? 'Render' : 'Update'} deep tree (100 levels)', + ticks: 1000, + ); + + static Future main() async { + await DeepTreeBenchmark(renderPass: false).report(); + await DeepTreeBenchmark(renderPass: true).report(); + } + + @override + int buildTree(World world) { + // Build the chain bottom-up so each level is created with its children. + Component? next; + for (var level = 0; level < _depth; level++) { + next = Component( + children: [ + ...List.generate(_leavesPerLevel, (_) => Component()), + if (next != null) next, + ], + ); + } + world.add(next!); + return _depth * (_leavesPerLevel + 1); + } +} + +/// Same shape as the nested tree, but every 10th parent overrides +/// `updateTree` through [HasTimeScale] (with the time scale left at 1.0, so +/// the traversal work stays identical and only the override indirection is +/// measured). +class BarrierTreeUpdateBenchmark extends _TraversalBenchmark { + static const _amountParents = 1000; + static const _amountChildren = 10; + static const _barrierInterval = 10; + + BarrierTreeUpdateBenchmark() + : super( + 'Update barrier tree (1k x 10, 10% time-scaled)', + ticks: 90, + renderPass: false, + ); + + static Future main() async { + await BarrierTreeUpdateBenchmark().report(); + } + + @override + int buildTree(World world) { + world.addAll( + List.generate(_amountParents, (i) { + final children = List.generate(_amountChildren, (_) => Component()); + return i % _barrierInterval == 0 + ? _TimeScaledParent(children: children) + : Component(children: children); + }), + ); + return _amountParents * (_amountChildren + 1); + } +} + +class _TimeScaledParent extends Component with HasTimeScale { + _TimeScaledParent({super.children}); +} + +Future main() async { + await WideTreeBenchmark.main(); + await NestedTreeBenchmark.main(); + await DeepTreeBenchmark.main(); + await BarrierTreeUpdateBenchmark.main(); +} diff --git a/packages/flame/benchmark/common.dart b/packages/flame/benchmark/common.dart new file mode 100644 index 00000000000..9ba11ce0387 --- /dev/null +++ b/packages/flame/benchmark/common.dart @@ -0,0 +1,17 @@ +import 'package:flame/game.dart'; + +/// Brings a [FlameGame] to a fully loaded and mounted state without a +/// [GameWidget], mirroring what `flame_test`'s `initializeGame` does. +/// +/// Without this, `hasLayout` stays false: components are placed directly into +/// the children containers without ever loading or mounting, `onLoad` never +/// runs, and lifecycle events bypass the queue. Benchmarks would then measure +/// a very different code path than a real game. +Future mountGame(FlameGame game, {Vector2? size}) async { + game.onGameResize(size ?? Vector2(800, 600)); + // ignore: invalid_use_of_internal_member + await game.load(); + // ignore: invalid_use_of_internal_member + game.mount(); + game.update(0); +} diff --git a/packages/flame/benchmark/component_churn_benchmark.dart b/packages/flame/benchmark/component_churn_benchmark.dart new file mode 100644 index 00000000000..3d71659a5ab --- /dev/null +++ b/packages/flame/benchmark/component_churn_benchmark.dart @@ -0,0 +1,111 @@ +import 'dart:collection'; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; + +import 'common.dart'; + +const _dt = 1.0 / 60; + +/// Measures the steady-state add/remove churn typical of bullets, particles +/// and spawners: every tick a batch of components is added, an older batch is +/// removed, and the game updates. This exercises the full lifecycle pipeline +/// (enqueueing, [FlameGame.processLifecycleEvents], loading, mounting, +/// unmounting) plus the children-container add and remove operations, amid a +/// stable population of live components. +/// +/// The benchmark runs at two population sizes: removal cost inside one large +/// sibling container scales differently per container implementation (for +/// example, a sorted list shifts elements on every removal, while a tree does +/// a logarithmic lookup), so a container replacement must be evaluated at +/// both sizes. +class ComponentChurnBenchmark extends AsyncBenchmarkBase { + static const _batchSize = 100; + static const _liveBatches = 5; + static const _amountTicks = 60; + + final int staticPopulation; + late final FlameGame _game; + final Queue> _batches = Queue(); + + ComponentChurnBenchmark({required this.staticPopulation}) + : super( + 'Lifecycle churn ' + '(100 per tick, ${staticPopulation ~/ 1000}k population)', + ); + + static Future main() async { + await ComponentChurnBenchmark(staticPopulation: 1000).report(); + await ComponentChurnBenchmark(staticPopulation: 10000).report(); + } + + List _newBatch() { + return List.generate(_batchSize, (_) => Component()); + } + + @override + Future setup() async { + _game = FlameGame(); + await mountGame(_game); + await _game.world.addAll( + List.generate(staticPopulation, (_) => Component()), + ); + for (var i = 0; i < _liveBatches; i++) { + final batch = _newBatch(); + _batches.addLast(batch); + await _game.world.addAll(batch); + } + await _game.ready(); + } + + @override + Future run() async { + for (var i = 0; i < _amountTicks; i++) { + _game.world.removeAll(_batches.removeFirst()); + final batch = _newBatch(); + _batches.addLast(batch); + await _game.world.addAll(batch); + _game.update(_dt); + } + } +} + +/// Measures bulk mounting and unmounting: 1000 components are added and +/// processed in one tick, then all removed and processed in the next. This +/// stresses [FlameGame.processLifecycleEvents] with a long event queue, as +/// happens when levels are loaded and torn down. +class MassAddRemoveBenchmark extends AsyncBenchmarkBase { + static const _amountComponents = 1000; + static const _amountCycles = 5; + + late final FlameGame _game; + + MassAddRemoveBenchmark() : super('Mass add/remove (1k per cycle)'); + + static Future main() async { + await MassAddRemoveBenchmark().report(); + } + + @override + Future setup() async { + _game = FlameGame(); + await mountGame(_game); + } + + @override + Future run() async { + for (var i = 0; i < _amountCycles; i++) { + final components = List.generate(_amountComponents, (_) => Component()); + await _game.world.addAll(components); + _game.update(_dt); + _game.world.removeAll(components); + _game.update(_dt); + } + } +} + +Future main() async { + await ComponentChurnBenchmark.main(); + await MassAddRemoveBenchmark.main(); +} diff --git a/packages/flame/benchmark/main.dart b/packages/flame/benchmark/main.dart index 61eb9b073e1..723946a20f9 100644 --- a/packages/flame/benchmark/main.dart +++ b/packages/flame/benchmark/main.dart @@ -1,7 +1,19 @@ -import 'render_components_benchmark.dart'; -import 'update_components_benchmark.dart'; +import 'children_traversal_benchmark.dart' as children_traversal; +import 'collision_detection_benchmark.dart' as collision_detection; +import 'component_churn_benchmark.dart' as component_churn; +import 'components_at_point_benchmark.dart' as components_at_point; +import 'priority_change_benchmark.dart' as priority_change; +import 'render_components_benchmark.dart' as render_components; +import 'type_query_benchmark.dart' as type_query; +import 'update_components_benchmark.dart' as update_components; Future main() async { - await RenderComponentsBenchmark.main(); - await UpdateComponentsBenchmark.main(); + await children_traversal.main(); + await component_churn.main(); + await priority_change.main(); + await type_query.main(); + await update_components.main(); + await render_components.main(); + await components_at_point.main(); + await collision_detection.main(); } diff --git a/packages/flame/benchmark/priority_change_benchmark.dart b/packages/flame/benchmark/priority_change_benchmark.dart new file mode 100644 index 00000000000..8e79199cdae --- /dev/null +++ b/packages/flame/benchmark/priority_change_benchmark.dart @@ -0,0 +1,129 @@ +import 'dart:math'; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; + +import 'common.dart'; + +const _dt = 1.0 / 60; +const _randomSeed = 69420; + +/// Measures the cost of changing the priority of a single child in many +/// sibling containers: every tick, one child per parent gets a new priority +/// and the next update processes the resulting reorder events. Today each +/// changed parent pays a full rebalance of all its children, so this +/// benchmark captures the "one bullet changes z-order" worst case. +/// +/// All priority values and child picks are precomputed in [setup] and replayed +/// identically on every run, so results are comparable across engine versions. +class SiblingPriorityChangeBenchmark extends AsyncBenchmarkBase { + static const _amountParents = 100; + static const _amountChildren = 50; + static const _amountTicks = 50; + + late final FlameGame _game; + late final List> _children; + late final List> _changes; + + SiblingPriorityChangeBenchmark() + : super('Priority change (1 child per parent)'); + + static Future main() async { + await SiblingPriorityChangeBenchmark().report(); + } + + @override + Future setup() async { + final random = Random(_randomSeed); + _game = FlameGame(); + await mountGame(_game); + final parents = List.generate( + _amountParents, + (_) => Component( + children: List.generate( + _amountChildren, + (i) => Component(priority: i), + ), + ), + ); + await _game.world.addAll(parents); + await _game.ready(); + _children = [ + for (final parent in parents) parent.children.toList(growable: false), + ]; + + // For every tick, one (childIndex, newPriority) pair per parent. + _changes = List.generate(_amountTicks, (_) { + return List.generate(_amountParents, (_) { + return ( + random.nextInt(_amountChildren), + random.nextInt(_amountChildren * 20), + ); + }); + }); + } + + @override + Future run() async { + for (final tickChanges in _changes) { + for (var parent = 0; parent < _amountParents; parent++) { + final (childIndex, newPriority) = tickChanges[parent]; + _children[parent][childIndex].priority = newPriority; + } + _game.update(_dt); + } + } +} + +/// Measures the y-sort pattern: every child of a single large container gets +/// a new priority every tick (as when sorting sprites by their y coordinate +/// while they move), followed by an update that reorders the whole container. +class YSortPriorityBenchmark extends AsyncBenchmarkBase { + static const _amountChildren = 1000; + static const _amountTicks = 30; + + late final FlameGame _game; + late final List _children; + late final List> _priorities; + + YSortPriorityBenchmark() : super('Priority change (y-sort, all children)'); + + static Future main() async { + await YSortPriorityBenchmark().report(); + } + + @override + Future setup() async { + final random = Random(_randomSeed); + _game = FlameGame(); + await mountGame(_game); + await _game.world.addAll( + List.generate(_amountChildren, (i) => Component(priority: i)), + ); + await _game.ready(); + _children = _game.world.children.toList(growable: false); + + // A fresh permutation of priorities for every tick, so almost every child + // actually changes value (identical consecutive values are skipped by the + // priority setter). + _priorities = List.generate(_amountTicks, (_) { + return List.generate(_amountChildren, (i) => i)..shuffle(random); + }); + } + + @override + Future run() async { + for (final tickPriorities in _priorities) { + for (var i = 0; i < _children.length; i++) { + _children[i].priority = tickPriorities[i]; + } + _game.update(_dt); + } + } +} + +Future main() async { + await SiblingPriorityChangeBenchmark.main(); + await YSortPriorityBenchmark.main(); +} diff --git a/packages/flame/benchmark/render_components_benchmark.dart b/packages/flame/benchmark/render_components_benchmark.dart index 5406b4966cc..66a9b0fe08f 100644 --- a/packages/flame/benchmark/render_components_benchmark.dart +++ b/packages/flame/benchmark/render_components_benchmark.dart @@ -7,6 +7,8 @@ import 'package:canvas_test/canvas_test.dart'; import 'package:flame/components.dart'; import 'package:flame/game.dart'; +import 'common.dart'; + const _amountComponents = 500; const _amountTicks = 2; const _depthMultiplier = 0.25; @@ -29,7 +31,7 @@ class RenderComponentsBenchmark extends AsyncBenchmarkBase { _canvas = MockCanvas(); _game = FlameGame(); - _game.onGameResize(Vector2.all(100.0)); + await mountGame(_game, size: Vector2.all(100.0)); await _game.addAll( List.generate( @@ -75,3 +77,5 @@ class _BenchmarkComponent extends PositionComponent { } } } + +Future main() => RenderComponentsBenchmark.main(); diff --git a/packages/flame/benchmark/type_query_benchmark.dart b/packages/flame/benchmark/type_query_benchmark.dart new file mode 100644 index 00000000000..b235dc18bc3 --- /dev/null +++ b/packages/flame/benchmark/type_query_benchmark.dart @@ -0,0 +1,90 @@ +import 'dart:collection'; + +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; + +import 'common.dart'; + +const _dt = 1.0 / 60; + +/// Measures the per-type query caches of the children container +/// (`children.register()` / `children.query()`), which Flame uses +/// internally for hitboxes (`GestureHitboxes`), post-processing +/// (`CameraComponent`), and layout (`LinearLayoutComponent`). +/// +/// Every add and remove has to update all registered caches, so this +/// benchmark churns a mixed-type population in a container with two +/// registered queries while reading one query per tick. A children-container +/// replacement must keep both the cache-maintenance and the query-read cost +/// at least this fast. +class TypeQueryChurnBenchmark extends AsyncBenchmarkBase { + static const _amountStatic = 1000; + static const _batchSize = 50; + static const _liveBatches = 5; + static const _amountTicks = 60; + static const _markedInterval = 5; + + late final FlameGame _game; + final Queue> _batches = Queue(); + + TypeQueryChurnBenchmark() : super('Type-query churn (2 registered queries)'); + + static Future main() async { + await TypeQueryChurnBenchmark().report(); + } + + List _newBatch() { + return List.generate( + _batchSize, + (i) => i % _markedInterval == 0 ? _MarkedComponent() : _PlainComponent(), + ); + } + + @override + Future setup() async { + _game = FlameGame(); + await mountGame(_game); + _game.world.children.register<_MarkedComponent>(); + _game.world.children.register<_PlainComponent>(); + await _game.world.addAll( + List.generate( + _amountStatic, + (i) => + i % _markedInterval == 0 ? _MarkedComponent() : _PlainComponent(), + ), + ); + for (var i = 0; i < _liveBatches; i++) { + final batch = _newBatch(); + _batches.addLast(batch); + await _game.world.addAll(batch); + } + await _game.ready(); + } + + @override + Future run() async { + var visited = 0; + for (var i = 0; i < _amountTicks; i++) { + _game.world.removeAll(_batches.removeFirst()); + final batch = _newBatch(); + _batches.addLast(batch); + await _game.world.addAll(batch); + for (final marked in _game.world.children.query<_MarkedComponent>()) { + visited += marked.marker; + } + _game.update(_dt); + } + assert(visited > 0); + } +} + +class _MarkedComponent extends Component { + final int marker = 1; +} + +class _PlainComponent extends Component {} + +Future main() async { + await TypeQueryChurnBenchmark.main(); +} diff --git a/packages/flame/benchmark/update_components_benchmark.dart b/packages/flame/benchmark/update_components_benchmark.dart index bfa704d075d..83a5c749dd9 100644 --- a/packages/flame/benchmark/update_components_benchmark.dart +++ b/packages/flame/benchmark/update_components_benchmark.dart @@ -4,9 +4,11 @@ import 'package:benchmark_harness/benchmark_harness.dart'; import 'package:flame/components.dart'; import 'package:flame/game.dart'; +import 'common.dart'; + const _amountComponents = 1000; -const _amountTicks = 2000; -const _amountInputs = 500; +const _amountTicks = 500; +const _amountInputs = 125; const _amountChildren = 10; class UpdateComponentsBenchmark extends AsyncBenchmarkBase { @@ -28,6 +30,10 @@ class UpdateComponentsBenchmark extends AsyncBenchmarkBase { @override Future setup() async { _game = FlameGame(); + // Mount the game properly so that the components load and mount for real: + // without this, onLoad never runs and the child components are never + // created, making the benchmark measure a tree 11x smaller than intended. + await mountGame(_game); await _game.addAll( List.generate(_amountComponents, _BenchmarkComponent.new), ); @@ -103,3 +109,5 @@ class _BenchmarkComponent extends PositionComponent { @override String toString() => '[Component $id]'; } + +Future main() => UpdateComponentsBenchmark.main();