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

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

@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.

• 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)
``````

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.

• 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

• 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

@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.

``````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

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

immediate_mesh.surface_begin(Mesh.PRIMITIVE_LINES, material)
immediate_mesh.surface_end()

material.albedo_color = color

return await final_cleanup(mesh_instance, persist_ms)
func final_cleanup(mesh_instance: MeshInstance3D, persist_ms: float):
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 `CSGBox3D`s in my scene with `CollisionShape3D`s 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))

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.