flame-engine / flame

A Flutter based game engine.
https://flame-engine.org
MIT License
9.19k stars 899 forks source link

Invisible Rigid Body #3213

Closed DavidChZh closed 2 months ago

DavidChZh commented 3 months ago

What happened?

As you can see in the video, in my current business, I need to achieve the effect of a polygonal rigid body becoming larger. I achieve this by creating and removing rigid bodies multiple times. When I frequently create rigid bodies and quickly remove them, some invisible rigid bodies will be generated. This phenomenon is sporadic but very frequent, and it is more likely to occur when multiple rigid bodies are simultaneously enlarged. From the log, the total number of rigid bodies and the total number displayed on the screen are consistent. I am confused about where these invisible rigid bodies come from. I think these invisible ones may not be rigid bodies, because I have turned on the debug mode. If they are rigid bodies, they should be displayed on the screen. I really can't find a reason that can explain it. Do you have any ideas to provide?

What do you expect?

No more invisible rigid bodies are generated

How can we reproduce this?

No response

What steps should take to fix this?

No response

Do have an example of where the bug occurs?

No response

Relevant log output

No response

Execute in a terminal and put output into the code block below

[✓] Flutter (Channel stable, 3.22.0, on macOS 14.3.1 23D60 darwin-arm64, locale zh-Hans-CN)
    • Flutter version 3.22.0 on channel stable at /Users/evdo/Desktop/development/flutter_sdk
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 5dcb86f68f (8 weeks ago), 2024-05-09 07:39:20 -0500
    • Engine revision f6344b75dc
    • Dart version 3.4.0
    • DevTools version 2.34.3
    • Pub download mirror https://pub.flutter-io.cn
    • Flutter download mirror https://storage.flutter-io.cn

[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
    • Android SDK at /Users/evdo/Library/Android/sdk
    • Platform android-34, build-tools 34.0.0
    • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 17.0.6+0-17.0.6b829.9-10027231)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 15.1)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 15C65
    • CocoaPods version 1.15.2

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2022.3)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 17.0.6+0-17.0.6b829.9-10027231)

[✓] VS Code (version 1.90.0)
    • VS Code at /Users/evdo/Desktop/Visual Studio Code.app/Contents
    • Flutter extension version 3.90.0

[✓] Connected device (5 available)
    • sdk gphone64 arm64 (mobile)     • emulator-5554             • android-arm64  • Android 14 (API 34) (emulator)
    • iPhone14 (mobile)               • 00008110-001A64CE2E40A01E • ios            • iOS 17.5.1 21F90
    • macOS (desktop)                 • macos                     • darwin-arm64   • macOS 14.3.1 23D60 darwin-arm64
    • Mac Designed for iPad (desktop) • mac-designed-for-ipad     • darwin         • macOS 14.3.1 23D60 darwin-arm64
    • Chrome (web)                    • chrome                    • web-javascript • Google Chrome 126.0.6478.127

[✓] Network resources
    • All expected network resources are available.

• No issues found!

Affected platforms

Android

Other information

  flame: ^1.17.0
  flame_forge2d: ^0.18.0

Are you interested in working on a PR for this?

DavidChZh commented 3 months ago

https://github.com/flame-engine/flame/assets/24714777/9e033ddb-0d13-4ab5-9aae-1e5074324603

ufrshubham commented 3 months ago

To confirm your doubt about invisible bodies just check the count of world.physicalWorld.bodies list.

DavidChZh commented 3 months ago

To confirm your doubt about invisible bodies just check the count of world.physicalWorld.bodies list.

Thank you very much! I checked world.physicalWorld.bodies and found the hidden bodies. Strangely, the userData property parent of the hidden bodies is null, while the parent of a normal body points to the world. It does not exist in the world, which means it cannot be removed. I find it hard to understand how it is generated

ufrshubham commented 3 months ago

Strangely, the userData property parent of the hidden bodies is null, while the parent of a normal body points to the world.

The userData shouldn't automatically point to anything. Ideally you should be the one who sets userData while creating the body and fixtures. I think the only problematic thing is, somehow some of the forge2d bodies are getting disconnected from their BodyComponent, that is why you don't see them being rendered even though they are still there.

Will it be possible for you to create a simple reproducible example for this? Also, for the expanding behavior, maybe you can create multiple fixtures with varying scale and keep only 1 of them as non-sensor. Then instead of creating and destroying the bodies, you can just change the isSensor flag for the fixtures such that 1 bigger fixture gradually becomes non-sensor and rest of them remain sensors. I think it will be a bit more efficient on the physics engine than spawning multiple bigger bodies.

DavidChZh commented 3 months ago

Strangely, the userData property parent of the hidden bodies is null, while the parent of a normal body points to the world.

The userData shouldn't automatically point to anything. Ideally you should be the one who sets userData while creating the body and fixtures. I think the only problematic thing is, somehow some of the forge2d bodies are getting disconnected from their BodyComponent, that is why you don't see them being rendered even though they are still there.

Will it be possible for you to create a simple reproducible example for this? Also, for the expanding behavior, maybe you can create multiple fixtures with varying scale and keep only 1 of them as non-sensor. Then instead of creating and destroying the bodies, you can just change the isSensor flag for the fixtures such that 1 bigger fixture gradually becomes non-sensor and rest of them remain sensors. I think it will be a bit more efficient on the physics engine than spawning multiple bigger bodies.

Thank you very much, it helps me a lot. My current approach is to use destroyBody to remove disconnected bodies when they are detected. When I have some free time, I will try to use multiple fixtures and control them through isSensor to achieve the enlargement effect. I have currently observed that this problem occurs when BodyComponent is created and removed at a high frequency.

DavidChZh commented 2 months ago

Strangely, the userData property parent of the hidden bodies is null, while the parent of a normal body points to the world.

The userData shouldn't automatically point to anything. Ideally you should be the one who sets userData while creating the body and fixtures. I think the only problematic thing is, somehow some of the forge2d bodies are getting disconnected from their BodyComponent, that is why you don't see them being rendered even though they are still there.

Will it be possible for you to create a simple reproducible example for this? Also, for the expanding behavior, maybe you can create multiple fixtures with varying scale and keep only 1 of them as non-sensor. Then instead of creating and destroying the bodies, you can just change the isSensor flag for the fixtures such that 1 bigger fixture gradually becomes non-sensor and rest of them remain sensors. I think it will be a bit more efficient on the physics engine than spawning multiple bigger bodies.

Hello, I am trying to implement it by controlling the isSensor flag, and I encountered a problem. The fixture I obtained through body.fixtures cannot modify isSensor, and _isSensor is a private variable. Is my usage wrong?

spydon commented 2 months ago

Use setSensor, it is using the old school way of setting it before the modern variable setter overrides were available.

DavidChZh commented 2 months ago

Use setSensor, it is using the old school way of setting it before the modern variable setter overrides were available.

It works fine. Thanks very much

DavidChZh commented 2 months ago

Use setSensor, it is using the old school way of setting it before the modern variable setter overrides were available.

It works fine. Thanks very much

invisible bodies

When I used isSensor to mark and realized the function of making the sphere bigger, invisible bodies no longer appeared on the simulator, but occasionally appeared on Apple's real machine. So I used world.destroyBody while using isSensor to completely avoid this problem. I wrote an example that does not use isSensor. Please take a look

import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/foundation.dart';

class SpriteBodyExample extends Forge2DGame {
  static const String description = '''
    In this example we show how to add a sprite on top of a `BodyComponent`.
    Tap the screen to add more pizzas.
  ''';

  SpriteBodyExample()
      : super(
          gravity: Vector2(0, 100.0),
          world: SpriteBodyWorld(),
        );
}

class SpriteBodyWorld extends Forge2DWorld
    with TapCallbacks, HasGameReference<Forge2DGame> {
  @override
  Future<void> onLoad() async {
    super.onLoad();
    addAll(createBoundaries(game, strokeWidth: 0));
  }

  @override
  void onTapDown(TapDownEvent info) {
    super.onTapDown(info);
    final position = info.localPosition;
    expansion(position);
  }

  expansion(Vector2 position) async {
    for (var i = 0; i <= 4; i++) {
      Pizza pizza = Pizza(position, size: Vector2(10, 15));
      game.world.add(pizza);
      await Future.delayed(const Duration(milliseconds: 10), () {});
      if (i != 4) {
        pizza.removeFromParent();
      }
    }
  }
}

class Pizza extends BodyComponent with TapCallbacks {
  final Vector2 initialPosition;
  final Vector2 size;

  Pizza(
    this.initialPosition, {
    Vector2? size,
  }) : size = size ?? Vector2(2, 3);

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    final sprite = await game.loadSprite('pizza.png');
    renderBody = false;
    add(
      SpriteComponent(
        sprite: sprite,
        size: size,
        anchor: Anchor.center,
      ),
    );
  }

  removeInvalidBody() {
    var invalidBody = game.world.physicsWorld.bodies
        .where((body) =>
            body.userData != null &&
            body.userData is Pizza &&
            (body.userData as Pizza).parent == null)
        .toList();
    for (var i = 0; i < invalidBody.length; i++) {
      game.world.destroyBody(invalidBody[i]);
      debugPrint("无效移除$i");
    }
  }

  @override
  void onTapUp(TapUpEvent event) {
    removeInvalidBody();
    super.onTapUp(event);
  }

  @override
  Body createBody() {
    final shape = PolygonShape();

    final vertices = [
      Vector2(-size.x / 2, size.y / 2),
      Vector2(size.x / 2, size.y / 2),
      Vector2(0, -size.y / 2),
    ];
    shape.set(vertices);

    final fixtureDef = FixtureDef(
      shape,
      restitution: 0,
      friction: 1,
    );

    final bodyDef = BodyDef(
      userData: this,
      position: initialPosition,
      angle: (initialPosition.x + initialPosition.y) / 2 * pi,
      type: BodyType.dynamic,
    );
    Body body = world.createBody(bodyDef);
    body.createFixture(fixtureDef);
    // body.createFixture(fixtureDef1);
    return body;
  }
}

List<Wall> createBoundaries(Forge2DGame game, {double? strokeWidth}) {
  final visibleRect = game.camera.visibleWorldRect;
  final topLeft = visibleRect.topLeft.toVector2();
  final topRight = visibleRect.topRight.toVector2();
  final bottomRight = visibleRect.bottomRight.toVector2();
  final bottomLeft = visibleRect.bottomLeft.toVector2();

  return [
    Wall(topLeft, topRight, strokeWidth: strokeWidth),
    Wall(topRight, bottomRight, strokeWidth: strokeWidth),
    Wall(bottomLeft, bottomRight, strokeWidth: strokeWidth),
    Wall(topLeft, bottomLeft, strokeWidth: strokeWidth),
  ];
}

class Wall extends BodyComponent {
  final Vector2 start;
  final Vector2 end;
  final double strokeWidth;

  Wall(this.start, this.end, {double? strokeWidth})
      : strokeWidth = strokeWidth ?? 1;

  @override
  Body createBody() {
    final shape = EdgeShape()..set(start, end);
    final fixtureDef = FixtureDef(shape, friction: 0.3);
    final bodyDef = BodyDef(
      userData: this, // To be able to determine object in collision
      position: Vector2.zero(),
    );
    paint.strokeWidth = strokeWidth;

    return world.createBody(bodyDef)..createFixture(fixtureDef);
  }
}
ufrshubham commented 2 months ago

I think you left out the most important part of the problem in your original question. You never mentioned how exactly you were adding and removing the components. I'm pretty sure that the problem is caused by the use of Future.delayed. It must be messing up with the component lifecycle as it is running out of sync with the game loop. Instead of that you should be using something like Timer or TimerComponent to make your code remain in sync with the game loop. You can even calculate the elapsed time manually in the update method and call add/remove after fixed intervals.

spydon commented 2 months ago

I haven't looked closely at what you're doing but SpawnComponent and RemoveEffect could be used for adding and removing respectively, in addition to DevKage's suggestions.

DavidChZh commented 2 months ago

I think you left out the most important part of the problem in your original question. You never mentioned how exactly you were adding and removing the components. I'm pretty sure that the problem is caused by the use of Future.delayed. It must be messing up with the component lifecycle as it is running out of sync with the game loop. Instead of that you should be using something like Timer or TimerComponent to make your code remain in sync with the game loop. You can even calculate the elapsed time manually in the update method and call add/remove after fixed intervals.

I tried using a Timer, but the problem persisted.

import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/extensions.dart';
import 'package:flame_forge2d/flame_forge2d.dart';
import 'package:flutter/foundation.dart';
import 'dart:async' as async;

class SpriteBodyExample extends Forge2DGame {
  static const String description = '''
    In this example we show how to add a sprite on top of a `BodyComponent`.
    Tap the screen to add more pizzas.
  ''';

  SpriteBodyExample()
      : super(
          gravity: Vector2(0, 100.0),
          world: SpriteBodyWorld(),
        );
}

class SpriteBodyWorld extends Forge2DWorld
    with TapCallbacks, HasGameReference<Forge2DGame> {
  @override
  Future<void> onLoad() async {
    super.onLoad();
    addAll(createBoundaries(game, strokeWidth: 0));
  }

  @override
  void onTapDown(TapDownEvent info) {
    super.onTapDown(info);
    final position = info.localPosition;
    expansion(position);
  }

  expansion(Vector2 position) {
    int i = 0;
    Pizza? pizza;
    async.Timer.periodic(const Duration(milliseconds: 10), (timer) {
      if (i == 4) timer.cancel();
      pizza?.removeFromParent();
      pizza = Pizza(position, size: Vector2(10, 15));
      game.world.add(pizza!);
      i++;
    });
  }
}

class Pizza extends BodyComponent with TapCallbacks {
  final Vector2 initialPosition;
  final Vector2 size;

  Pizza(
    this.initialPosition, {
    Vector2? size,
  }) : size = size ?? Vector2(2, 3);

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    final sprite = await game.loadSprite('pizza.png');
    renderBody = false;
    add(
      SpriteComponent(
        sprite: sprite,
        size: size,
        anchor: Anchor.center,
      ),
    );
  }

  removeInvalidBody() {
    var invalidBody = game.world.physicsWorld.bodies
        .where((body) =>
            body.userData != null &&
            body.userData is Pizza &&
            (body.userData as Pizza).parent == null)
        .toList();
    for (var i = 0; i < invalidBody.length; i++) {
      game.world.destroyBody(invalidBody[i]);
      debugPrint("pizza无效移除$i");
    }
  }

  @override
  void onTapUp(TapUpEvent event) {
    removeInvalidBody();
    super.onTapUp(event);
  }

  @override
  Body createBody() {
    final shape = PolygonShape();

    final vertices = [
      Vector2(-size.x / 2, size.y / 2),
      Vector2(size.x / 2, size.y / 2),
      Vector2(0, -size.y / 2),
    ];
    shape.set(vertices);

    final fixtureDef = FixtureDef(
      shape,
      restitution: 0,
      friction: 1,
    );

    final bodyDef = BodyDef(
      userData: this,
      position: initialPosition,
      angle: (initialPosition.x + initialPosition.y) / 2 * pi,
      type: BodyType.dynamic,
    );
    Body body = world.createBody(bodyDef);
    body.createFixture(fixtureDef);
    // body.createFixture(fixtureDef1);
    return body;
  }
}

List<Wall> createBoundaries(Forge2DGame game, {double? strokeWidth}) {
  final visibleRect = game.camera.visibleWorldRect;
  final topLeft = visibleRect.topLeft.toVector2();
  final topRight = visibleRect.topRight.toVector2();
  final bottomRight = visibleRect.bottomRight.toVector2();
  final bottomLeft = visibleRect.bottomLeft.toVector2();

  return [
    Wall(topLeft, topRight, strokeWidth: strokeWidth),
    Wall(topRight, bottomRight, strokeWidth: strokeWidth),
    Wall(bottomLeft, bottomRight, strokeWidth: strokeWidth),
    Wall(topLeft, bottomLeft, strokeWidth: strokeWidth),
  ];
}

class Wall extends BodyComponent {
  final Vector2 start;
  final Vector2 end;
  final double strokeWidth;

  Wall(this.start, this.end, {double? strokeWidth})
      : strokeWidth = strokeWidth ?? 1;

  @override
  Body createBody() {
    final shape = EdgeShape()..set(start, end);
    final fixtureDef = FixtureDef(shape, friction: 0.3);
    final bodyDef = BodyDef(
      userData: this, // To be able to determine object in collision
      position: Vector2.zero(),
    );
    paint.strokeWidth = strokeWidth;

    return world.createBody(bodyDef)..createFixture(fixtureDef);
  }
}
DavidChZh commented 2 months ago

I haven't looked closely at what you're doing but SpawnComponent and RemoveEffect could be used for adding and removing respectively, in addition to DevKage's suggestions.

SpawnComponent generates a PositionComponent, but I need to generate a BodyComponent, which seems to be unusable.

ufrshubham commented 2 months ago

I tried using a Timer, but the problem persisted.

By Timer I meant the Flame's Timer

DavidChZh commented 2 months ago

I tried using a Timer, but the problem persisted.

By Timer I meant the Flame's Timer

It is indeed as you said. After changing to flame timer, this problem no longer occurs.

  async.Timer? timer;
  expansion(Vector2 position) {
    int i = 0;
    Pizza? pizza;
    timer = async.Timer(
      0.01,
      onTick: () {
        pizza?.removeFromParent();
        pizza = Pizza(position, size: Vector2(10, 15));
        game.world.add(pizza!);
        debugPrint("pizza$i");
        if (i == 4) timer?.stop();
        i++;
      },
      repeat: true,
    );
  }

It is just that when my limit time setting is reduced, the following error will be reported. It runs normally at 1s or 0.1s. If it is 0.01s, it will be reported. The smaller the interval, the higher the frequency.

E/flutter ( 9436): [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: 'package:flame/src/components/mixins/has_game_reference.dart': Failed assertion: line 32 pos 7: 'game != null': Could not find Game instance: the component is detached from the component tree
E/flutter ( 9436): #0      _AssertionError._doThrowNew (dart:core-patch/errors_patch.dart:51:61)
E/flutter ( 9436): #1      _AssertionError._throwNew (dart:core-patch/errors_patch.dart:40:5)
E/flutter ( 9436): #2      HasGameReference._findGameAndCheck (package:flame/src/components/mixins/has_game_reference.dart:32:7)
E/flutter ( 9436): #3      HasGameReference.game (package:flame/src/components/mixins/has_game_reference.dart:19:27)
E/flutter ( 9436): #4      BodyComponent.world (package:flame_forge2d/body_component.dart:79:29)
E/flutter ( 9436): #5      Pizza.createBody (package:flame_hello/forge2D_example.dart:127:17)
E/flutter ( 9436): #6      BodyComponent.onLoad (package:flame_forge2d/body_component.dart:76:12)
E/flutter ( 9436): <asynchronous suspension>
E/flutter ( 9436): #7      Pizza.onLoad (package:flame_hello/forge2D_example.dart:73:5)
E/flutter ( 9436): <asynchronous suspension>
E/flutter ( 9436): #8      Component._startLoading.<anonymous closure> (package:flame/src/components/core/component.dart:858:32)
E/flutter ( 9436): <asynchronous suspension>
ufrshubham commented 2 months ago

It runs normally at 1s or 0.1s. If it is 0.01s, it will be reported. The smaller the interval, the higher the frequency.

0.01s is way too small of an interval. For a game running at 60 FPS, 1 frame will take ~0.016 seconds on an average. If you are trying to add and remove something at a higher frequency than that, it won't be even visible. Also, add and remove don't happen instantly. It will take at least 1 frame for those operations to get processed. So if your code is trying change the state faster than what the game is capable of, it will cause such issues.

DavidChZh commented 2 months ago

It runs normally at 1s or 0.1s. If it is 0.01s, it will be reported. The smaller the interval, the higher the frequency.

0.01s is way too small of an interval. For a game running at 60 FPS, 1 frame will take ~0.016 seconds on an average. If you are trying to add and remove something at a higher frequency than that, it won't be even visible. Also, add and remove don't happen instantly. It will take at least 1 frame for those operations to get processed. So if your code is trying change the state faster than what the game is capable of, it will cause such issues.

You are awesome and totally answered all my questions!

DavidChZh commented 2 months ago

I debugged again today for some reasons. I still used Future.delayed to create a delay, but set the time to be greater than 16, and the invisible bodies problem did not appear. So I came to the following conclusions: When the interval time is set to less than 16 milliseconds. When using Future.delayed, the console does not output errors, but the invisible bodies problem occurs When using Flame's Timer, although errors are output, invisible bodies do not appear So Future.delayed does not disrupt the life cycle, but when developing games, in order to avoid some strange problems, try to use Flame's Timer

ufrshubham commented 2 months ago

I debugged again today for some reasons. I still used Future.delayed to create a delay, but set the time to be greater than 16, and the invisible bodies problem did not appear. So I came to the following conclusions: When the interval time is set to less than 16 milliseconds. When using Future.delayed, the console does not output errors, but the invisible bodies problem occurs When using Flame's Timer, although errors are output, invisible bodies do not appear So Future.delayed does not disrupt the life cycle, but when developing games, in order to avoid some strange problems, try to use Flame's Timer

Just to add to that, the 16 ms number was used just as an example. It is not necessary that all flame games will always be running at 60 FPS. It all depends on the hardware and the game itself.

Also, it is an average FPS. So it is quite possible that some frames take more than 16 ms and some take less. Interval values closer to the average frame times will always run the risk of facing this problem.