How to implement 3D projectile collision?

I’ll be honest, projectiles are giving me a LOT of grief. There’s a lot of strange specific things that just don’t work for whatever reason.

I could really use an example of a successful implementation I could reverse engineer and understand. Maybe I’ll DM somebody about this.

So, I had some time to mull over this conundrum. What makes projectile implementation so tricky is all the moving parts.

I have to manage/take into account:

  1. The projectile root node + their children.
  2. The player scene in relation to the projectile. (Node interactions)
  3. A multiplayer implementation that works with the system I’ve built so far and that is modifiable enough for new multiplayer system iterations that are GUARANTEED to happen.

So, considering everything I’ve tried so far, I think I’ll try using a RigidBody3D next.

I could subvert the multiplayer physics problem by having the server be the only one who handles projectile physics, while the other clients see either a replicated projectile position OR a predicted position of the projectile they themselves shot.

The multiplayer implementation is another step entirely, but first I need to remake properly working local shooting code in my player script. This is the reason my Area3D implementation attempt failed. I got it working with a CharacterBody3D, but nothing else.

Can you share some of your code, it’s very strange that the Area3D isn’t detecting other bodies.

2 Likes

On it, however, there have been 2 iterations of my projectile code. So, I’ll share them both.

CharacterBody3D Version:

extends CharacterBody3D

#------------------------------------------#
# Variables:
#------------------------------------------#

# Projectile stats

var Projectile_Speed : float = 10

var Current_Projectile_Lifetime : float = 0.0
const MAX_PROJECTILE_LIFETIME : float = 5.0

# Nodes
@onready var Projectile_Hitbox : Area3D = $Hitbox
@onready var Projectile_Mesh : MeshInstance3D = $MeshInstance3D

#------------------------------------------#
# Virtual Functions:
#------------------------------------------#

func _physics_process(delta: float) -> void:
	
	# Move the projecile forward every physics tick
	velocity = global_basis * Vector3.FORWARD * Projectile_Speed
	
	# Needed to trigger physics
	move_and_slide()
	
	print(Projectile_Hitbox.get_overlapping_bodies())
	
	# Tick projectile lifetime counter
	Current_Projectile_Lifetime += delta
	
	# Delete projectile if lifetime runs out
	if Current_Projectile_Lifetime >= MAX_PROJECTILE_LIFETIME:
		queue_free()

func initialize_projectile(Spawn_Position:Vector3, Target_Position:Vector3) -> void:
	
	# Set the projectile's position to the muzzle of the player's weapon
	global_position = Spawn_Position
	
	# Rotate the projectile toward the camera raycast's target position
	look_at(Target_Position)

Scene:


Area3D Version:

extends Area3D

#------------------------------------------#
# Variables:
#------------------------------------------#

# Projectile stats

var Projectile_Speed : float = 10

var Current_Projectile_Lifetime : float = 0.0
const MAX_PROJECTILE_LIFETIME : float = 5.0

# Nodes
@onready var Projectile_Hitbox : Area3D = $"."
@onready var Projectile_Mesh : MeshInstance3D = $MeshInstance3D

#------------------------------------------#
# Virtual Functions:
#------------------------------------------#

func _physics_process(delta: float) -> void:
	
	# Move the projectile forward every physics tick
	global_position = global_position * Projectile_Speed * delta
	
	print(Projectile_Hitbox.get_overlapping_bodies()) # THIS DOESN'T WORK
	
	# Tick projectile lifetime counter
	Current_Projectile_Lifetime += delta
	
	# Delete projectile if lifetime runs out
	if Current_Projectile_Lifetime >= MAX_PROJECTILE_LIFETIME:
		queue_free()

func initialize_projectile(Spawn_Position:Vector3, Target_Position:Vector3) -> void:
	
	# Set the projectile's position to the muzzle of the player's weapon
	global_position = Spawn_Position
	
	# Rotate the projectile toward the camera raycast's target position
	look_at(Target_Position)

Scene:


These are the only things that change between each version of my projectile. However, both of them interact with the player’s shoot function the same, with a key difference.

  • The CharacterBody3D works, and shoots the projectiles properly.
  • The Area3D version doesn’t work, and only spawns a projectile at 0.0.0 for some reason. (The issue is with the Area3D line that moves the projectile after spawn)
global_position = global_position * Projectile_Speed * delta

Also, when I try to detect bodies with the Area3D version, it just doesn’t work with anything other than RigidBody3D.

Edit:

I forgot to share the player shooting function. Let me do that before it’s too late.

func primary_action() -> void:
	
	if Can_Trigger_Primary_Action == false:
		return
	
	# Update the camera raycast
	Camera_Raycast.force_raycast_update()
	
	# Create collision variables
	var Camera_Raycast_Collision_Point : Vector3 = Camera_Raycast.get_collision_point()
	var New_Muzzle_Raycast_Collision_Point : Vector3 = Muzzle_Raycast.to_local(Camera_Raycast_Collision_Point)
	
	# Update muzzle raycast to go toward camera raycast hit location
	Muzzle_Raycast.target_position = New_Muzzle_Raycast_Collision_Point
	Muzzle_Raycast.force_raycast_update()
	
	# Instantiate projectile
	var Projectile_Instance : Node = Test_Projectile.instantiate()
	
	# Spawn projectile in scene
	add_sibling(Projectile_Instance)
	
	# Add real-time parameters to the projectile
	Projectile_Instance.initialize_projectile(Temp_Gun.global_position, \
	Camera_Raycast.get_collision_point())
	
	# Add fire rate delay
	Can_Trigger_Primary_Action = false
	
	# Fire rate timer and action re-enabling
	await get_tree().create_timer(Current_Primary_Action_Frequency).timeout
	Can_Trigger_Primary_Action = true

Your Area3D movement code should be changed to this, otherwise your projectile just stays in place, you’re currently not modifying its position anywhere.

I know that in the previous post about projectile movement I asked you to define the velocity, not add/subtract from it, but that was meant for CharacterBody3D, because there is a separate function move_and_slide() that is doing the actual movement. With Area3D you need to do the movement yourself, hence this +=

As for the collision detection - can you maybe use the body_entered signal to detect the collision?
Double check your collision layers and masks as well, those are very easy to setup incorrectly and don’t notice it.

3 Likes

Got it. I’ll give all these a try tomorrow. It might be another dud day, but I feel I’m getting closer with every attempt and reply I get.

Great news: The projectile movement now WORKS with an Area3D!!!

I completely forgot about the += sign. I just haven’t used it in a while.

I’m now working on getting collisions working. If I can get that done, I’ll be able to implement local projectiles. Then after local projectiles are implemented, I’ll devise a system for multiplayer projectiles.

I’ll keep you updated.


Update 1:

I connected the _body_entered() signal to the projectile script.

func _on_body_entered(body:Node3D) -> void:
	
	print(body)

However, it still only works with RigidBody3D. I also checked the collision layers and masks; they’re correct as far as I know.

I’m not sure where the exact issue with collision is. But maybe it’s due to me using CSGshapes for my test map. Or maybe it’s something weirder I’m not seeing.

I’ll keep running more tests.


Update 2:

So, in theory, I could use Area3Ds for all geometry, like so:

But I feel this is REALLY impractical, as I have to add a bunch of exception for player and projectile areas. Plus, building maps seems like a royal pain.

I got shooting the projectiles working, and I can even switch the projectile to a RigidBody3D with the same code. (I tested it)

Does anyone have any solutions to the Area3D body collision issue? If I don’t have a solution in a day, I’ll switch to RigidBody3Ds and configure them from there.

2 Likes

All my 3D levels are built from StaticBody3Ds with a MeshInstance3D and a CollisionShape3D hanging off them. And they’re all imported .glb models, so all that setup is done in the Advanced Import dialog.. I’ve never had a problem detecting collisions.

1 Like

Do you use some sort of projectile?

If so, could you share the node tree? I’d love to reverse engineer it to see if I can get this solved.

The reason I ask is because I have no issue with my player collision. It’s just specifically Area3Ds interacting with bodies that don’t work.

The link I sent you yesterday was the code for a whole project that has it. Look up the cryot_ice_shard.
image

extends Area3D


@export var ice_shard_scene: PackedScene
@export var activation_sound: AudioStream

@onready var collision_shape = $CollisionShape3D

var targets: Array[Node]
var num_targets = 0


func create_shards(level: int) -> void:
	targets = get_tree().get_nodes_in_group("destructible")
	num_targets = level
	Sounds.play_sound_effect(activation_sound)
	
	var num_targets_found = targets.size()
	
	if targets.is_empty():
		return
	var current_target = 0
	
	for i in level:
		var ice_shard = ice_shard_scene.instantiate()
		ice_shard.set_damage(level)
		if targets[current_target] == null: # If the target is no longer valid
			print_rich("Target Removed: %s" % [targets[current_target]])
			targets.remove_at(current_target) # Remove it from our list
		ice_shard.set_target(targets[current_target])
		current_target += 1 # We want to iterate through the targets
		if current_target >= num_targets_found: # But if we have more ice shards than targets
			current_target = 0 # start over targeting at the beginning
		add_child(ice_shard)
		ice_shard.global_position = global_position


# Used by Volfrae
func get_number_of_targets():
	targets = get_tree().get_nodes_in_group("destructible")
	return targets.size()
1 Like

I also have a collision shape on my Area3D.

The main issue is the detection of bodies. How’d you do that? I can’t really translate the code you sent.

So sorry, I was in a meeting when I posted that. That was actually the code for the thing that creates the shards. ice_shard_small is the actual projectile.

This is what the CollisionObject3D looks like on the Inspector.


Specifically, it only hits boxes/barrels and other breakables. It flies through walls. You’ll notice it has a self-destruct. If its target is destroyed, it deletes itself. The launcher also puts a self-destruct timer on it and if it doesn’t hit something by a certain time, it deletes itself.

The code itself is a year old and if I were refactoring there are a number of things I would fix.

The detection of bodies is here:

func _ready() -> void:
	self.body_entered.connect(_on_ice_shard_body_entered)

. . . and here:

func _on_ice_shard_body_entered(body: Node3D) -> void:
	body.hit(damage, true)
	Sounds.play_sound_effect(collision_sound)
	die()

Here’s the whole script:

extends Area3D

class_name IceShard

@export var collision_sound: AudioStream

var damage = 5
var rise_speed = 2.0
var rotation_speed = 5.0
var attack_speed = 50.0
var ready_to_move = false
var target: Node3D

var velocity = Vector3.ZERO
var acceleration = Vector3.ZERO


func _ready() -> void:
	set_as_top_level(true)
	self.body_entered.connect(_on_ice_shard_body_entered)
	$Timer.timeout.connect(_on_timer_timeout)
	$Timer.start(1.38)


func _process(delta: float) -> void:
	if !is_instance_valid(target): # If our taregt is destroyed, we die.
		die()
		return
	if ready_to_move:
		position -= transform.basis.z * attack_speed * delta
		pass
	else:
		#Code for pointing towards the target
		var target_position = target.transform.origin
		var new_transform = self.transform.looking_at(target_position, Vector3.UP)
		self.transform  = self.transform.interpolate_with(new_transform, rotation_speed * delta)
		#code for rising up
		position += transform.basis.y * rise_speed * delta


func set_damage(level):
	damage = 5 * level


func set_target(node: Node3D):
	target = node


func _on_ice_shard_body_entered(body: Node3D) -> void:
	body.hit(damage, true)
	Sounds.play_sound_effect(collision_sound)
	die()


func die():
	queue_free()


func _on_timer_timeout() -> void:
	ready_to_move = true
2 Likes

HOLY GUACAMOLE!!! I found out the problem!

I pm’d pennyloafers, asking him to take a look at my codebase for anything I missed.

Turns out, I forgot to mention what physics engine I was using. It completely glazed my mind.

I’m using Jolt, and apparently, in project settings under Physics > Jolt Physics 3D, I have to ENABLE Area3D detecting static bodies.

Now, it FINALLY detects StaticBody3D and even CSGshapes.

Lesson learned. Always mention what physics engine you’re using when asking questions about physics.

Anyways, thanks for helping me out @gertkeno, @wchc, @soapspangledgames, @mrdicerack, @dragonforge-dev, and @pennyloafers. I couldn’t have figured this out without you guys.


Anyways, as for how to actually implement 3D projectile collision (for posterity), you will need the following:

  1. An Area3D as your projectile’s root node (with adjacent child nodes). It’s needed to detect walls (bodies) and entity hitboxes (areas).

  1. Connect the signals when an Area3D node enters a body or area.

The signals are area_entered and body_entered.

  1. Some light code to implement collision functionality.
func _on_body_entered(body:Node3D) -> void:
	
	print(body)
	
	queue_free()

func _on_area_entered(area:Area3D) -> void:
	
	print(area)
	
	queue_free()

That’s it for the absolute basics (Which is where I’m at as of posting this).

Hope this helps.

4 Likes

Yeah I didn’t think to ask that question. This is good to know for the future when someone says that physics isn’t working for them and they are saying stuff I know works isn’t working.

3 Likes

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.