How to make the track link always point inward?

I’m making a script-based tank track, and when I test the game I notice that on certain curves the track link kind of changes the direction it’s pointed in, it has to be pointed inwards not outwards

this is my script

local RunService = game:GetService(“RunService”)

local enableRotationInterpolation = true

local function getPathPoints()
local pathPoints = {}

for _, part in ipairs(script.Parent.Paths:GetChildren()) do
if part:IsA(“BasePart”) and tonumber(part.Name) then
table.insert(pathPoints, {position = part.Position, name = tonumber(part.Name)})
end
end

table.sort(pathPoints, function(a, b)
return a.name < b.name
end)

local orderedPositions = {}
for _, point in ipairs(pathPoints) do
table.insert(orderedPositions, point.position)
end

return orderedPositions
end

local function calculatePathDistances(pathPoints)
local distances = {0}
for i = 2, #pathPoints do
local dist = (pathPoints[i] - pathPoints[i-1]).Magnitude
table.insert(distances, distances[i-1] + dist)
end
return distances
end

local function moveLinks(links, pathPoints, distances, speed)
local pathLength = distances[#distances]

for i, link in ipairs(links) do
local progress = (speed * tick() + (i - 1) * (pathLength / #links)) % pathLength

  local index1, index2
  for j = 1, #distances - 1 do
  	if progress >= distances[j] and progress < distances[j + 1] then
  		index1, index2 = j, j + 1
  		break
  	end
  end

  local segmentLength = distances[index2] - distances[index1]
  local alpha = (progress - distances[index1]) / segmentLength

  local position = pathPoints[index1]:Lerp(pathPoints[index2], alpha)
  local direction = (pathPoints[index2] - pathPoints[index1]).Unit

  local orientation = CFrame.new(position, position + direction) 

  
  local pivotOffset = link:GetPivot().Position - link.Position
  local pivotedCFrame = orientation * CFrame.new(pivotOffset)

  if enableRotationInterpolation then
  	link.CFrame = link.CFrame:Lerp(pivotedCFrame, 0.1)
  else
  	link.CFrame = pivotedCFrame
  end

end
end

local function setupTankTracks()
local pathPoints = getPathPoints()
local distances = calculatePathDistances(pathPoints)

local links = {}
for _, link in pairs(script.Parent:GetChildren()) do
if link.Name:match(“TrackLink”) then
table.insert(links, link)
end
end

RunService.Heartbeat:Connect(function()
moveLinks(links, pathPoints, distances, 5)
end)
end

setupTankTracks()

1 Like

You could modify the orientation calculation in the moveLinks function. The current code only considers the direction between two consecutive path points, which can lead to abrupt changes in orientation, especially on sharp curves.

-- ... (previous code remains the same)

local function calculateTangent(pathPoints, currentIndex, windowSize)
    local startIndex = math.max(1, currentIndex - windowSize // 2)
    local endIndex = math.min(#pathPoints, currentIndex + windowSize // 2)
    
    local tangent = Vector3.new(0, 0, 0)
    for i = startIndex, endIndex - 1 do
        tangent = tangent + (pathPoints[i + 1] - pathPoints[i]).Unit
    end
    
    return tangent.Unit
end

local function moveLinks(links, pathPoints, distances, speed)
    local pathLength = distances[#distances]
    local windowSize = 5  -- Adjust this value to control smoothness

    for i, link in ipairs(links) do
        local progress = (speed * tick() + (i - 1) * (pathLength / #links)) % pathLength
        local index1, index2
        for j = 1, #distances - 1 do
            if progress >= distances[j] and progress < distances[j + 1] then
                index1, index2 = j, j + 1
                break
            end
        end

        local segmentLength = distances[index2] - distances[index1]
        local alpha = (progress - distances[index1]) / segmentLength

        local position = pathPoints[index1]:Lerp(pathPoints[index2], alpha)
        local tangent = calculateTangent(pathPoints, index1, windowSize)

        -- Use the tangent to create the orientation
        local up = Vector3.new(0, 1, 0)
        local right = tangent:Cross(up).Unit
        up = right:Cross(tangent).Unit
        local orientation = CFrame.fromMatrix(position, right, up, -tangent)

        local pivotOffset = link:GetPivot().Position - link.Position
        local pivotedCFrame = orientation * CFrame.new(pivotOffset)

        if enableRotationInterpolation then
            link.CFrame = link.CFrame:Lerp(pivotedCFrame, 0.1)
        else
            link.CFrame = pivotedCFrame
        end
    end
end

-- ... (rest of the code remains the same)
1 Like

here is the result:

When it reaches the bottom curve it continues pointing downwards, I did some tests without interpolation to find out if this was the problem but it still continues

the links need be like that:

1 Like

Well, then you could maybe implement a orientation calculation that considers the overall shape of the track. It combines the path’s tangent with an “up” vector calculated relative to the track’s center. By blending these vectors based on the path’s curvature, the track links should maintain a natural orientation throughout the circuit.

-- ... (previous code remains the same)

local function calculateTangent(pathPoints, currentIndex, nextIndex)
    return (pathPoints[nextIndex] - pathPoints[currentIndex]).Unit
end

local function moveLinks(links, pathPoints, distances, speed)
    local pathLength = distances[#distances]

    for i, link in ipairs(links) do
        local progress = (speed * tick() + (i - 1) * (pathLength / #links)) % pathLength
        local index1, index2
        for j = 1, #distances - 1 do
            if progress >= distances[j] and progress < distances[j + 1] then
                index1, index2 = j, j + 1
                break
            end
        end

        local segmentLength = distances[index2] - distances[index1]
        local alpha = (progress - distances[index1]) / segmentLength

        local position = pathPoints[index1]:Lerp(pathPoints[index2], alpha)
        
        -- Calculate forward direction (tangent)
        local forward = calculateTangent(pathPoints, index1, index2)
        
        -- Calculate up vector
        local nextIndex = (index2 % #pathPoints) + 1
        local toNext = (pathPoints[nextIndex] - position).Unit
        local up = forward:Cross(toNext):Cross(forward).Unit
        
        -- Create orientation using forward and up vectors
        local right = up:Cross(forward).Unit
        local orientation = CFrame.fromMatrix(position, right, up, -forward)

        local pivotOffset = link:GetPivot().Position - link.Position
        local pivotedCFrame = orientation * CFrame.new(pivotOffset)

        if enableRotationInterpolation then
            link.CFrame = link.CFrame:Lerp(pivotedCFrame, 0.1)
        else
            link.CFrame = pivotedCFrame
        end
    end
end

local function setupTankTracks()
    local pathPoints = getPathPoints()
    local distances = calculatePathDistances(pathPoints)

    local links = {}
    for _, link in pairs(script.Parent:GetChildren()) do
        if link.Name:match("TrackLink") then
            table.insert(links, link)
        end
    end

    RunService.Heartbeat:Connect(function()
        moveLinks(links, pathPoints, distances, 5)
    end)
end

setupTankTracks()
1 Like

Thanks for the answer, this is the result after modifying the script you gave me, to make it easier I can send you the model:

trackhelp.rbxm (53.2 KB)

1 Like

The problem is that when you calculate the orientation, the default “up vector” is set to Vector3.new(0, 1, 0). This tries to make the part always have the upwards face face upwards.

What you want is for the flat (upwards) face of the track to always stay on the same side of the track, not flip around depending on which way the track is moving.

(The full form of this constructor is
lookAt(at : Vector3, lookAt : Vector3, up : Vector3) .)


You can fix this by having the up vector be based on where the other tracks are. The “perfect” way to do this is set the up vector to be any direction facing into the polygon. With convex polygons, you can simplify this to just be based on the previous and next track:

So, for your code, that would be something like:

local prevPoint -- Set to the point before the point being moved towards
local goingToPoint -- Set to the point being moved towards
local nextPoint -- Set to the point after the point being moved towards

-- Get the direction that the upwards side of the track should face
local upVector = (nextPoint + prevPoint) / 2 - nextPoint
local upVectorUnit = upVector.Unit  -- Set the length to one
-- Finally, get the orientation
local orientation = CFrame.new(position, position + direction, upVectorUnit)
1 Like

The changes to the code should work, i’ve tested it thoroughly.

local RunService = game:GetService("RunService")

local function getPathPoints()
	local pathPoints = {}

	for _, part in ipairs(script.Parent.Paths:GetChildren()) do
		if part:IsA("BasePart") and tonumber(part.Name) then
			table.insert(pathPoints, {position = part.Position, name = tonumber(part.Name)})
		end
	end

	table.sort(pathPoints, function(a, b)
		return a.name < b.name
	end)

	local orderedPositions = {}
	for _, point in ipairs(pathPoints) do
		table.insert(orderedPositions, point.position)
	end

	return orderedPositions
end

local function calculatePathDistances(pathPoints)
	local distances = {0}
	for i = 2, #pathPoints do
		local dist = (pathPoints[i] - pathPoints[i-1]).Magnitude
		table.insert(distances, distances[i-1] + dist)
	end
	return distances
end

local function moveLinks(links, pathPoints, distances, speed, tankCFrame)
	local pathLength = distances[#distances]

	for i, link in ipairs(links) do
		local progress = (speed * tick() + (i - 1) * (pathLength / #links)) % pathLength
		local index1, index2
		for j = 1, #distances - 1 do
			if progress >= distances[j] and progress < distances[j + 1] then
				index1, index2 = j, j + 1
				break
			end
		end

		local segmentLength = distances[index2] - distances[index1]
		local alpha = (progress - distances[index1]) / segmentLength

		local position = pathPoints[index1]:Lerp(pathPoints[index2], alpha)

		-- Calculate the local position of the link relative to the tank
		local localPosition = tankCFrame:PointToObjectSpace(position)

		-- Set the link's CFrame using the tank's orientation and the local position
		link.CFrame = tankCFrame * CFrame.new(localPosition)
	end
end

local function setupTankTracks()
	local pathPoints = getPathPoints()
	local distances = calculatePathDistances(pathPoints)

	local links = {}
	for _, link in pairs(script.Parent:GetChildren()) do
		if link.Name:match("TrackLink") then
			table.insert(links, link)
		end
	end

	-- Store the initial CFrame of the tank
	local tankCFrame = script.Parent:GetPivot()

	RunService.Heartbeat:Connect(function()
		moveLinks(links, pathPoints, distances, 5, tankCFrame)
	end)
end

setupTankTracks()
1 Like

the problem continues, did it work for you?

How to apply this to my script? i tried some times and didn’t worked

I believe you would just need to replace this line:

local orientation = CFrame.new(position, position + direction)

with:

local prevPoint = pathPoints[((j-2) % #pathPoints)+1] -- The index before with wrap around
local goingToPoint = pathPoints[j]
local nextPoint = pathPoints[((j) % #pathPoints)+1]  -- The index after with wrap around

-- Get the direction that the upwards side of the track should face
local upVector = (nextPoint + prevPoint) / 2 - nextPoint
local upVectorUnit = upVector.Unit  -- Set the length to one
-- Finally, get the orientation
local orientation = CFrame.lookAt(position, position + direction, upVectorUnit)
1 Like

like that?

local RunService = game:GetService(“RunService”)

local enableRotationInterpolation = true

local function getPathPoints()
local pathPoints = {}

for _, part in ipairs(script.Parent.Paths:GetChildren()) do
if part:IsA(“BasePart”) and tonumber(part.Name) then
table.insert(pathPoints, {position = part.Position, name = tonumber(part.Name)})
end
end

table.sort(pathPoints, function(a, b)
return a.name < b.name
end)

local orderedPositions = {}
for _, point in ipairs(pathPoints) do
table.insert(orderedPositions, point.position)
end

return orderedPositions
end

local function calculatePathDistances(pathPoints)
local distances = {0}
for i = 2, #pathPoints do
local dist = (pathPoints[i] - pathPoints[i-1]).Magnitude
table.insert(distances, distances[i-1] + dist)
end
return distances
end

local function moveLinks(links, pathPoints, distances, speed)
local pathLength = distances[#distances]

for i, link in ipairs(links) do
local progress = (speed * tick() + (i - 1) * (pathLength / #links)) % pathLength

  local index1, index2
  for j = 1, #distances - 1 do
  	if progress >= distances[j] and progress < distances[j + 1] then
  		index1, index2 = j, j + 1


  local segmentLength = distances[index2] - distances[index1]
  local alpha = (progress - distances[index1]) / segmentLength

  local position = pathPoints[index1]:Lerp(pathPoints[index2], alpha)
  local direction = (pathPoints[index2] - pathPoints[index1]).Unit

  
  local prevPoint = pathPoints[((j-2) % #pathPoints)+1] -- The index before with wrap around
  local goingToPoint = pathPoints[j]
  local nextPoint = pathPoints[((j) % #pathPoints)+1]  -- The index after with wrap around

  -- Get the direction that the upwards side of the track should face
  local upVector = (nextPoint + prevPoint) / 2 - nextPoint
  local upVectorUnit = upVector.Unit  -- Set the length to one
  -- Finally, get the orientation
  local orientation = CFrame.new(position, position + direction, upVectorUnit)


  local pivotOffset = link:GetPivot().Position - link.Position
  local pivotedCFrame = orientation * CFrame.new(pivotOffset)

  if enableRotationInterpolation then
  	link.CFrame = link.CFrame:Lerp(pivotedCFrame, 0.1)
  else
  	link.CFrame = pivotedCFrame
  			end
  		end
  	end
  break

end
end
local function setupTankTracks()
local pathPoints = getPathPoints()
local distances = calculatePathDistances(pathPoints)

local links = {}
for _, link in pairs(script.Parent:GetChildren()) do
if link.Name:match(“TrackLink”) then
table.insert(links, link)
end
end

RunService.Heartbeat:Connect(function()
moveLinks(links, pathPoints, distances, 5)
end)
end

setupTankTracks()

Whoops, I meant to use CFrame.lookAt instead of CFrame.new:

local orientation = CFrame.lookAt(position, position + direction, upVectorUnit)

(I edited the original with that too.)

But yes, that’s what I was thinking. Does that seem to work?

1 Like

Thanks for reply! i think it’s better, but still dont point every time to inward, could the probem be the paths orientation?

1 Like

Yeah thinking about that it makes sense: for the flat bits if it uses the positions of the three points in a line it gets a 0 length vector, which has undefined direction.

You might be able to fix that by moving the bottom middle point downwards the tiniest bit and moving the top middle point upwards the tiniest bit.

If that doesn’t work, you could try this code (in place of all the code from before):

-- Get the average point:
local count = 0
local sum = Vector3.new(0,0,0)
for point in pathPoints do
    count += 1
    sum += point
end
local averagePoint = sum/count
-- Get the direction that the upwards side of the track should face
local upVector = averagePoint - position
local upVectorUnit = upVector.Unit  -- Set the length to one
-- Finally, get the orientation
local orientation = CFrame.lookAt(position, position + direction, upVectorUnit)

That code decides which direction is inwards based on where the average of the path points is, so it fixes the edge case where all the 3 relevant points are in a perfect line.

1 Like

thx for reply, need be like that?

local RunService = game:GetService(“RunService”)

local enableRotationInterpolation = true

local function getPathPoints()
local pathPoints = {}

for _, part in ipairs(script.Parent.Paths:GetChildren()) do
	if part:IsA("BasePart") and tonumber(part.Name) then
		table.insert(pathPoints, {position = part.Position, name = tonumber(part.Name)})
	end
end

table.sort(pathPoints, function(a, b)
	return a.name < b.name
end)

local orderedPositions = {}
for _, point in ipairs(pathPoints) do
	table.insert(orderedPositions, point.position)
end

return orderedPositions

end

local function calculatePathDistances(pathPoints)
local distances = {0}
for i = 2, #pathPoints do
local dist = (pathPoints[i] - pathPoints[i-1]).Magnitude
table.insert(distances, distances[i-1] + dist)
end
return distances
end

local function moveLinks(links, pathPoints, distances, speed)
local pathLength = distances[#distances]

for i, link in ipairs(links) do
	local progress = (speed * tick() + (i - 1) * (pathLength / #links)) % pathLength

	local index1, index2
	for j = 1, #distances - 1 do
		if progress >= distances[j] and progress < distances[j + 1] then
			index1, index2 = j, j + 1


	local segmentLength = distances[index2] - distances[index1]
	local alpha = (progress - distances[index1]) / segmentLength

	local position = pathPoints[index1]:Lerp(pathPoints[index2], alpha)
	local direction = (pathPoints[index2] - pathPoints[index1]).Unit

	
			-- Get the average point:
			local count = 0
			local sum = Vector3.new(0,0,0)
			for point in pathPoints do
				count += 1
				sum += point
			end
			local averagePoint = sum/count
			-- Get the direction that the upwards side of the track should face
			local upVector = averagePoint - position
			local upVectorUnit = upVector.Unit  -- Set the length to one
			-- Finally, get the orientation
			local orientation = CFrame.lookAt(position, position + direction, upVectorUnit)

	local pivotOffset = link:GetPivot().Position - link.Position
	local pivotedCFrame = orientation * CFrame.new(pivotOffset)

	if enableRotationInterpolation then
		link.CFrame = link.CFrame:Lerp(pivotedCFrame, 0.1)
	else
		link.CFrame = pivotedCFrame
				end
			end
		end
	break
end

end
local function setupTankTracks()
local pathPoints = getPathPoints()
local distances = calculatePathDistances(pathPoints)

local links = {}
for _, link in pairs(script.Parent:GetChildren()) do
	if link.Name:match("TrackLink") then
		table.insert(links, link)
	end
end

RunService.Heartbeat:Connect(function()
	moveLinks(links, pathPoints, distances, 5)
end)

end

setupTankTracks()

1 Like

Yeah that looks good.

1 Like

thx for reply:3 I tested it and it gave me an error. I was thinking about making the orientation of the links the same as the paths, this way I just adjust the orientation of the paths to be favorable for the links (sorry if it seemed confusing)

But let’s try to solve this error first and then test this other method

1 Like

Whoops, I’ve been using too much python recently. Please change the loop from what’s above to

for index, point in ipairs(pathPoints) do
1 Like

OMG THANK YOU SO MUCH, I LOVE YOU!

ITS WORKING CORRETLY!

THANK YOU

Now that I realize, it’s pointing outwards lol, can you teach me how to point outwards and inwards? maybe some types of mats will have to be out

1 Like

Nice! Glad I could help.


To change which direction is outwards, you can multiple the up vector you got by -1:

-- Get the average point:
local count = 0
local sum = Vector3.new(0,0,0)
for index, point in ipairs(pathPoints) do
    count += 1
    sum += point
end
local averagePoint = sum/count
-- Get the direction that the upwards side of the track should face
local upVector = averagePoint - position
local upVectorUnit = upVector.Unit  -- Set the length to one

-- Flip the upVector to flip which direction is outwards
upVector = upVector * -1

-- Finally, get the orientation
local orientation = CFrame.lookAt(position, position + direction, upVectorUnit)

(Unrelated, but if you want to rotate which direction is forwards, you can do position - direction instead of position + direction inside CFrame.lookAt.)

1 Like