Help brainstorming a FAST target seeking algorithm?

So, there’s a little something I need a bit of help with brainstorming and how it could be implemented.

Normally in a game when you have a zombie, you simply iterate over all players, compare their distances and chase the closest player.
This is the simple and naive way to find the nearest target in a game.

I, however, seek a much faster way to do it.

Issues start to arise if you let’s say… have a game where NPCs fight each other, if you have 200+ NPCs then all of them are going to seek, iterate and compare distances and whatnot and it rapidly becomes very expensive.

I’ve considered perhaps doing a chunk approach, where NPCs are inside a chunk and I only seek targets that are within those chunks, this reduces computing costs when all NPCs are spread out and far apart from each other.

But computing costs increase again once you have NPCs and targets clutter and clump together in just a few chunks.

I’m also not entirely sure how to scan through chunks in a cone shape properly without accidentally skipping over chunks or leaving strips of unchecked chunks.

I’m trying to brainstorm and design an algorithm to look for nearby targets in the absolute fastest way possible.
If it comes at the cost of a little precision that’s alright by me.

If I have a field full of NPCs, that includes NPCs that are far away from each other and certain spots where there might be dense groups of NPCs, I need an algorithm for getting the closest or highest priority NPC with an as low as possible computational cost.

This is mostly just concept for now, I can write code and work it out myself.
I just need some ideas on what could be a good algorithm or how to do this stuff really fast.
I will most likely utilize parallel luau as well.

3 Likes

chunking is the way to go
if you’re only finding the closest NPC to another, then you don’t have to search in a cone of chunks, just the adjacent ones, and if your chunks ARE small enough to require more than adjacency checking, i would be glad to help with a flood-fill or cone based approach

i would have two sets of chunks(or one set per team), so friendly NPC’s can quickly find adjacent enemies without getting lost on other friendlies

1 Like

Here’s my take on an algorithm that does what you need I hope.

As long as you don’t need the exact distance between the target and each of the possible other parts, you can get a very fast distance checking algorithm by leaving out the square root portion of the magnitude formula. Getting the square root is not necessary here because assuming sqrt(a) > sqrt(b) is true then a > b will be true as well. And we know the magnitude is equal to sqrt(x^2 + y^2 + z^2), so if we omit the square root from our distance check, then square our minimum distance, we can find objects within a certain radius without using magnitude.

Adding, subtracting, and multiplying is very cheap and it’s all we need for this.

local function distSquared(a: Vector3, b: Vector3)
	local dir = b - a
	
	local dX, dY, dZ = dir.X, dir.Y, dir.Z

	return dX * dX + dY * dY + dZ * dZ -- magnitude squared
end

local MIN_DISTANCE_STUDS_SQUARED = 50 ^ 2

local target = workspace.target
target.Color = Color3.new(0, 1, 0)

local DEFAULT_COLOUR = BrickColor.new('Medium stone grey').Color
local CLOSEST_COLOUR = Color3.new(1, 0, 1)
local WITHIN_RADIUS_COLOUR = Color3.new(1, 0, 0)
local WITHIN_FOV_COLOUR = Color3.new(0, 1, 1)
local FOV = math.rad(180) -- radians

local otherParts = workspace:WaitForChild('otherParts'):GetChildren()

local otherPartsCount = #otherParts

local lastWithinRadius = {}

while true do
	for _,v in lastWithinRadius do
		v.Color = DEFAULT_COLOUR
	end
	
	table.clear(lastWithinRadius)

	local count = 0

	for i = 1, otherPartsCount do
		local part = otherParts[i]
		if distSquared(target.Position, part.Position) < MIN_DISTANCE_STUDS_SQUARED then
			otherParts[i].Color = WITHIN_RADIUS_COLOUR
			table.insert(lastWithinRadius, otherParts[i])
			count += 1
		end
	end
	
	task.wait()
end

lastWithinRadius will now be populated with a table of parts within a certain radius.

Now while this is the first piece of the puzzle, we now need to find the parts that are within the enemy’s FOV which I believe is what you meant by “cone shaped”

Unfortunately to my knowledge there’s no great to do this without using division or magnitude since using the dot product requires using unit vectors which is is dividing the vector by its own magnitude. That being said, we’ve already narrowed down the parts that are within the appropriate radius, so we wouldn’t have to query parts that aren’t necessary.

To get whether or not an object is within another’s field of view, what we have to do is get the “look” vector (which is literally target.CFrame.LookVector) and find the arc cosine of the scalar between it and the directional vector target → query, then determine whether it is smaller than the target field of view * 0.5. If it is, then we know the part is within the field of view.

	local withinFOV = table.create(count)

	for i = 1, count do
		local part = lastWithinRadius[i]
		local vec = (part.Position - target.Position).Unit
		local look = target.CFrame.LookVector

		local dot = vec:Dot(look)
		local angle = math.acos(dot)
		
		if angle < 0.5 * FOV then
			table.insert(withinFOV, part)
			part.Color = WITHIN_FOV_COLOUR
		end
	end

withinFOV will now be populated with a list of parts within the other part’s field of view.

Finally, we do one last query on withinFOV to find the closest part.

	local closestDistance, closest = math.huge, nil
	
	for i = 1, withinFOVCount do
		local part = withinFOV[i]
		local distance = distSquared(part.Position, target.Position)
		
		if distance < closestDistance then
			closestDistance = distance
			closest = part
		end
	end
	
	if closest then
		closest.Color = CLOSEST_COLOUR
	end

And for a demo:

For reference, I was able to run this algorithm 1000 times for 1024 parts in an average of 0.364 seconds.

3 Likes

This is awesome! I will look into this further and see if this is fit for some of the situations I needed it for.

I’m also assuming DistSquared() is basically a faster, less accurate .Magnitude?

This is of course Lua(u) but it reminds me of the fast square root algorithm that Quake uses to do super fast computations for… I believe things like surface normals and whatnot?

Well it’s more so a case of balancing an equation to minimize the use of square roots altogether until absolutely necessary. It should be about as accurate as .Magnitude, except that instead of giving us the magnitude in studs, it gives us the magnitude squared (^2) in studs.

We know the formula for magnitude is expressed as sqrt(x^2 + y^2 + z^2) – the hindrance with the magnitude formula (why it’s so expensive relative to other operations) is the square root portion. So we somehow need to eliminate the square root from our magnitude formula. Again, this is simply a matter of balancing equations.

So if we know that
m = sqrt(x^2 + y^2 + z^2)
then we do the inverse on the left side (square it) to eliminate the need for the square root on the right side:
m^2 = x^2 + y^2 + z^2 (or m^2 = x*x + y*y + z*z)

Now that we’ve eliminated the need for the most expensive portion we have an extremely cheap and efficient way to determine whether a distance is smaller than another distance.

The only difference here is that when we’re comparing distances, we need to square the number we want to compare to, which again, is very cheap. That is why I had squared the minimum distance here:

When comparing the distance from one part to another part, we already have the other distance squared, which is why we can just call distSquared for each vector and compare it to our existing smallest distance squared.

2 Likes

That’s super clever actually!

So if I’m getting this right, we optimize the algorithm by pre-squaring the distance that we need to compare to?

Normally the maximum distance (or the detection radius) of a NPC is in studs.

And to get the distance between 2 vectors in studs, it needs to be squared or square rooted before we check if it is less than the maximum distance in studs.

But if I’m understanding this correctly, we optimize this simply by (quite literally) reversing the order of calculations?
So we square the maximum detection radius instead so we don’t have to square the distance between 2 vectors?

A while ago I had learned some things about math operations on CPUs and learned that adding, subtracting and multiplying is faster than dividing, squaring, etc.

So the whole algorithm is essentially optimized by simply doing slower calculations involving square(root)ing when the script initializes and we only use add/multiply operations while doing all of the distance checking and whatnot?

Yeah, however just to be clear – squaring isn’t the hindrance for performance, it is a micro optimization to square it beforehand so you don’t have to do it on every single iteration. Squaring would take an extremely minute amount of time compared to more expensive operations like division and square roots and such, so in my opinion it is worthwhile to do it beforehand to avoid a few redundant calculations.

For the first part, yes.

The second part, we square the max detection radius so we don’t have to do the square root portion of the magnitude formula, as, again, finding the square root is relatively expensive compared to other operations.

The slower calculations aren’t done at the beginning, however they are minimized to do as few as possible (ie only on the ones that are within our target radius). Just for clarity, the slower calculations are the .Unit calculations that are done within our FOV check.


I probably should have listed the optimizations were made/things that were taken into consideration, from biggest to smallest:

1- The biggest optimization is the magnitude optimization where we leave out the square root portion of the magnitude formula and instead square the “compare” distance, squaring is inexpensive. We use this to narrow down the possible parts that are within the field of view to ensure the query is as small as possible.

The field of view test is relatively expensive because it requires finding the unit vector of part.Position - target.Position (and possibly part.CFrame.LookVector if it isn’t calculated beforehand like .Magnitude is, however take this with a grain of salt as I’m not 100% sure on the specifics of this). Finding this is expensive because we divide the vector’s magnitude by its own length (magnitude). Again, finding the length/magnitude of a vector requires finding the square root. Both division and square root are relatively expensive, so we want to narrow our query as inexpensively as we can to be as small as possible to avoid doing redundant expensive calculations.

2- Using table.create pre-allocates memory/pre-populates the table with nil so inserting to the table is faster so the table doesn’t have to change in size dynamically, which takes time.

3- Using table.clear clears the table while keeping the memory of the table allocated so we don’t have to re-create the table. Re-creating the table means re-allocating memory which again does take time.

4- Pre-calculating the length of the table also takes time, albeit for the most part a linear amount of time as long as the table isn’t super long. However, as far as I know, the length of the table is not cached in Luau so doing it every time can take a small amount of time, so if we can pre-calculate the length that may be beneficial.


PS. For full transparency, I just wanted to add that while benchmarking, I did remove setting of part colour since writing to properties does take time, it shaved off around 0.04 seconds per 1000 queries.

1 Like