godotengine / godot

Godot Engine – Multi-platform 2D and 3D game engine
https://godotengine.org
MIT License
86.96k stars 19.49k forks source link

Area2D body_entered signal is emitted too late #86199

Open davidmcasas opened 7 months ago

davidmcasas commented 7 months ago

Tested versions

Reproducible in: v4.2.1.stable.official [b09f793f5]

System information

Windows 10 - v4.2.1.stable.official [b09f793f5]

Issue description

Area2D body_entered signal seem to execute the callback 1 physics tick too late. Here is a visual representation, where the player is a CharacterBody2D, and the coin is an Area2D. The Area2D callback is set to increase the score by 1 and make the coin invisible and queue_free()

fd521bf49d9a2b73321ffdbcc6a45bbefbee1f46

This visual overlap is undesired, the Area2D (coin) is set to become invisible (visible = false) in the body_entered callback, however it is still visible for the first frame where it overlaps with the player.

This also happens in the official pong demo, where the ball can be seen overlap the paddles if the speed of the ball increases.

If maybe this is not considered a bug and it's just the way the engine was designed, there should be an easy alternative to make an Area2D with immediate body_entered callbacks. There's a running thread on the forums on this issue: https://forum.godotengine.org/t/why-are-area2d-collisions-triggered-1-frame-late/37354

Steps to reproduce

Create a CharacterBody2D and another Area2D. Make the Area2D emit the body_entered signal, and on the callback set it so the Area2D becomes invisible or whatever. You'll see that the CharacterBody2D actually overlaps the Area2D for 1 frame.

Minimal reproduction project (MRP)

MRCollisionExample.zip In this MRP just press right arrow key to move the CharacterBody2D to the right. When it reaches the Area2D, the callback is set to teleport the CharacterBody2D back to the start, however, they can be seen overlapping for a frame, like this: ebeaf25a703e804c3bc0b49825080a7c27c5ed3d_2_690x456

This also happens in the Godot pong official demo: https://godotengine.org/asset-library/asset/121 If you increase the ball speed, it will be seen overlaping the paddles, which is a highly undesired visual glitch.

Rindbee commented 7 months ago

I think overlapping one frame is acceptable, but it may overlap two frames.

In the _on_body_entered function of area.gd, print the before-reset position of the body:

  1. In most cases it is (133.3333, 0);
  2. In rare cases, it is (120, 0).

Note: In order to test faster, I set the initial position of the body to (0,0), and left nothing else modified.

(106.6667, 0) (120, 0) (133.3333, 0)
2 0 1
davidmcasas commented 7 months ago

I noticed that if you increase "physics/common/physics_ticks_per_second" or set "application/run/max_fps" to a low value so there are multiple consecutive _physics_process calls with no _process call in between, the overlap is not visible. The visual overlap only happens if there are _process calls between the _physics_process calls. I guess that's because the engine drawing happens somewhere during _process calls, as it's tied to FPS.

Rindbee commented 7 months ago

Adjusting the fps will make it look like there is no overlap.

But it will still overlap two physics frames (you will get two different positions where x exceeds 111, one can be output in _physics_process() of player.gd, and the other can be output in _on_body_entered() of area.gd) .

_physics_process() and move_and_slide() (first overlap) --> _physics_process() and move_and_slide() (second overlap) --> _on_body_entered().

Ideally it might be:

_physics_process() and move_and_slide() (first overlap) --> _on_body_entered().

Rindbee commented 7 months ago

The reason for the two-frame overlap seems to be that move_and_slide() immediately updates the CharacterBody2D's transform, but does not immediately update the corresponding GodotBody2D's transform in the physics server.

GodotBody2D in PhysicsServer2D::BODY_MODE_KINEMATIC mode can only update its transform through integrate_velocities(), but not through set_state().

The following is an approximate process. https://github.com/godotengine/godot/blob/2d0ee20ff30461b6b10f6fdfba87511a0ebc6642/main/main.cpp#L3748-L3750
https://github.com/godotengine/godot/blob/2d0ee20ff30461b6b10f6fdfba87511a0ebc6642/main/main.cpp#L3771

...
PhysicsServer2D::flush_queries()
    ...
        GodotSpace2D::call_queries()                                        // Query from `monitor_query_list`.
            GodotArea2D::call_queries()
                Area2D::_body_inout()
                    signal body_entered/body_exited                         // May emit.
...
OS::get_singleton()->get_main_loop()->physics_process()
    SceneTree::physics_process()
        ...
        SceneTree::flush_transform_notifications()
        ...
        SceneTree::_process
            Node::_notification(NOTIFICATION_PHYSICS_PROCESS)
                _physics_process()                                          // Script method.
                    CharacterBody2D::move_and_slide()                       // Update `CharacterBody2D`'s transform.
                        Node2D::set_global_transform()
                            Node2D::set_transform()                         // Add the `CharacterBody2D` to `xform_change_list`.
        ...
        SceneTree::flush_transform_notifications()                          // Flush transform notifications from `xform_change_list`.
            CollisionObject3D::_notification(NOTIFICATION_TRANSFORM_CHANGED)
                GodotPhysicsServer2D::body_set_state()
                    GodotBody2D::set_state()                                // May update `GodotBody2D`'s transform. 
                        // Fails due to its mode (`PhysicsServer2D::BODY_MODE_KINEMATIC`).
                        // GodotCollisionObject2D::_set_transform()            
        ...         
...     
PhysicsServer2D::step()
    ...
        GodotStep2D::step()
            GodotStep2D::_pre_solve_island()                                
                GodotAreaPair2D::pre_solve()
                    GodotArea2D::add_body_to_query()                        // Add the area to `monitor_query_list`.
            GodotBody2D::integrate_velocities()                             // Update `GodotBody2D`'s transform.
                // Success. But it may be too late to add the area to `monitor_query_list` in time.
                GodotCollisionObject2D::_set_transform()                     
...

Although GodotBody2D's transform is also updated in the same frame, it is updated too late, resulting in missing the opportunity to add area to monitor_query_list in the same frame. This ultimately results in emit body_entered/body_exited signals possibly being 2 frames late.

davidmcasas commented 1 month ago

@Rindbee Got any news on this?

Just for the record, recently tried to replicate this on Unity with Unity's Area2D body_entered equivalent, OnTriggerEnter2D (which calls Destroy() to remove the Coin). On Unity it works as expected, without overlapping frames, with any framerate and timestep settings. As soon as the Player touches the Coin, OnTriggerEnter2D event is called immediately. You will never see the Player overlapping the Coin.

But it seems impossible to replicate that behaviour in Godot. In Godot, the _body_entered signal may be called up to 2 visible frames late (depending on if you used move_and_slide(), 2 frames late, or updated position manually, 1 frame late), causing visible overlaps. I wish it worked the same way as in Unity. It seems there's no simple solution to this problem in Godot, at least not without relying on a custom coded solution or RayCasts that would add unnecesary performance overhead.

Sadly nobody seems to care about this. It's probably such a small detail that most people won't notice. But then we have threads like these, all originated by the same issue: https://github.com/godotengine/godot/issues/53099 https://www.reddit.com/r/godot/comments/vqgq1j/area2d_body_entered_delayed/ https://forum.godotengine.org/t/collision-between-two-areas-2d-happens-too-late/2158

I only wish that someday this issue will be addressed and we can have events that fire on the same visible frame.

Please don't let this issue die! Thanks

nitaku commented 3 days ago

I am facing similar issues in my project too, and sadly in my case the problem is not limited to a single frame.

I too am unable to access the correct position of a RigidBody2D right after entering an Area2D. I want to use this position (and a stored previous position) to draw a line showing where the body has crossed the boundary of the area. Since the signal is fired late, both the current and the previous positions are already inside the Area2D, causing the line to be drawn in the wrong location. The line remains visible for way more than one frame, and it is clearly noticeable that it does not cross the boundary.

At the moment, the only clean solution I found is to avoid using signals altogether and test for collisions manually in _physics_process, which does not seem ideal.