flame icon indicating copy to clipboard operation
flame copied to clipboard

new BatchedAnimationComponent

Open s1r1m1r1 opened this issue 1 month ago • 2 comments

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:

  1. 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
}
  1. 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?

s1r1m1r1 avatar Nov 18 '25 03:11 s1r1m1r1

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);
  }
}


s1r1m1r1 avatar Nov 18 '25 03:11 s1r1m1r1

Sounds good, I'll assign you to the issue!

Two initial notes about the implementation:

  • The instances argument 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.

spydon avatar Nov 18 '25 06:11 spydon