new BatchedAnimationComponent
Problem to solve
Feature Proposal: BatchedAnimationComponent for High-Volume Animated Instances
Summary
This proposal introduces a new core component, BatchedAnimationComponent, designed to drastically reduce engine overhead when rendering thousands of animated sprites that share the same animation sequence (e.g., particles, ambient effects, large groups of identical enemies).
The implementation leverages manual rendering within a single component and incorporates built-in Frustum Culling, resulting in measured performance gains
The Problem: Component Overhead
When a user needs to display thousands of identical animated elements—such as a large swarm of insects, numerous embers, or a high-density particle field—the standard approach requires creating thousands of separate SpriteAnimationComponent instances.
This approach introduces significant overhead in the engine for every frame:
Component Tree Traversal: The engine must iterate over and manage thousands of entries in the component tree.
Individual update/render Calls: Each of the thousands of components incurs the cost of its own virtual method calls, even if its update method is empty.
Redundant Work: The animation ticker is advanced independently for each component, even though the animation frame should be identical across all instances.
The Solution: BatchedAnimationComponent
The BatchedAnimationComponent resolves this by treating the objects as lightweight data instances rather than heavy-weight components.
Proposal
Core Optimizations:
Manual Rendering and Single Animation Ticker: The component holds a single SpriteAnimation and advances its ticker only once per frame. Its render method then manually iterates over a simple List<AnimatedInstanceData> and calls currentSprite.render(...) for each instance. This bypasses the engine's component management overhead.
Built-in Frustum Culling: The component efficiently checks the bounding box of each instance against game.camera.visibleWorldRect. If an instance is outside the visible area, its render call is immediately skipped, providing massive performance gains on large maps or zoomed-in views.
Proposed Component Structure
Below are the classes that demonstrate the implemented solution:
- AnimatedInstanceData (Lightweight Data Container)
class AnimatedInstanceData {
AnimatedInstanceData({
required this.size,
required this.position,
this.anchor = Anchor.topLeft,
this.overridePaint,
});
Vector2 position;
Anchor anchor;
Vector2 size;
Paint? overridePaint; // Allows per-instance coloring/opacity
}
- BatchedAnimationComponent (The Engine Component)
class BatchedAnimationComponent<T extends FlameGame>
extends SpriteAnimationComponent
with HasGameReference<T> {
BatchedAnimationComponent({
required this.instances,
super.animation,
super.position,
super.size,
super.priority,
super.key,
});
List<AnimatedInstanceData> instances;
// 1. Declare reusable temporary variables outside the render method
// (as private fields of the component)
var _instanceRect = Rect.zero;
@override
void render(Canvas canvas) {
final currentSprite = animationTicker?.getSprite();
if (currentSprite == null) {
return;
}
// Frustum Culling Setup
final Rect visibleRect = game.camera.visibleWorldRect;
final anchorOffset = Vector2.zero(); // Avoid GC by reusing
for (final instance in instances) {
// Calculate bounding box start (minX, minY)
instance.anchor.getOffsetToTopLeft(instance.size).copyInto(anchorOffset);
final minX = instance.position.x - anchorOffset.x;
final minY = instance.position.y - anchorOffset.y;
_instanceRect = Rect.fromLTWH(
minX,
minY,
instance.size.x,
instance.size.y,
);
// Culling Check
if (!visibleRect.overlaps(_instanceRect)) {
continue;
}
// Render the single sprite instance
final renderPaint = instance.overridePaint ?? paint;
currentSprite.render(
canvas,
position: instance.position,
size: instance.size,
anchor: instance.anchor,
overridePaint: renderPaint,
);
}
}
}
More information
No response
Other
- [ ] Are you interested in working on a PR for this?
benchmark to compare
import 'dart:math';
import 'dart:ui' hide TextStyle;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flame/input.dart';
import 'package:flame/text.dart';
import 'package:flutter/material.dart' show Colors;
class BatchedBenchmarkExample extends FlameGame with PanDetector {
static const description = '''
See how many SpriteAnimationComponent's your platform can handle before it
starts to drop in FPS.
1000 animation components are added per tap.
''';
BatchedBenchmarkExample();
final emberSize = Vector2.all(20);
late final TextComponent emberCounter;
late final HudButtonComponent addEmberButton;
final counterPrefix = 'Animations: ';
final Random random = Random();
@override
void onPanUpdate(DragUpdateInfo info) {
// add camera pan to test rectangle
camera.moveBy(-info.delta.global);
super.onPanUpdate(info);
}
@override
Future<void> onLoad() async {
await camera.viewport.addAll([
FpsTextComponent(
position: size - Vector2(10, 50),
anchor: Anchor.bottomRight,
),
emberCounter = TextComponent(
position: size - Vector2(10, 25),
anchor: Anchor.bottomRight,
priority: 1,
),
addEmberButton = HudButtonComponent(
button: TextBoxComponent(
text: 'Add 1000 embers',
textRenderer: TextPaint(
style: const TextStyle(
fontSize: 24,
color: Colors.cyan,
backgroundColor: Colors.black,
),
),
boxConfig: const TextBoxConfig(maxWidth: 300),
),
buttonDown: TextBoxComponent(
text: 'Add 1000 embers',
textRenderer: TextPaint(
style: const TextStyle(
fontSize: 23,
color: Colors.cyan,
backgroundColor: Colors.black,
),
),
boxConfig: const TextBoxConfig(maxWidth: 300),
),
position: size - Vector2(10, 100),
anchor: Anchor.bottomRight,
priority: 1,
onPressed: _onPressed,
),
]);
children.register<MultiEmber>();
}
void _onPressed() {
final list = List.generate(
1000,
(k) => AnimatedInstanceData(
position: Vector2(
(size.x / 2) * random.nextDouble() * (random.nextBool() ? 1 : -1),
(size.y / 2) * random.nextDouble() * (random.nextBool() ? 1 : -1),
),
// size: Vector2.all(random.nextInt(24) + 10),
size: emberSize,
/// expensive , but useful , if used properly
// overridePaint: Paint()
// ..colorFilter = ColorFilter.mode(
// Color.fromARGB(
// 255,
// random.nextInt(256),
// random.nextInt(256),
// random.nextInt(256),
// ),
// BlendMode.modulate,
// ),
),
);
var ember = world.children.query<MultiEmber>().firstOrNull;
if (ember == null) {
ember ??= MultiEmber(instances: list, size: size / 2);
world.add(ember);
} else {
ember.instances.addAll(list);
}
}
@override
void update(double dt) {
super.update(dt);
emberCounter.text =
'$counterPrefix ${world.children.query<MultiEmber>().firstOrNull?.instances.length ?? 0}';
}
}
class MultiEmber extends BatchedAnimationComponent {
MultiEmber({
required super.instances,
super.position,
super.size,
super.priority,
super.key,
});
@override
Future<void> onLoad() async {
animation = await SpriteAnimation.load(
'animations/ember.png',
SpriteAnimationData.sequenced(
amount: 3,
textureSize: Vector2.all(16),
stepTime: 0.15,
),
);
}
}
class BatchedAnimationComponent<T extends FlameGame>
extends SpriteAnimationComponent
with HasGameReference<T> {
BatchedAnimationComponent({
required this.instances,
super.animation,
super.position,
super.size,
super.priority,
super.key,
});
List<AnimatedInstanceData> instances;
// 1. Declare reusable temporary variables outside the render method
// (as private fields of the component)
var _instanceRect = Rect.zero;
@override
void render(Canvas canvas) {
final currentSprite = animationTicker?.getSprite();
if (currentSprite == null) {
return;
}
// 1. Get the current camera view bounds (the visible area in world coordinates)
final visibleRect = game.camera.visibleWorldRect;
// The Anchor class helps calculate the offset for the instance's bounding box.
// We can reuse a temporary Vector2 for the offset calculation to avoid garbage collection (GC) overhead.
for (var i = 0; i < instances.length; i++) {
final instance = instances[i];
// Determine the top-left corner (minX, minY) of the instance's bounding box
final anchorOffset = instance.anchor.getOffsetToTopLeft(instance.size);
final minX = instance.position.x - anchorOffset.x;
final minY = instance.position.y - anchorOffset.y;
// 2. Create the instance's bounding box (using its calculated top-left and size)
_instanceRect = Rect.fromLTWH(
minX,
minY,
instance.size.x,
instance.size.y,
);
// 3. Frustum Culling Check
if (!visibleRect.overlaps(_instanceRect)) {
// Instance is completely outside the screen, SKIP the render call.
continue;
}
// --- 4. Render (Only if the instance is visible) ---
final renderPaint = instance.overridePaint ?? paint;
currentSprite.render(
canvas,
position: instance.position,
size: instance.size,
anchor: instance.anchor,
overridePaint: renderPaint,
);
}
}
}
class AnimatedInstanceData {
AnimatedInstanceData({
required this.size,
required this.position,
this.anchor = Anchor.topLeft,
this.overridePaint,
});
Vector2 position;
Anchor anchor;
Vector2 size;
Paint? overridePaint;
}
/// Extension methods for the [Anchor] enum.
extension AnchorExtension on Anchor {
/// Calculates the Vector2 offset required to move from the anchor point
/// (which is [0, 0] relative to the instance's position) to the top-left corner
/// (minX, minY) of the instance's bounding box.
///
/// The result is a Vector2 representing the distance the top-left corner is
/// away from the position defined by the anchor.
///
/// Example: If the anchor is Anchor.center (0.5, 0.5) and size is (100, 100),
/// the offset will be (50, 50).
Vector2 getOffsetToTopLeft(Vector2 size) {
// Anchor values range from 0.0 (left/top) to 1.0 (right/bottom).
final double offsetX = x * size.x;
final double offsetY = y * size.y;
// The offset to the top-left corner (minX, minY) is the negative of the anchor offset.
return Vector2(offsetX, offsetY);
}
}
Sounds good, I'll assign you to the issue!
Two initial notes about the implementation:
- The
instancesargument needs a better name. - The culling feels too specific to be just within one component, we should come up with a composable way to add it to any component in a separate issue.