How to create looping laser bounce in 3D with intersect ray

Godot Version

4.2

Question

I am kind of stuck trying to create a laser that keeps reflecting/bouncing in 3D. So far I have it bounce once but I’m not sure how to set new ray for continuous bouncing.

var max_reflections :int = 10 var cast_length = 100

var from : Vector3
var to : Vector3
var query : PhysicsRayQueryParameters3D
var result : Dictionary

### FIRST CAST
from = marker3d.global_position
to = marker3d.to_global(Vector3(0,0,-cast_length))
query = PhysicsRayQueryParameters3D.create(from, to)
result = space_state.intersect_ray(query)

if result:
	line(from, result["position"], Color(1, 0, 0), 1) ### 1ST LINE
else:
	line(from, to, Color(1, 0, 0), 1)
if !result:
	return

### SECOND CAST
var result_position = result["position"]
var result_normal = result["normal"]
var shooting_direction = result_position - from
var target_direction = shooting_direction.bounce(result_normal)
line(result_position, result_position + target_direction * cast_length, Color(1, 0, 0), 1) ### 2ND LINE

for i in range(1, max_reflections):
	##### SECOND CAST
	from = result["position"]
	to = result["position"] + target_direction.normalized() * cast_length
	query = PhysicsRayQueryParameters3D.create(from, to)
	result = space_state.intersect_ray(query) ### SECOND COLLSIION
	if result:
		_ball(result) # Placing a visible mesh ball for debugging
	else:
		line(from, to, Color(1, 0, 0), 1)
	if !result:
		line(from, to, Color(1, 0, 0), 1)
		return
		
		
	i += 1
	if i >= max_reflections:
		break

I’m going to go ahead and assume that your #SECOND CAST-code and your for-loop are two different attempts at solving the same problem. A for-loop is indeed one way to solve your problem. However, there are a few issues with your for-loop.

Ray information is not fully updated between iterations

  • from
  • to
  • target_direction (not updated)

As noted in the box above, your target_direction is not being updated in your for-loop. Because of this, every ray in your for-loop will shoot in the same direction.

Using for-loops incorrectly
I don’t use GDScript myself but from what I can gather on the web, you shouldn’t increment i yourself – GDScript does that for you. Please print out i to confirm; I’m unsure of whether I’m correct here. However, I’m pretty sure you don’t need to break your for-loop. Like most other languages, a for-loop will only do as many loops as you set it to do – unless, of course, you modify i (which you are doing).

Please consult the GDScript reference and other sources to become more familiar with how it works.

Making a conceptual distinction between your first ray and subsequent rays
In the code you’ve shown in your post, you’re separating your first ray (#FIRST CAST) from the “bounce”-rays (#SECOND RAY and so on…). From a code-perspective, this is not a maintainable or reliable approach to implementing a bouncing laser. Unless the first ray of the laser should behave differently, you should only have to write the code for your ray(s) once – otherwise you may run into scenarios in the future where you forgot to change both pieces of “ray code” which may then result in unintended behaviour.

Your set of raycasts are used to achieve a “laser that keeps reflecting/bouncing in 3D”. If they are part of the same thing and behave identically, you shouldn’t make distinctions between them.

TL;DR: distinguishing the first ray from the rest makes sense from a design standpoint, not from an implementation standpoint.

Solution

extends Node3D

@onready var marker3d = $Marker3D
@export var cast_length = 10
@export var max_reflections = 10
const epsilon = 0.01 # collision margin for the bounce rays

func _physics_process(delta):
	var space_state = get_world_3d().direct_space_state
	
	# The ray information (initialized for the first ray in the loop)
	var dir = marker3d.global_basis * Vector3.FORWARD
	var from = marker3d.global_position
	var to = marker3d.global_position + dir * cast_length
	var result: Dictionary

	# NOTE: I changed the range-parameters. 0-indexing is a programming standard
	var rays_to_shoot = 1 + max_reflections
	for i in range(rays_to_shoot):
		# Perform a raycast with the current ray information
		var query = PhysicsRayQueryParameters3D.create(from, to)
		result = space_state.intersect_ray(query)

		if result.size() == 0:
			DebugGeo.draw_debug_line(delta, from, to, 0.025, Color.RED)
			break
		else:
			
			# ===== Debugging Code =====
			var hue = 0.25 + fmod(i / 2.0, 1.0)
			var color = Color.from_hsv(hue, 1.0, 1.0)
			DebugGeo.draw_debug_line(delta, from, result["position"], 0.025, color)
			DebugGeo.draw_debug_sphere(delta, result["position"], 4, 4, 0.2, color)
			DebugGeo.draw_debug_line(delta, result["position"], result["position"] + result["normal"], 0.025, Color.DODGER_BLUE)
			# ==========================
			
			# Update ray info
			dir = dir.bounce(result["normal"])
			from = result["position"] + result["normal"] * epsilon
			to = from + dir * cast_length


If you have any further questions, let me know.

EDIT:
The issue experienced in this post was caused by floating-point precision. For some reason, the issue is more apparent with CollisionShape3D than with CSGBox3D. The fix was to add a margin (commonly denoted as epsilon) to the bounce position.

The solution makes use of a utility script to draw the laser for debugging purposes. You can find this script (and the setup for it) here. Alternatively, the OPs line() function is available in full further down in this thread.

1 Like

Thank you for information but the code doesn’t seem to work properly.

Imgur
Imgur

  • Seems correctly bounce only when shooter is pointing forward, any other direction would only bounce once.
  • The rays would still cast through object after the bounce.
  • I also got the dreaded “must be normalized” error.
    @ shoot_laser_intersect_bounce(): The normal Vector3 must be normalized.
    on this line:
    dir = dir.bounce(result["normal"])

Hmm… that’s weird. Would you mind making the following code changes?

    else:
        # This makes it easier to see potential errors.
        # The line's color cycles and the end-point is now correct.
        var hue = 0.25 + fmod(i / 2.0, 1.0)
        var color = Color.from_hsv(hue, 1.0, 1.0)
        line(from, result["position"], color, 1)
        # Like your error said, the normal should be normalized.
        dir = dir.bounce(result["normal"].normalized())
    # I forgot to add a "break" here.
    # Without this, the code doesn't work in the expected way.
    if result.size() == 0:
        line(from, to, Color.RED, 1)
        break # Add this.

I honestly have no idea why this would be the case. I assume the box has a collision shape. Perhaps the code changes I have outlined above will help find the issue. If you have your own thoughts as to what might be causing the issue, please share them.

Some new screenshots with the above code changes would be appreciated.

It’s no longer casting through objects, thank you. But there seems to be inconsistency with the intersect_ray itself. As you can see the results when I move the shooter left/right by a little.

Imgur
Imgur
Imgur
Imgur
Imgur
Imgur

  • First, It will not bounce when the block has no rotation at all (0,0,0)
  • It is also unpredictable. For example,
    -I had it bounce when block is rotated y at 13degrees,
    -But not bounce when I rotate the block by other degrees,
    -Then changed it back to 13degrees and it no longer bounce
    -Same goes with moving the location of shooter
    -edit* also how far away from the target as well

Imgur
Imgur

  • The normalized() does not work
    dir = dir.bounce(result["normal"]).normalized()

I suspect it has to do with the ray not accurately/consistently detects the normal from the surface of the cube. I remember, from my earlier code, I checked the normal of the surface that was not bounced and it was all zeros. At the time though I wasn’t sure if the line was accurately represent the casting ray direction or not.

I got curious so I decided to run the code for myself. I built a quick square room with 3 cubes which served to test the reflective properties of the script.

The only difference between my initial proposed solution and the code I used to test the solution is the debugging code for rendering the lines (apparently line() is not natively a part of Godot), and some syntax errors.
(I thought you were meant to use := to infer the type from a value, but I guess = is better? I’m bad with GDScript still.)

Therefore, I ended up using a utility script which has been kindly provided by the GitHub user, Cykyrios. Here’s the repo.

Findings

Basically, there are none; everything works exactly as expected. I didn’t even have to normalize the normal vector (result["normal"]).

The only logical conclusion I can draw from this is that your scene has a quirky setup, or that your project has a non-default configuration. As an example, the boxes and walls in my scene was initially not touching the floor which would allow the ray to “pass through” (from a top-down perspective). However, I saw no issues after fixing that.

If you have more questions, let me know.


Screenshots from the test

Laser code from the test
extends Node3D

@onready var marker3d = $Marker3D
@export var cast_length = 10
@export var max_reflections = 10

func _physics_process(delta):
	var space_state = get_world_3d().direct_space_state
	
	# The ray information (initialized for the first ray in the loop)
	var dir = marker3d.global_basis * Vector3.FORWARD
	var from = marker3d.global_position
	var to = marker3d.global_position + dir * cast_length
	var result: Dictionary

	# NOTE: I changed the range-parameters. 0-indexing is a programming standard
	var rays_to_shoot = 1 + max_reflections
	for i in range(rays_to_shoot):
		# Perform a raycast with the current ray information
		var query = PhysicsRayQueryParameters3D.create(from, to)
		result = space_state.intersect_ray(query)

		if result.size() == 0:
			DebugGeo.draw_debug_line(delta, from, to, 0.025, Color.RED)
			break
		else:
			var hue = 0.25 + fmod(i / 2.0, 1.0)
			var color = Color.from_hsv(hue, 1.0, 1.0)
			DebugGeo.draw_debug_line(delta, from, result["position"], 0.025, color)
			DebugGeo.draw_debug_sphere(delta, result["position"], 4, 4, 0.2, color)

			# Update ray info
			dir = dir.bounce(result["normal"])
			from = result["position"]
			to = result["position"] + dir * cast_length
1 Like

I just created a simple project for the test and still encounter the same problems. Would you mind having a look? I’ve included both line renderers for debug. (for some reason Cykyrios line script causes error so I did not use it)

I removed the link, no longer relevant

Thank you for all this help.

Would you mind uploading your project files to a platform that doesn’t require an obscene amount of cookies for me to decline – Google Drive or GitHub perhaps?

Besides, the project I used to test and confirm the code’s utility is also simple. Perhaps you can just share a screenshot of your simple scene setup along with the code you’re using?

  • What does the physics scene look like; what colliders are you using?
  • How does the scene hierarchy look?
  • Where are you placing your laser-script?
  • Do you have any additional code that may interfere with the laser’s functionality?

Sorry I didn’t know about them cookies for download, I just got that website off the net. Here’s the screenshot of set up and code.

  • Just capsule collider for player and box for blocks
  • As screenshot
  • Laser in physic_process()
  • This is a new scene with only a standard CharacterBody3D script.

Imgur

extends CharacterBody3D

const SPEED = 5.0
const JUMP_VELOCITY = 4.5
var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")

var cast_length = 10
var max_reflections = 10
@onready var marker3d = $Marker3D


func _physics_process(delta):
	if not is_on_floor():
		velocity.y -= gravity * delta
	if Input.is_action_just_pressed("ui_accept") and is_on_floor():
		velocity.y = JUMP_VELOCITY
	var input_dir = Input.get_vector("left", "right", "up", "down")
	var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
	if direction:
		velocity.x = direction.x * SPEED
		velocity.z = direction.z * SPEED
	else:
		velocity.x = move_toward(velocity.x, 0, SPEED)
		velocity.z = move_toward(velocity.z, 0, SPEED)
	move_and_slide()
	
	
	var space_state = get_world_3d().direct_space_state
	
	# The ray information (initialized for the first ray in the loop)
	var dir = marker3d.global_basis * Vector3.FORWARD
	var from = marker3d.global_position
	var to = marker3d.global_position + dir * cast_length
	var result: Dictionary

	# NOTE: I changed the range-parameters. 0-indexing is a programming standard
	var rays_to_shoot = 1 + max_reflections
	for i in range(rays_to_shoot):
		# Perform a raycast with the current ray information
		var query = PhysicsRayQueryParameters3D.create(from, to)
		result = space_state.intersect_ray(query)

		if result.size() == 0:
			#DebugGeo.draw_debug_line(delta, from, to, 0.025, Color.RED)
			line(from, to, Color.RED, 1)
			break
		else:
			var hue = 0.25 + fmod(i / 2.0, 1.0)
			var color = Color.from_hsv(hue, 1.0, 1.0)
			#DebugGeo.draw_debug_line(delta, from, result["position"], 0.025, color)
			#DebugGeo.draw_debug_sphere(delta, result["position"], 4, 4, 0.2, color)
			line(from, result["position"], color, 0.025)

			# Update ray info
			dir = dir.bounce(result["normal"])
			from = result["position"]
			to = result["position"] + dir * cast_length





########## DRAW LINE
func line(pos1: Vector3, pos2: Vector3, color = Color.WHITE_SMOKE, persist_ms = 0):
	var mesh_instance := MeshInstance3D.new()
	var immediate_mesh := ImmediateMesh.new()
	var material := ORMMaterial3D.new()

	mesh_instance.mesh = immediate_mesh
	mesh_instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF

	immediate_mesh.surface_begin(Mesh.PRIMITIVE_LINES, material)
	immediate_mesh.surface_add_vertex(pos1)
	immediate_mesh.surface_add_vertex(pos2)
	immediate_mesh.surface_end()

	material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	material.albedo_color = color

	return await final_cleanup(mesh_instance, persist_ms)
func final_cleanup(mesh_instance: MeshInstance3D, persist_ms: float):
	get_tree().get_root().add_child(mesh_instance)
	if persist_ms == 1:
		await get_tree().physics_frame
		mesh_instance.queue_free()
	elif persist_ms > 0:
		await get_tree().create_timer(persist_ms).timeout
		mesh_instance.queue_free()
	else:
		return mesh_instance
########## END DRAW LINE

Okay… just to give you a quick update, I tried replacing the CSGBox3Ds in my scene with CollisionShape3Ds and I am now also experiencing a similar issue. The issue is specifically between intersect_ray() and CollisionShape3D.

I will try and find a solution to this.

Alright, I found the solution. Honestly, it’s one of the things I thought about mentioning in my initial response to your problem – I didn’t think it was relevant…
Of course that’s the solution…

Solution

Basically, due to the limits of floating-point precision, the point of intersection retrieved from intersect_ray() may lie inside the shape you’re colliding with. When this happens, you will notice the behaviour you’re experiencing.

As with most issues whose root cause is floating-point precision, you have to add a slight margin to your algorithm (commonly denoted as epsilon in the literature). A value of 0.01 metres away from the collision surface is enough to eliminate the weird behaviour.

I will try to update my initial response so you can mark that as the solution for any future readers.

Relevant changed code:

        var epsilon = 0.01
        dir = dir.bounce(result["normal"])
		from = result["position"] + result["normal"].normalized() * epsilon
		to = from + dir * cast_length
1 Like

*Edit : just saw your post lol. I will give it a try.

Maybe this is the problem? It is something I tried before without success.

The classic floating-point precision error. Something I learned to always look out for since it happens all the time in raytracers and other graphics programs.

Anyway, here is the fix

#everything the same except this line
var collision = direct_state.intersect_ray(muzzle.global_transform.origin, raycast.get_collision_point() + ((raycast.get_collision_point() - muzzle.global_transform.origin ).normalized() * 2))

https://www.reddit.com/r/godot/comments/kqu6mk/3d_sometimes_intersect_ray_will_not_give_me_a/

Yeah, you got it xD

Apparently, this doesn’t happen with CSGBox3D which is weird. But yeah, that’s it.

EDIT: Let me know if it works out.

Yes it works! Thank you so much. This has bothered me for the last 2 weeks.

Very nice!

Give me a few minutes to correct my initial response (code and explanation). Then you can mark that as the solution.

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