Optimization help regarding the custom pathfinding script

So I’ve been making my own custom A* pathfinding algorithm, which works, but I have no idea how to optimize it properly.
I’ve tried a couple of things, but I don’t really have a good idea on how to use them properly, such as Luau or natives.
Yes, I did use bindable functions so it would return something in return.
I’m honestly kind of lost on where to go with this lol, so I’m just here to ask how and what I should change for better performance. :confused:

Char_script

PF_Location

Here’s the PF_algorithm script i guess.

--!native

local module = {}
local Func = require(script.Parent.Parent.Second.module)--Components

@native
function module:A_star(Start_Pos, End_Pos, PF)
	print("- - - - -     A*     - - - - -")
	task.desynchronize()
	local Grid = Func:Create_Grid(PF)
	local open, closed = {}, {}
	local start = Func:Get_closest_node(Start_Pos, Grid)
	local goal = Func:Get_closest_node(End_Pos, Grid)
	start.Parent = start
	start.G_cost = 0
	start.H_cost = Func:Dist(start, goal)
	start.F_cost = start.G_cost + start.H_cost + Func:Is_Type(start)
	table.insert(open, start)
	while #open ~= 0 do
		local current = open[1]
		table.remove(open, table.find(open, current))
		table.insert(closed, current)
		if current == goal then
			print("- - - A* has found the path! - - -")
			task.synchronize()
			return Func:Reconstruct_path(start, goal, closed)
		end
		for _, neighbor in ipairs(Func:get_neighbors(current, Grid)) do
			if table.find(closed, neighbor) then
				continue
			end
			local in_open = table.find(open, neighbor)

			if not in_open then
				neighbor.G_cost = Func:Dist(neighbor, start)
				neighbor.H_cost = Func:Dist(neighbor, goal)
				neighbor.F_cost = neighbor.G_cost + neighbor.H_cost + Func:Is_Type(neighbor)
				neighbor.Parent = current
				table.insert(open, neighbor)
			else	
				--					UPDATE VERTEX
				local cost_to_neighbor = current.G_cost + Func:Dist(current, neighbor)
				if neighbor.G_cost > cost_to_neighbor then
					neighbor.G_cost = cost_to_neighbor
					neighbor.F_cost = neighbor.G_cost + neighbor.H_cost + Func:Is_Type(neighbor)
					neighbor.Parent = current
					if table.find(open, neighbor) then
						table.remove(open, table.find(open, neighbor))
					end
					table.insert(open, neighbor)
				end
			end
		end
		open = Func:Sort_table(open)
	end
	print("- - - A* has not found the path! - - -")
	task.synchronize()
	return {}
end

return module

And here are the secondary functions that PF_algorithm uses.

--!native
local module = {}

@native
function module:Place_Waypoint(Position, Action, Label)
	local new_waypoint = PathWaypoint.new(Position, Enum.PathWaypointAction[Action], Label)
	return new_waypoint
end

@native
function module:get_neighbors(Node, grid)
	task.desynchronize()
	local Neighbors = {}
	for _, N in ipairs(grid) do
		local distance = (Node.Part.Position - N.Part.Position).Magnitude
		if N ~= Node and distance < Node.Part.Size.X*2 then
			table.insert(Neighbors, N)
		end
	end
	return Neighbors
end

@native
function module:is_in_sight(Node1, Node2, PF)
	task.desynchronize()
	local params = RaycastParams.new()
	params.FilterType = Enum.RaycastFilterType.Exclude
	params.FilterDescendantsInstances = {PF}
	local RAY = workspace:Raycast(Node1.Part.Position, (Node2.Part.Position-Node1.Part.Position), params)
	local Success = pcall(function()
		local A = RAY.Instance
	end)
	if Success then
		return false
	else	
		return true
	end
end

@native
function module:Dist(Node1, Node2)
	return (Node2.Part.Position - Node1.Part.Position).Magnitude
end

@native
function module:Sort_table(Table)
	task.desynchronize()
	if #Table ~= 0 then
		table.sort(Table, function(a, b)
			return a.F_cost < b.F_cost
		end)
		return Table
	else	
		return Table
	end
end

@native
function module:Get_closest_node(Pos, grid)
	local min = 99999
	local temp = {}
	for _, node in ipairs(grid) do
		local distance = (node.Part.Position - Pos).Magnitude
		if distance < min then
			min = distance
			table.insert(temp, node)
		end
	end
	return temp[#temp]
end

@native
function module:Is_Type(node)
	task.desynchronize()
	if node.Part:GetAttribute("Type") == "Jump" or node.Part:GetAttribute("Type") == "Custom" then
		return 10
	else	
		return 0
	end
end

@native
function module:Create_Grid(PF)
	local Grid = {}
	for _, part in ipairs(PF:GetChildren()) do
		table.insert(Grid, {["Part"] = part, ["Parent"] = nil, ["F_cost"] = 0, ["G_cost"] = 0, ["H_cost"] = 0})
	end	
	return Grid
end

@native
function module:Pos_in_sight(Pos1, Pos2)
	local RAY = workspace:Raycast(Pos1, (Pos2-Pos1))
	local success = pcall(function()
		local A = RAY.Instance
	end)
	if success then
		return false
	else	
		return true
	end
end

@native
function module:Reconstruct_path(start, goal, closed)
	local PATH = {}
	local temp = {}
	local current = goal
	repeat
		local waypoint = module:Place_Waypoint(current.Part.Position, current.Part:GetAttribute("Type"), current.Part:GetAttribute("Label"))
		table.insert(temp, waypoint)
		current = current.Parent
	until current == start
	local funni = #temp
	repeat
		table.insert(PATH, temp[funni])
		funni -= 1
	until #PATH == #temp	
	return PATH
end

return module

Here’s the rigs script

local figure = script.Parent.Parent
local humanoid = figure.Humanoid
local humanoidrootpart = figure.HumanoidRootPart
local serverscriptservice = game:GetService("ServerScriptService")
local PF_Algorithm = serverscriptservice.PathFinding.Func.FUNCTION
local PF = workspace.CurrentLevel:FindFirstChildWhichIsA("Folder"):WaitForChild("PF")-- PF is a folder that holds the node parts that can be seen

humanoidrootpart:SetNetworkOwner(nil)
local goal = game.Players:GetChildren()[1].Character.HumanoidRootPart

while true do--		Yes i chould make it only generate if target is moving
	task.wait()
	local end_pos = goal.Position
	local dist = (humanoidrootpart.Position - end_pos).Magnitude
	if dist > 4 then
		local PATH = PF_Algorithm:Invoke("A_star", humanoidrootpart.Position, end_pos, PF)
		local index = 2
		local point = PATH[index]
		if point then
			humanoid:MoveTo(point.Position)
			humanoid.MoveToFinished:Wait()
		end
	end
end

Have you tried --!optimize 2?

Tried that but same performance :confused:
Also i had no idea that --!optimize 2 was a thing lol

Looking at the small amount visible in console, it looks like you are printing the same line over and over again: - - - A* has found the path! - - -
Try commenting it out, since spamming print usually lags the game alot. (But this change wouldn’t have significant change in performance)

Also try this, what’s the point of wasting resources if player isn’t moving?

That helped a bit thanks but i need the generating process itself to be optimized somehow :expressionless:

First. It’s crazy expensive to search the whole grid every time to find neighbors. You should precalculate the neighbors in a list. Since this is a grid there is a trivial representation that can be made via table that will let you instantly return neighbors instead of searching (though you’ll have to build that table). That by itself will be probably the most dramatic speedup.

Secondly you are using tables and table.find() for handling the open list and closed lists. That works, but doing a dictionary instead will allow you constant time lookups saving you extra time.

Thirdly you appear to be using parallel luau. That’s fine, but desynchronizing might end up costing you some time so benchmark that (not super familiar with how luau handles it tbh so this might be a useless avenue to pursue, but it seems like you are calling this excessively).

Finally you are recalculating paths every time. But since this is tracking a moving player, you could probably only do partial recalculations by removing some of the nodes and restarting the search (assuming the data has a longer lifetime than your function). You could also just preload the current path into the closed list to potentially help.

I will note that sorting every time might be slow. If there aren’t some awesome optimizations under the hood that I’m not aware of, it would be cheaper to just go through the whole list and track the minimum. (Sorts don’t run at linear time usually)

Finally you seem to be using a grid which is fine, but you could also consider doing some greedy meshing or some more sophisticated grouping to reduce the amount of geometry that needs to be searched. That way you are basically just trying to find the block they are on and know you can move straight through large blocks. So a room will pretty much would be considered a single node if there are no obstacles telling the character to move right to the position it wants. This makes it pretty much a nav mesh which could be more data efficient and potentially reduce the amount of nodes you are searching tens to hundreds of times.

That could actually do it! But how do I precalculate the neighbors?

Depends on how you want to access them.

The most traditional way (and one of the more memory efficient methods) is to just basically create a table that directly represents the game world.

local map = {
  {0, 0, 0, 0, 0, 0, 0},
  {0, 0, 1, 0, 0, 0, 0},
  {0, 2, 1, 0, 0, 0, 0},
  {0, 1, 1, 0, 1, 0, 0},
  {0, 0, 1, 0, 1, 1, 0},
  {0, 0, 0, 1, 1, 3, 0},
  {0, 0, 0, 0, 1, 0, 0},
}

That example uses numbers like 1 for a wall, 2 for start and 3 for finish (copied from an example I made for someone once). Your version wouldn’t store numbers but the actual block or node there, but it’s the same concept. Then finding neighbors is as simple as looking up, down, left, right and diagonal if you support it.

So at most you are looping

for x = -1, 1 do
    for y = -1, 1 do
        if x == y and y == 0 then continue end --dont want to add your own position as a neighbor
        --now just check that the coordinates generated (nodePos + Vector2(x, y) are within the size of the region (though a lookup would likely be nil.
    end
end

Note though that you also need to keep track of the positions in that example if you are storing the node. Maybe store both {node = node, nodePos = vec2Pos}

The other method would simply be to start by going through every node like you do and simply saying

local nodes = {}
for node in graph do
    nodes[node] = getNeighborsSlow(node)
end

local function getNeighbors(node)
    return nodes[node]
end

The second one is easier to implement but will be slower to generate and take up more memory. Though it will technically be faster to query, but by little enough to be negligible.

There are probably ways to speed up building the graph, but those are a bit beyond the scope of this and don’t save a lot for the complexity of adding them in most cases.

Also the more complex methods like I mentioned by grouping would likely be represented much like the second method. But those are a lot more complicated to generate because you first need a grid, then optimize and link to get yourself a graph. Worth the time if you run into performance issues, but significantly more complex.

1 Like

Thanks! Now I will be trying these suggestions out!

1 Like