2D: Getting the gravity at a coordinate in space

Godot Version

4.3

Question

I am trying to predict the trajectory of an object in (2D) space.
I followed this tutorial, which uses a simple algorithm to simulate the movement and works fine with the global gravity.
My scene however, has multiple combined gravity areas (Area2D) with gravity falloff that influence the trajectory.

I read that I can get the gravity from the physics state of an object via the _integrate_forces function, but I think I can’t access that within my prediction loop, correct?

Otherwise, is there some way to get the gravity vector at a given global coordinate?

The trajectory code using the global gravity is as follows:

func update_trajectory() -> void:
	var velocity : Vector2 = Vector2(0, EXPLOSION_FORCE)
	var line_start := Vector2(global_position.x, global_position.y + 100)
	var line_end : Vector2
	# TODO: Take into account object gravity
	var force = PHYSICS_TEST_BALL.constant_force
	var gravity : float = ProjectSettings.get_setting("physics/2d/default_gravity")
	var gravity_vec : Vector2 = gravity * ProjectSettings.get_setting("physics/2d/default_gravity_vector")
	var drag : float = 0.0
	# Lower timestep value = more precise physics calculation
	var timestep := 0.02
	# Alternating colors of the line
	var colors := [Color.RED, Color.BLUE]
	
	PHYSICS_TEST_BALL.global_position = line_start
	
	# Predict until a goal is it, or max 70 steps
	for i : int in 70:
		# TODO: How to calculate real gravity?
		velocity += gravity_vec * timestep
		# TODO: Understand the following line
		velocity = velocity * clampf(1.0 - drag * timestep, 0, 1)
		line_end = line_start + (velocity * timestep)
		
		var collision:= PHYSICS_TEST_BALL.move_and_collide(velocity * timestep)
		# If it hits something
		if collision:
			# TODO: differentiate between bodies (calculate bounce and continue)
			# and the goal (break and finish)
			velocity = velocity.bounce(collision.get_normal())
			draw_line_global(line_start, PHYSICS_TEST_BALL.global_position, Color.YELLOW)
			line_start = PHYSICS_TEST_BALL.global_position
			continue
		
		draw_line_global(line_start, line_end, colors[i%2])
		line_start = line_end

Well, not via the _integrate_forces() function. If you want to access the current state of your PHYSICS_TEST_BALL (which I assume is a Rigidbody2D), you need to request it from the physics server (PhysicsServer2D).

var body_id = PHYSICS_TEST_BALL.get_rid()
var state = PhysicsServer2D.body_get_direct_state(body_id)

Once you have the state, you can simply ask for the gravity vector currently being applied to the body.

var g = state.total_gravity

Hopefully, that should do it.


Here are the relevant documentation pages:

Thanks a lot Sweatix, that was what I was looking for!

I am able to get the gravity of the actually moving object with that.

The issue now is, that the physics are not recalculated during my loop.
As such, the gravity value is always only from the last position of the last frame / physics process.
I suppose I don’t have a way to force a recalculate inside my loop for just this object, right?

I see. Unfortunately, I’m not familiar with any method that updates the gravity vector. Perhaps this is where your approach is limited. I have another approach for you that might work.

Proposed approach

There’s a integrate_forces() method within the PhysicsDirectBodyState2D that allows you to manually integrate the physics of your state – similar to how you are currently integrating the physics of your ball with move_and_collide.

My thought process here is: move_and_collide() only takes the velocity input into account while integrate_forces() correctly, and completely, updates the physics state. In other words, integrate_forces() should automatically apply all forces including the total_gravity vector.

Obviously this requires you to alter the code you currently have but I believe it could be a better approach. I’ve tried rewriting your code to incorporate this idea:

func update_trajectory() -> void:
	# Alternating colors of the line
	var colors := [Color.RED, Color.BLUE]
	
	# Get the state for the ball
	var body_id = PHYSICS_TEST_BALL.get_rid()
	var state = PhysicsServer2D.body_get_direct_state(body_id)

	# Set start velocity and start position for the ball
	state.transform.origin = Vector2(global_position.x, global_position.y + 100)
	state.linear_velocity = Vector2(0, EXPLOSION_FORCE)

	var previous_position: Vector2
	var previous_velocity: Vector2

	# Predict until a goal is it, or max 70 steps
	for i : int in 70:
		# Store current state info before updating the state
		previous_position = state.transform.origin
		previous_velocity = state.linear_velocity

		# Integrate the simulation by one timestep (i.e. update the state)
		state.integrate_forces()

		# Determine if body is in a collision (for triggering bounce-code)
		var in_contact = state.get_contact_count() > 0

		if in_contact:
			# TODO: differentiate between bodies (calculate bounce and continue)
			# and the goal (break and finish)

			# Get the normal for the 0th contact point
			var contact_normal = state.transform.basis * state.get_contact_local_normal(0)

			# "Bounce" the velocity
			var new_velocity = previous_velocity.bounce(contact_normal)
			# NOTE: previous_velocity is used because the current state velocity
			# ...at the time of contact is affected by the collision. Alternatively, you
			# ...can probably give the ball a physics material that has a bounce-value of 1.0.
			# This would likely eliminate the need for this entire if-block! Give it a try.

			# Draw a line
			draw_line_global(previous_position, state.transform.origin, Color.YELLOW)
			line_start = PHYSICS_TEST_BALL.global_position
		else: 
			draw_line_global(previous_position, state.transform.origin, colors[i%2])

I must mention here that this code is completely untested, and I’ve never used integrate_forces() before. Hopefully it gets you close to a pretty functional solution though.

If you don’t see this approach working, perhaps you can stick with your current approach and use a shapecast to detect and compute the gravity vector at each step.


Let me know how it goes!

Oh wow, thanks so much for the effort!

I tried it. There was one compile error, as line_start towards the end of the function was no longer defined.

After fixing that, it unfortunately still doesn’t work and I also understand why now.
I debugged the engine, and the gravity is indeed only updated every real tick.

The code that updates the state gravity is GodotBody2D::integrate_forces in godot_body_2d.cpp.

Going after the name, you might think that this is called with integrate_forces from the gdscript. It is, however, only invoked by the physics server’s step function.

Instead when calling state.integrate_forces() in script, the function
PhysicsDirectBodyState2D::integrate_forces() in physics_server_2d.cpp is called.
This function takes the gravity stored in the state, and then calculates a velocity and angular vector based on that.

What I did now was to calculate the gravity myself.
The following code is essentially a recreation of the GodotBody2D function adapted to my use case:

# Function to simulate the gravity at a given global coordinate
# This is mostly adapted from GodotBody2D::integrate_forces in godot_body_2d.cpp
func simulate_gravity(ball: RigidBody2D) -> Vector2:
	var planet_gravity := Vector2(0,0)
	for planet in PLANETS:
		planet_gravity += get_gravity_for_body(ball, planet)
	return planet_gravity
		
# Function to simulate the gravity at a given global coordinate
# This is mostly adapted from GodotBody2D::integrate_forces in godot_body_2d.cpp
# and https://www.reddit.com/r/gamedev/comments/w6woww/adding_orbital_gravity_to_your_game/
func get_gravity_for_body(satellite: RigidBody2D, planet: StaticBody2D) -> Vector2:
	var planet_gravity_area : Area2D = planet.get_node("Area2D")
	var planet_gravity := Vector2(0,0)
	var gr_unit_dist : float = 0.0
	if planet_gravity_area.gravity_point:
		gr_unit_dist = planet_gravity_area.gravity_point_unit_distance
	
	# Get the direction in-between the planet and the satellite
	var v : Vector2 = planet.global_position - satellite.global_position
	
	if gr_unit_dist > 0:
		var v_length_sq = v.length_squared()
		if (v_length_sq > 0):
			var gravity_strength = planet_gravity_area.gravity * gr_unit_dist * gr_unit_dist / v_length_sq
			planet_gravity = v.normalized() * gravity_strength
	else:
		planet_gravity = v.normalized() * planet_gravity_area.gravity
	
	return planet_gravity

My initial idea was to combine this with your code.
Unfortunately, I can’t set the state gravity, so integrate_forces will always use the fixed gravity from the last tick. It also doesn’t actually move the object, but simply updates the vectors.

So I modified my former implementation to include my changes from above.

# Simulate the physics process to predict the path
func update_trajectory() -> void:
	PHYSICS_TEST_BALL.hide()
	var body_id = PHYSICS_TEST_BALL.get_rid()
	var state = PhysicsServer2D.body_get_direct_state(body_id)
	PHYSICS_TEST_BALL.set_deferred("freeze", false)
		
	var line_start := Vector2(global_position.x, global_position.y + 100)
	var line_end : Vector2

	var drag : float = 0.0
	# Lower timestep value = more precise physics calculation. Engine uses 0.0166..
	var timestep := 0.0166
	# Alternating colors of the line
	var colors := [Color.RED, Color.BLUE]
	PHYSICS_TEST_BALL.global_position = line_start
	
	# Initial calculation and force application
	var gravity_vec = simulate_gravity(PHYSICS_TEST_BALL)
	var velocity : Vector2 = calculate_velocity(PHYSICS_TEST_BALL, timestep, gravity_vec, Vector2(0, EXPLOSION_FORCE))
	
	# Smooth sailing without force application from here
	# Predict until a goal is hit, or max steps
	for i : int in 500:
		#var gravity_vec = state.total_gravity
		gravity_vec = simulate_gravity(PHYSICS_TEST_BALL)
		#print("Sim: " + str(gravity_vec))
		velocity += calculate_velocity(PHYSICS_TEST_BALL, timestep, gravity_vec)
		velocity = velocity * clampf(1.0 - drag * timestep, 0, 1)
		line_end = line_start + (velocity * timestep)
		
		var collision:= PHYSICS_TEST_BALL.move_and_collide(velocity * timestep)
		# If it hits something
		if collision:
			# TODO: differentiate between bodies (calculate bounce and continue)
			# and the goal (break and finish)
			velocity = velocity.bounce(collision.get_normal())
			draw_line_global(line_start, PHYSICS_TEST_BALL.global_position, Color.YELLOW)
			line_start = PHYSICS_TEST_BALL.global_position
			continue
		
		draw_line_global(line_start, line_end, colors[i%2])
		line_start = line_end

func calculate_velocity(object: RigidBody2D, timestep: float, gravity = Vector2(), applied_force = Vector2(), constant_force = Vector2()) -> Vector2:
	return object.mass * gravity + applied_force + constant_force

You can see from the following screenshot that it is somewhat close, but not yet perfect.
It especially falls apart after the first collision. I’ll keep trying to get closer to the actual movement.

1 Like

I see. I’m not sure why PhysicsDirectBodyState2D.integrate_forces() is exposed then. I thought it was designed for problems like this.

I wish I had more experience with Godot’s physics engine so I could provide better assistance here. I don’t, unfortunately.

However, it seems like you’ve found a good way of computing the gravity vector yourself and the results look pretty good. Nice!


Good luck improving the collision response of the trajectory!

Hi @Sweatix :

I found a way to make your approach work and the implementation becomes even simpler! :slightly_smiling_face:

I looked around different physics engine plugins, and it turns out the Rapier2D physics engine has just what I need!

It allows to manually call the step function for a space:
Godot Rapier2D determinism
Its determinism also improves one additional issue I had with the Godot physics engine.
The prediction lines would jitter in certain conditions. This doesn’t happen anymore with Rapier.

That simplifies the process quite a bit. What I did now was:

  1. Create a hidden Window with a new SubViewport
  2. Duplicate all necessary physics objects in that SubViewport
  3. Simply call the step function within that loop and draw the lines according to the ball position

I am not sure why creating a new Window was necessary, but without it the simulation in the SubViewport would not run.

Here is the code for the sub-scene setup:

# Called when the node enters the scene tree for the first time.
func _ready():
	screen_size = get_viewport_rect().size
	create_sub_view()

# Creates a SubViewport that is used for physics prediction simulation
func create_sub_view() -> void:
	simulation_viewport = SubViewport.new()
	simulation_viewport.size = screen_size
	simulation_viewport.disable_3d = true
	var win = Window.new()
	win.add_child(simulation_viewport)
	add_child(win)
	win.hide()
	
	# Deactivate automatic physics calculation in the space
	PhysicsServer2D.space_set_active(simulation_viewport.world_2d.space, false)

The planets / gravity sources are added in via a loop:

PLANETS = planets
	for planet in PLANETS:
		simulation_viewport.add_child(planet.duplicate())

The simulation itself is then just a few lines of code:

# Draw a prediction path of the player
func _draw() -> void:
	simulate_trajectory()
	
# Simulate the trajectory using Rapier2Ds manual step
func simulate_trajectory() -> void:
	var space : RID = simulation_viewport.world_2d.space
	var fixed_delta = 1.0 / ProjectSettings.get_setting("physics/common/physics_ticks_per_second")

	# Create our simulation test ball
	var sim_ball = instantiate_ball(simulation_viewport)
	teleport(Vector2(global_position.x, global_position.y + 100), sim_ball)
	
	# Create our line parameters
	var line_start : Vector2
	var line_end : Vector2
	var colors := [Color.RED, Color.BLUE]

	# Run the physics loop and draw the line after each step
	for i in 1000:
		line_start = sim_ball.global_position
		RapierPhysicsServer2D.space_step(space, fixed_delta)
		RapierPhysicsServer2D.space_flush_queries(space)
		line_end = sim_ball.global_position
		draw_line_global(line_start, line_end, colors[i%2])
	
	# Delete the simulation test ball
	sim_ball.queue_free()

As you can see in the following screenshot, the prediction works perfectly:

Thanks again for your support!

1 Like