Breakdown: Piercing Raycasts and Bullet Penetration

In this topic, we will be covering a method used to create piercing raycasts with adjustable parameters, which can be used for projectiles such as bullets. This topic uses the help of visual aids.

Disclaimer: Like seen in physics engines, this method makes assumptions that optimize for convex collisions. Concave mesh collisions will always be treated as convex.


How do they work?

We should first cover how piercing raycasts work in this sense, which will help lay the foundations for our algorithm.

So, as we already know, raycasts stop when they hit their first target (seen below).

Ray ends on first hit

From this, we can infer that we will need multiple raycasts to pull off a piercing effect.

A key element of this method will be the exit points of our rays, with the exit point of a ray being the point inwhich the ray ends its overlap with a given object.
Luckily for us, solving for the exit point of a ray is decently trivial. To get an exit point, we can just cast a ray backwards from the other side of the object.

Two rays are cast; one forwards and one backwards


As you can see, this is already beginning to resemble a piercing ray. Now that we have entrance and exit points for our ray, we can compare the distance between them to determine how many world units we had to travel to pierce this object.

But wait! There’s already a problem here. Let’s do the same thing but now with a second object.


Ah, now our ray is hitting the wrong object when going backwards! The entrance and exit points should always be paired together, otherwise this method will not work properly. For this we can simply apply an include-list filter to our backwards raycast operation.

Backwards raycast now has a filter to only include the initial object

Now we see that the backwards raycast is properly ignoring all objects except our target object.

Next, let’s make our ray pierce multiple objects

To pierce multiple objects, we must add an exclude-list filter to our ‘inwards’ raycasts.
For each time we hit an object, we will add it to this exclude list, such that the next inwards raycast will ignore it and move on to the next object.

Each time an object is hit, it is ignored on the next pass.


(It is important that we continue to cast from the starting point, for reasons that will be mentioned shortly)

As we make each pass, we can accumulate our in/out points to a list. This is how we will gather the results of the piercing ray.

Handling intersecting objects

In many game worlds and physics scenarios, you will come across objects that have collisions that overlap or are in very close contact with one-another. Because of this, it’s important that we account for it.

First, let’s look at an example case of intersection and how our methods handle it so far:

Currently this method is producing individual entrance & exit points for each object, even when they overlap. This can cause unwanted outputs and issues with rays that have restricted penetration depths.

Luckily, we can make use of some extra logic to resolve this.
Let’s go back with another example. So far, we have the entrance & exit points for the first object:

Only one pass is made, so we have our first entrance & exit points.


As you may notice above, overlaps between objects that occur along our ray will always be present between the entrance & exit points of a previous pass. This means that we can detect an overlap by casting another ray along this distance. This is displayed with the image below:

Now with all overlapping objects that are within this range, we can gather all of their entrance & exit points. We then get the outer-most points that make up the combined space.

Select the entrance point closest to the ray origin, and the exit point farthest from the ray origin.

Then, only those outer points are stored. We then add all hit objects to the ignore-list for the next iteration and repeat like before.

Final result of the first iteration. See how the entrance and exit points are only on the outside of the solid material?


Code

Now with the ideas out of the way, here’s the code. This section won’t be broken down into details as much as the previous segments. Read the comments for details on each step.

Note: The pierce and overlap_threshold parameters are not required, but are put to display potential behavior adjustments.

Explanation of the parameters

pierce is a number that represents the maximum total depth that this ray can travel in material before it comes to a stop.
For example, if you wanted to cast a ray, resembling a bullet, you may want to allow it to travel through up to 2 world units of walls. In a case where multiple rays are cast, you may want to hold this value in a state so that a bullet’s pierce does not reset every step.

overlap_threshold is a number that essentially dictates how wide, or small, a gap between two objects should be before they are considered ‘overlapping’. Rays do not exit between any gaps smaller than this value.
A value of 0 matches default behavior, while a value of 0.01 compensates for floating-point errors between touching objects. This value can be negative.


--pos & direction: These describe the identity of the ray. 'direction' is expected to be normalized.
--length: The length of the ray
--pierce: The net length, in world units, that the ray will be allowed to penetrate through objects.
--overlap_threshold: When a gap between parts is smaller this threshold, they are considered overlapping.
local function raycast_pierce(pos:Vector3, direction:Vector3, length:number, pierce:number, overlap_threshold:number)
	--`ins` and `outs` represent the RaycastResults from the entrances and exits of the ray
	local ins:{RaycastResult}, outs:{RaycastResult} = {}, {}

	--Setup RaycastParams used to exclude objects from previous passes.
	local exclude_params = RaycastParams.new()
	exclude_params.FilterType = Enum.RaycastFilterType.Exclude

	--Main loop
	while true do
		--Here we set up the secondary loop, for overlap handling:

		--We have to keep track of in/out points for overlap cases.
		local in_result:RaycastResult
		--We will use these distance values to solve for the outer-most points.
		local in_dist:number

		local out_result:RaycastResult
		local out_dist:number

		while true do
			--Repeats until there are no objects overlapping in this pass
			--We hit our first object on the first iteration, then repeat for overlap checks.

			--On the first iteration we will use the total ray length, otherwise we will use the overlap check range (out_dist).
			local cast_length
			if out_result then
				cast_length = out_dist + overlap_threshold
			else
				cast_length = length
			end

			--The direction of the entrance ray
			local a_dir = direction * cast_length

			--The entrance raycast result
			local a_result = workspace:Raycast(pos, a_dir, exclude_params)

			--Break loop if there is no hit
			if not a_result then
				break
			end

			--Exclude this object from future passes
			local inst = a_result.Instance
			exclude_params:AddToFilter(inst)

			--Select this point if it is the outer-most.
			--The first hit is always closest, so this applies to the first result only.
			if not in_result then
				in_result = a_result
				in_dist = a_result.Distance
			end


			--Now do a raycast for the exit point

			--Temporary RaycastParams used to only include this object.
			local include_params = RaycastParams.new()
			include_params.FilterType = Enum.RaycastFilterType.Include
			include_params:AddToFilter(inst)

			--The direction of the exit ray
			local b_dir = direction * length

			--The exit raycast result
			local b_result = workspace:Raycast(pos + b_dir, -b_dir, include_params)

			--Break loop if there is no hit
			if not b_result then
				break
			end

			--Select this point if it is the outer-most
			local b_dist = length - b_result.Distance
			if not out_dist or (b_dist > out_dist)--[[If this point is farther from the origin]] then
				out_result = b_result
				out_dist = b_dist
			end
		end

		if not in_result then
			--Nothing was hit; break loop
			break
		end

		--Store inwards RaycastResult
		table.insert(ins, in_result)

		if not out_result then
			--No exit point; break loop
			break
		end

		--This represents the length that the ray travelled within objects over this pass
		local pierce_dist = out_dist - in_dist

		pierce -= pierce_dist
		if pierce < 0 then
			--Negative pierce remaining; break loop
			break
		end

		--Store outwards RaycastResult
		table.insert(outs, out_result)
	end

	return ins, outs
end

Example file:

PiercingCast-Tutorial.rbxl (84.2 KB)
This file contains the code paired with a visual example. Click to fire a ray from the camera.

17 Likes

Updated code to strip out an unnecessary check. (explanation provided in comments)
Found & suggested by @engine4u!