Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions packages/flame/benchmark/README.md
Original file line number Diff line number Diff line change
@@ -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 <device>
```

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<T>()`/`query<T>()` 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`).
197 changes: 197 additions & 0 deletions packages/flame/benchmark/children_traversal_benchmark.dart
Original file line number Diff line number Diff line change
@@ -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<void> setup() async {
_canvas = MockCanvas();
_game = FlameGame();
await mountGame(_game);
buildTree(_game.world);
await _game.ready();
}

@override
Future<void> 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<void> 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<void> 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<void> 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<void> 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<void> main() async {
await WideTreeBenchmark.main();
await NestedTreeBenchmark.main();
await DeepTreeBenchmark.main();
await BarrierTreeUpdateBenchmark.main();
}
17 changes: 17 additions & 0 deletions packages/flame/benchmark/common.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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);
}
111 changes: 111 additions & 0 deletions packages/flame/benchmark/component_churn_benchmark.dart
Original file line number Diff line number Diff line change
@@ -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<List<Component>> _batches = Queue();

ComponentChurnBenchmark({required this.staticPopulation})
: super(
'Lifecycle churn '
'(100 per tick, ${staticPopulation ~/ 1000}k population)',
);

static Future<void> main() async {
await ComponentChurnBenchmark(staticPopulation: 1000).report();
await ComponentChurnBenchmark(staticPopulation: 10000).report();
}

List<Component> _newBatch() {
return List.generate(_batchSize, (_) => Component());
}

@override
Future<void> 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<void> 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<void> main() async {
await MassAddRemoveBenchmark().report();
}

@override
Future<void> setup() async {
_game = FlameGame();
await mountGame(_game);
}

@override
Future<void> 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<void> main() async {
await ComponentChurnBenchmark.main();
await MassAddRemoveBenchmark.main();
}
Loading
Loading