[flame_3d] Children added to an Object3D after it was rendered can be culled while on screen

Cerrado
#4,164 0 comentarios 0 reacciones 1 asignado Ver en GitHub

Los mantenedores suelen responder en 1 día

@ufrshubham ya está trabajando en esto.

Desde el 29/10/2026.

  • #4180 de @ufrshubham — fusionado

Evaluación

Este issue todavía no se ha evaluado.

Descripción

bug flame_3d
What happened?

When a child is added to a Component3D whose AABB has already been computed, the parent's cached AABB is not invalidated. The parent's AABB is the hull of its own geometry and the AABBs of its children, so it no longer covers the new child.

This matters when the parent is an Object3D: Object3D.renderTree frustum culls the parent's AABB first and skips the whole subtree when it is outside of the view. A child added inside the view, but outside of the parent's stale AABB, is therefore never rendered.

Component3D.onMount only marks the new child's own world transform as dirty, nothing marks the parent's AABB as dirty. A typical case is a spawner or pool that is an Object3D and adds children to itself at runtime.

What do you expect?

Adding a child to a component should invalidate the cached AABB of that component (and its ancestors), so that the new child is taken into account when culling.

How can we reproduce this?
  1. Run the main.dart below with flutter run --enable-flutter-gpu (on Windows also add --enable-impeller).
  2. The Spawner is an Object3D that starts with a single (blue) child behind the camera, so its AABB is behind the camera too. After one second it adds a green cube to itself, in front of the camera.
  3. Only the red control cube is ever rendered. The green cube is skipped because the Spawner is culled using its stale AABB.
What steps should take to fix this?

Mark the parent's AABB dirty when a component is mounted:

@override
void onMount() {
  super.onMount();
  _markWorldTransformDirty();
  if (parent case final Component3D parent) {
    parent.markAabbDirty();
  }
}

Doing the same in onRemove is optional. Without it the parent's AABB stays bigger than needed after a child is removed. That isn't visible and only makes culling less effective, but it keeps the bounds tight.

Marking is cheap since it stops at the first ancestor that is already dirty, and the parent's AABB is only recomputed lazily on the next read. This is the same cost as a child moving, which already marks its parents dirty.

Do have an example of where the bug occurs?
import 'package:flame/game.dart';
import 'package:flame_3d/camera.dart';
import 'package:flame_3d/components.dart';
import 'package:flame_3d/game.dart';
import 'package:flame_3d/graphics.dart';
import 'package:flame_3d/resources.dart';
import 'package:flutter/widgets.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await GpuBackend.initialize();
  runApp(GameWidget(game: ReproGame()));
}

class ReproGame extends FlameGame3D {
  ReproGame()
    : super(
        camera: CameraComponent3D(
          position: Vector3(0, 2, -6),
          target: Vector3(0, 1, 0),
        ),
      );

  @override
  Future<void> onLoad() async {
    world.addAll([
      // Red: not nested, world position (1.5, 1, 0). Rendered.
      MeshComponent(
        mesh: _cubeMesh(const Color(0xFFFF0000)),
        position: Vector3(1.5, 1, 0),
      ),
      // A group with a single child behind the camera. After one second a
      // green cube is added to it at world position (-1.5, 1, 0). The green
      // cube is never rendered.
      Spawner(
        initial: MeshComponent(
          mesh: _cubeMesh(const Color(0xFF0000FF)),
          position: Vector3(0, 1, -20),
        ),
        spawned: MeshComponent(
          mesh: _cubeMesh(const Color(0xFF00FF00)),
          position: Vector3(-1.5, 1, 0),
        ),
      ),
    ]);
  }

  Mesh _cubeMesh(Color color) => CuboidMesh(
    size: Vector3.all(1),
    material: UnlitMaterial(albedoColor: color),
  );
}

/// An [Object3D] group that adds [spawned] to itself after one second.
class Spawner extends Object3D {
  Spawner({required Component3D initial, required this.spawned})
    : super(children: [initial]);

  final Component3D spawned;
  double _elapsed = 0;

  @override
  void update(double dt) {
    super.update(dt);
    _elapsed += dt;
    if (_elapsed > 1 && spawned.parent == null) {
      add(spawned);
    }
  }

  @override
  void draw(RenderContext context) {}
}
Relevant log output
No errors or warnings are logged.
Execute in a terminal and put output into the code block below
Flutter 3.47.8 • channel stable • https://github.com/flutter/flutter.git
Framework • revision 737ba8f885 (6 days ago) • 2026-10-22 21:37:31 -0700
Engine • hash dfd2e6cb7d319db05e3bf1503f3e2ac60f6288a2 (revision cf636b1dfb) (6 days ago) • 2026-10-23 02:13:38.000Z
Tools • Dart 3.13.7 • DevTools 2.60.0
Affected platforms

All

Other information

Reproduced on Windows (Impeller with the OpenGL ES backend), but the culling logic is platform independent.

Related to #4163, which is the other case of cached AABBs not being invalidated (when an ancestor moves). Both touch the same code in Component3D, but they can be reproduced and fixed independently.

If the parent is a plain Component3D the bug isn't visible, since only Object3D culls its subtree.

Are you interested in working on a PR for this?
  • I want to work on this
Lenguaje dominante
Dart
Estrellas
10.8k
Forks
1.1k
Merge medio
1 d 21 h
PR fusionados (30 d)
53

Preparar el entorno

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de flame-engine/flame

Todos los issues de flame-engine/flame

Issues similares

Más issues de Dart

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.