Using a Beam to Model Projectile Motion

So I’ve had this code for a while now and keep forgetting to post it. From the amount of questions about it on the devforum, I think it will be helpful for some people. The code is a little bit messy and unintuitive but it gets the job done. It’s a little hacky and I couldn’t figure out how to make it nicer while still being efficient (if anyone can make it better, please do so and post it here).
This code was largely put together using projectile motion code from an old Roblox tool (slingshot I think) and the code for making the beam is from the tutorial by EgoMoose here.

Put the following code into a ModuleScript (Named “ProjectileLib” and Parented to ReplicatedStorage if you want the example below to work):

local ProjectileLib = {}

-- Returns Tuple:  isInRange(boolean), lauchAngle(in Radians)
local function computeLaunchAngle(horizontalDistance, distanceY, intialSpeed, g) -- gravity must be (+)number
	local distanceTimesG = g*horizontalDistance -- Micro-optimizations
	local initialSpeedSquared = intialSpeed^2
	
	local inRoot = initialSpeedSquared^2 - (g*((distanceTimesG*horizontalDistance)+(2*distanceY*initialSpeedSquared)))
	if inRoot <= 0 then
		return false, 0.25 * math.pi -- Returns false as it is out of range
		-- Makes launch angle 45˚ for ranges past max Range
	end
	local root = math.sqrt(inRoot)
	local inATan1 = (initialSpeedSquared - root) / distanceTimesG
	local inATan2 = (initialSpeedSquared + root) / distanceTimesG
	local answerAngle1 = math.atan(inATan1) -- When optimal launch angle is lofted
	local answerAngle2 = math.atan(inATan2) -- When optimal launch angle is 'direct'
	-- You might be able to change some things and force the launch angle to be lofted at times for certain circumstance
	-- For example, shooting a basketball might require a more lofted trajectory, although EgoMoose's tutorial might be better for that
	if answerAngle1 < answerAngle2 then -- I've honestly never seen it be answer2, I can't figure out the case when it would be. I might remove it at some point
		return true, answerAngle1 -- Returns true as it is in range, same with below
	else
		return true, answerAngle2
	end
end

function ProjectileLib.ComputeLaunchVelocity(distanceVector, initialSpeed, g, allowOutOfRange) -- gravity: (+)number
	local horizontalDistanceVector = Vector3.new(distanceVector.X, 0, distanceVector.Z)
	local horizontalDistance = horizontalDistanceVector.Magnitude
	
	local isInRange, launchAngle = computeLaunchAngle(horizontalDistance, distanceVector.Y, initialSpeed, g)
	if not isInRange and not allowOutOfRange then return end
	
	local horizontaldirectionUnit = horizontalDistanceVector.Unit
	local vy = math.sin(launchAngle)
	local xz = math.cos(launchAngle)
	local vx = horizontaldirectionUnit.X * xz
	local vz = horizontaldirectionUnit.Z * xz

	return Vector3.new(vx*initialSpeed, vy*initialSpeed, vz*initialSpeed)
end

function ProjectileLib.ComputeLaunchVelocityBeam(distanceVector, initialSpeed, g, allowOutOfRange) -- gravity: (+)number
	local distanceY = distanceVector.Y
	local horizontalDistanceVector = Vector3.new(distanceVector.X, 0, distanceVector.Z)
	local horizontalDistance = horizontalDistanceVector.Magnitude
	
	local isInRange, launchAngle = computeLaunchAngle(horizontalDistance, distanceY, initialSpeed, g)
	if not isInRange and not allowOutOfRange then return end
	
	local horizontaldirectionUnit = horizontalDistanceVector.Unit
	local vy = math.sin(launchAngle)
	local xz = math.cos(launchAngle)
	local vx = horizontaldirectionUnit.X * xz
	local vz = horizontaldirectionUnit.Z * xz
	
	-- Just for beaming:
	local v0sin = vy * initialSpeed
	local horizontalRangeHalf = ((initialSpeed^2)/g * (math.sin(2*launchAngle)))/2
	
	local flightTime
	if horizontalRangeHalf <= horizontalDistance then
		flightTime = ((v0sin+(math.sqrt(v0sin^2+(2*-g*((distanceY))))))/g)
	else          
		flightTime = ((v0sin-(math.sqrt(v0sin^2+(2*-g*((distanceY))))))/g)
	end
	--
	
	return Vector3.new(vx*initialSpeed, vy*initialSpeed, vz*initialSpeed), flightTime -- flightTime is used to beam
end

-- v0: initialVelocity(Vec3),   x0: initialPosition(Vec3),   t1: flightTime((+)number),   g: gravity((+)number)
function ProjectileLib.BeamProjectile(v0, x0, t1, g)
	local g = Vector3.new(0, -g, 0)
    -- calculate the bezier points
    local c = 0.5*0.5*0.5
    local p3 = 0.5*g*t1*t1 + v0*t1 + x0
    local p2 = p3 - (g*t1*t1 + v0*t1)/3
    local p1 = (c*g*t1*t1 + 0.5*v0*t1 + x0 - c*(x0+p3))/(3*c) - p2
 
    -- the curve sizes
    local curve0 = (p1 - x0).magnitude
    local curve1 = (p2 - p3).magnitude
 
    -- build the world CFrames for the attachments
    local b = (x0 - p3).unit
    local r1 = (p1 - x0).unit
    local u1 = r1:Cross(b).unit
    local r2 = (p2 - p3).unit
    local u2 = r2:Cross(b).unit
    b = u1:Cross(r1).unit
 
    local cf0 = CFrame.new(
        (x0.x), (x0.y), (x0.z),
        r1.x, u1.x, b.x,
        r1.y, u1.y, b.y,
        r1.z, u1.z, b.z)
 
    local cf1 = CFrame.new(
        (p3.x), (p3.y), (p3.z),
        r2.x, u2.x, b.x,
        r2.y, u2.y, b.y,
        r2.z, u2.z, b.z)
 
	return curve0, -curve1, cf0, cf1
end

return ProjectileLib

The “allowOutOfRange” and related variables are a bit hacky, but they’re good for example if a player is controlling some sort of artillery piece and the target is the player’s mouse –> if the mouse is past the MaxRange of the artillery piece, it will just beam and fire to the MaxRange and no further. Set “allowOutOfRange” true for that.
However, if your making an Archer NPC like the example below, we probably don’t want it to fire unless the target is in range, so we can set it false for that. If it is set false and the target is out of range, ComputeLaunchVelocity will just return nil and we can check for that.

Use Example:

local Debris = game:GetService("Debris")
local ProjectileLib = require(game:GetService("ReplicatedStorage").ProjectileLib)

local function weld2Parts(partA, partB) -- I think this is a very old way to weld
	local weld = Instance.new("ManualWeld")
	weld.Parent = partA
	weld.C0 = partA.CFrame:inverse() * partB.CFrame
	weld.Part0 = partA
	weld.Part1 = partB
	return weld
end


local GRAVITY = workspace.Gravity -- Mine is: 32.174 studs/sec^2 -- This is the actual value for gravity in ft/sec^2
								  -- You can change this in the "Workspace" object in Explorer

local cloneProjectilePart = workspace.ProjectilePart -- Have a part named this in workspace
cloneProjectilePart.Anchored = true
local beamHolderPart = workspace.BeamHolderPart -- Have a part named this in workspace, and inside of it
												-- put 2 attachments and a beam that uses the 2 attachments.
												-- You may want to increase the number of Segments of the beam

local Archer = {} -- Imagine this is an "Archer" Object
Archer.MaxRange = 200 -- This is the Archer's max horizontal range in studs, but only on flat land
					  -- If the Target is lower than the Archer, it will be able to shoot further than it's MaxRange
					  -- If the Target is higher than the Archer, its effective MaxRange will be smaller
Archer.ArrowSpeed = math.sqrt(Archer.MaxRange*GRAVITY)
Archer.Target = workspace.TargetPart -- Have a part named this in workspace

local function archerFireProjectile(ArcherObject)
	local distanceVector = ArcherObject.Target.Position - beamHolderPart.Position
	local initialVelocity, flightTime = ProjectileLib.ComputeLaunchVelocityBeam(distanceVector, ArcherObject.ArrowSpeed, GRAVITY, false)
	if not initialVelocity then print("Target is out of range") return end -- Remove this altogether if allowOutOfRange is true

	local curve0, curve1, cf0, cf1 = ProjectileLib.BeamProjectile(initialVelocity, beamHolderPart.Position, flightTime, GRAVITY)
	beamHolderPart.Beam.CurveSize0 = curve0
	beamHolderPart.Beam.CurveSize1 = curve1
	beamHolderPart.Attach0.CFrame = beamHolderPart.Attach0.Parent.CFrame:Inverse() * cf0
	beamHolderPart.Attach1.CFrame = beamHolderPart.Attach1.Parent.CFrame:Inverse() * cf1
	
	-- Make Projectile and send it flying with initialVelocity:
	local angle = math.atan2(-initialVelocity.X, -initialVelocity.Z) + math.pi*0.5
	local projectilePart = cloneProjectilePart:Clone()
	projectilePart.CFrame = CFrame.new(beamHolderPart.Position) * CFrame.Angles(0, angle - math.pi*0.5, 0)
	projectilePart.CanCollide = false -- Make this true if you want the projectile to bounce off other objects, and remove the weld on touch below
	projectilePart.Parent = workspace
	projectilePart.AssemblyLinearVelocity = initialVelocity -- Give it velocity so it moves!
	projectilePart.Anchored = false
	--projectilePart:ApplyImpulse(initialVelocity) -- Apply Impulse is inaccurate, I'm not sure why
	projectilePart.Touched:Connect(function(otherPartOrTerrain) -- Simple weld on hit for sake of example
		if otherPartOrTerrain.Name ~= "ProjectilePart" and otherPartOrTerrain.Name ~= "BeamHolderPart" then
			weld2Parts(projectilePart, otherPartOrTerrain)
		end
	end)
	Debris:AddItem(projectilePart, 8) -- Destroy the projectile after 8 seconds
end

while wait(1) do
	archerFireProjectile(Archer)
end

I’m still working on a way to get the projectile to look in the tangent of it’s parabolic arc (like an arrow would through flight), but I think it requires constantly updating a BodyGyro or some constraint as it flies since it’s angular rotation is non-linear. If anyone has any idea how to do that, let me know! Thanks.

Edit: Heres a quick, crude video showcase:

44 Likes

The formula for the angle of the arrow through flight is simple:

local angle = math.atan(VerticalSpeed / HorizontalSpeed) -- or vy / vx

So heres an updated version of my use example script that rotates the projectile using the formula above every frame:

local ProjectileLib = require(game:GetService("ReplicatedStorage").ProjectileLib)

local function weld2Parts(partA, partB) -- I think this is a very old way to weld
	local weld = Instance.new("ManualWeld")
	weld.Parent = partA
	weld.C0 = partA.CFrame:inverse() * partB.CFrame
	weld.Part0 = partA
	weld.Part1 = partB
	return weld
end


local GRAVITY = workspace.Gravity -- Mine is: 32.174 studs/sec^2 -- This is the actual value for gravity in ft/sec^2
								  -- You can change this in the "Workspace" object in Explorer

local cloneProjectilePart = workspace.ProjectilePart -- Have a part named this in workspace
cloneProjectilePart.Anchored = true
local beamHolderPart = workspace.BeamHolderPart -- Have a part named this in workspace, and inside of it
												-- put 2 attachments and a beam that uses the 2 attachments.
												-- You may want to increase the number of Segments of the beam

local Archer = {} -- Imagine this is an "Archer" Object
Archer.MaxRange = 200 -- This is the Archer's max horizontal range in studs, but only on flat land
					  -- If the Target is lower than the Archer, it will be able to shoot further than it's MaxRange
					  -- If the Target is higher than the Archer, its effective MaxRange will be smaller
Archer.ArrowSpeed = math.sqrt(Archer.MaxRange*GRAVITY)
Archer.Target = workspace.TargetPart -- Have a part named this in workspace

local function archerFireProjectile(ArcherObject)
	local distanceVector = ArcherObject.Target.Position - beamHolderPart.Position
	local initialVelocity, flightTime = ProjectileLib.ComputeLaunchVelocityBeam(distanceVector, ArcherObject.ArrowSpeed, GRAVITY, false)
	if not initialVelocity then print("Target is out of range") return end -- Remove this altogether if allowOutOfRange is true

	local curve0, curve1, cf0, cf1 = ProjectileLib.BeamProjectile(initialVelocity, beamHolderPart.Position, flightTime, GRAVITY)
	beamHolderPart.Beam.CurveSize0 = curve0
	beamHolderPart.Beam.CurveSize1 = curve1
	beamHolderPart.Attach0.CFrame = beamHolderPart.Attach0.Parent.CFrame:Inverse() * cf0
	beamHolderPart.Attach1.CFrame = beamHolderPart.Attach1.Parent.CFrame:Inverse() * cf1
	
	-- Make Projectile and send it flying with initialVelocity:
	local angle = math.atan2(-initialVelocity.X, -initialVelocity.Z) + math.pi*0.5
	local projectilePart = cloneProjectilePart:Clone()
	projectilePart.CFrame = CFrame.new(beamHolderPart.Position) * CFrame.Angles(0, angle - math.pi*0.5, 0)
	projectilePart.CanCollide = false -- Make this true if you want the projectile to bounce off other objects, and remove the weld on touch below
	projectilePart.Parent = workspace
	projectilePart.AssemblyLinearVelocity = initialVelocity -- Give it velocity so it moves!
	projectilePart.Anchored = false
	--projectilePart:ApplyImpulse(initialVelocity) -- Apply Impulse is inaccurate because it applies it to the center of mass of the arrow mesh, instead of the mesh's actual 3D position
	
	coroutine.wrap(function()
		local inverseHorizontalSpeed = 1/math.sqrt(initialVelocity.X^2 + initialVelocity.Z^2) -- Constant throughout flight so we'll do the rather expensive calculation here and reuse it every frame
		local orientationY, orientationZ = projectilePart.Orientation.Y, projectilePart.Orientation.Z
		
		local rotateConnect = game:GetService("RunService").Stepped:connect(function(time, step)
			projectilePart.Orientation = Vector3.new(math.deg(math.atan(projectilePart.Velocity.Y * inverseHorizontalSpeed)), orientationY, orientationZ)
		end)
		wait(0.2) -- wait a bit so it doesn't weld to whatever is shooting it or to the terrain etc.
		local touchedConnect
		touchedConnect = projectilePart.Touched:Connect(function(otherPartOrTerrain) -- Simple weld on hit for sake of example
			if otherPartOrTerrain.Name ~= "ProjectilePart" and otherPartOrTerrain.Name ~= "BeamHolderPart" then
				weld2Parts(projectilePart, otherPartOrTerrain)
				rotateConnect:Disconnect()
				touchedConnect:Disconnect()
			end
		end)
		wait(8)
		rotateConnect:Disconnect()
		touchedConnect:Disconnect() -- Pretty sure this is disconnected automatically anyways once the part is destroyed
		projectilePart:Destroy() -- Destroy the projectile after 8 seconds
	end)() -- These brackets call the coroutine function instantly like any other function
end

while wait(1) do
	archerFireProjectile(Archer)
end
6 Likes

Sorry for the bump but this was bugging me not to put it here:
To get the orientation of an arrow through flight at any given moment you can use math.atan() like above but a faster solution:
The change in orientation of an arrow through flight is actually linear which surprised me but it’s quite obvious if you think about it. An arrows trajectory is parabolic arc and the tangent line to any point on the graphed arc gives us the arrow’s orientation at that point. So if we take a pseudo-derivative of the arc, we can get a linear function for the tangent line (the arrow’s orientation) at any time during it’s flight. Math.atan() works but if your trying to get a bunch of projectiles in the air like me, you want to limit trig calculations. Heres part of the Example script that I changed:
Note: I also changed some ProjectileLib code to return ‘horizontalRangeHalf’, which is half of the flat ground horizontal range of the projectile given the angle.

...
local initialVelocity, horizontalRangeHalf, launchAngle = ProjectileLib.ComputeLaunchVelocity(distanceVector, ArcherObject.ArrowSpeed, GRAVITY, false)
-- Not beaming this one, just launching an arrow
...

...
coroutine.wrap(function()
		local horizontalSpeed = math.sqrt(initialVelocity.X^2 + initialVelocity.Z^2) -- Constant throughout flight so we'll do the rather expensive calculation here and reuse it every frame
		local orientationY, orientationZ = projectilePart.Orientation.Y, projectilePart.Orientation.Z
		
		local inverseHorizontalRangeHalf = 1/horizontalRangeHalf
		local horizontalDistanceTraveled = 0
		local rotateConnect = game:GetService("RunService").Stepped:connect(function(time, step)
			horizontalDistanceTraveled += horizontalSpeed * step
			local arrowAngle = (1 - horizontalDistanceTraveled * inverseHorizontalRangeHalf) * launchAngle
			projectilePart.Orientation = Vector3.new(math.deg(arrowAngle), orientationY, orientationZ)
			--projectilePart.Orientation = Vector3.new(math.deg(math.atan(projectilePart.Velocity.Y
				--* inverseHorizontalSpeed)), orientationY, orientationZ)
		end)
...
2 Likes

Jusr thought I would say thanks for this and may I say what an awesome Resource you have given us. I tend to make games with slow moving projectiles and am slowly moving them across to this, with the server modelling the projectile for hit registration only with a basic box shape and the clients emulating the movement with the ‘real’ projectiles, which are quite large (think chair size and bigger).

I do find with some of the projectiles, they speed up when reflected, which is rather odd.

1 Like

Thanks for sharing this resource :love_you_gesture:

You code is clean and organised and just how all resources should be

1 Like


I need some help


for some reason


the result is nil