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
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:

46 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

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