Making multiple raycasts to track bullet

Im trying to make a anti air system, and for the gameplay the bullets are intentionally slow. To track the client-sided bullets I’m using raycasts, however I am very tired and very inexperienced with using raycasts, so this simple concept is clogging my foggy head.

My raycasts currently get longer over time, and don’t actually progress with the bullet (image reference if it helps below, what I have is A. and what I want is B)

For anyone curious, this is my current code for the raycasting

			for index = 0, max_range, (max_range / bullet_checks) do
				raycast_direction = raycast_origin.CFrame.LookVector * -index --increases raycast size
				raycast_result = workspace:Raycast(raycast_origin.Position, raycast_direction)
				
				if (raycast_result ~= nil) then
					raycast_distance += raycast_result.Distance --Adds distance of contact onto other raycasts
					break
				elseif(index == max_range) then
					raycast_distance = nil --Distance not allocated within bullet's range
					break
				end
				raycast_distance += index --Since no object was found on this RC, it adds the index to the sum
				task.wait((max_range / bullet_checks) / round_SPS) --Wait until new Raycast
			end

You’ll want to track a few things:

  1. t₀ start time
  2. x₀ start position
  3. v₀ start velocity
  4. a constant acceleration (e.g. gravity)

Then, on each update you can calculate some new variables:

  1. t delta time (calculated as current time - start time)
  2. x₁ current position by plugging everything into a kinematics equation
  3. last position updated at the end of each frame to be the calculated x₁ current position (and initialized as the x₀ on the first frame)

x₁ = x₀ + v₀t + (1/2)at²

In luau, this might look something like

local currentPosition = startPosition + startVelocity * deltaTime + (1/2) * acceleration * deltaTime ^ 2

With some simple vector math, you then have what you need to perform a raycast from last position to x₁ current position

1 Like

Now I’m not the best with kinematics so I’m not sure how accurate this is, but I have some issues.

Right now my equations is:

local currentPosition = startPosition + startVelocity * deltaTime

I excluded the rest since my bullet’s velocity is constant, which means the acceleration is 0, no force (not even gravity) impedes on it.

The issue is it’s inaccurate and I think I know why, my bullet only has a negative Z angularvelocity, as varying on angles it’s more or less accurate. So would this change the equation at all or is it not set up properly?

You’re correct that if acceleration is 0, it would end up zeroing out the + (1/2)at² part since they’re all multiplied together, so the equation becomes just x₁ = x₀ + v₀t

Could you post your code that calculates the start position, start velocity, and how it updates each cycle? The equation applies in 3d just as well as 2d, but vector math can sometimes be a bit nuanced, so it’s hard to diagnose your issue without the latest code.

1 Like

Yeah of course, sorry for the delay I’ve been preoccupied with stuff

Just to clarify, instead of completely rewriting the code I decided to first make the variable and do the math to compare values between my initial success and the new variable, current_position

local current_position
local delta_t = 0
			
			for index = 0, max_range, (max_range / bullet_checks) do
				raycast_direction = raycast_origin.CFrame.LookVector * -index --increases raycast size
				raycast_result = workspace:Raycast(raycast_origin.Position, raycast_direction)
				
				current_position = raycast_origin.Position + script.Parent.Bullet.AssemblyAngularVelocity * delta_t --Intended to get current position
				
				if (raycast_result ~= nil) then
					raycast_distance += raycast_result.Distance --Adds distance of contact onto other raycasts
					break
				elseif(index == max_range) then
					raycast_distance = nil --Distance not allocated within bullet's range
					break
				end
				raycast_distance += index --Since no object was found on this RC, it adds the index to the sum
				
				task.wait((max_range / bullet_checks) / round_SPS) --Wait until new Raycast
				delta_t += (max_range / bullet_checks) / round_SPS --Adds time passed
			end

Well, you should be raycasting from where the bullet used to be and aimed at where it is currently. All you need to do is for each iteration, set a value equal to the bullet’s current position for reference in the next iteration. current_position also doesn’t seem to do anything, even though you update it.

Also-also, it’s best to move bullets via RenderStepped instead of physics objects.

1 Like

As I said, current_position in this demonstration is not supposed to actually be doing anything, I am comparing the final value of current_position to my current raycasting method that is accurate.

Secondly, the bullet is client-sided. The reason for the raycast is supposed to be the server’s way of tracking the bullet and determining if a bullet has made contact with an object.

I guess I don’t understand your intended approach here. In your original post, you stated the goal is to create bullets that are “intentionally slow”, which I interpret to mean take time to travel through the air as opposed to being an instant hit scan.

As a result of needing “travel time”, the calculation needs to happen over time as well. I mentioned in my post about calculations happening in discrete “updates”, where each call to the update function has the following responsibilities:

  1. Based on time since the projectile was spawned, calculate the current goal position
  2. Perform a raycast between the projectile’s position on the previous update and the new goal position
  3. If nothing is hit, update the projectile’s visual position to the goal position. If something were hit on the way, it would process the hit (e.g. destruct projectile, damage target, play effects, etc.)

The most practical solution would call this update function every frame, for example on every PreRender / RenderStepped event of RunService.

I messed around with some physics equations today and came up with a fun projectile motion script to tinker with. The thrust/drag/lift are sort of unrealistic estimations, but functionally they can be tweaked to achieve one’s desired result.

This has way more variables than what you need for what you were trying to accomplish, but I’ll share the whole thing for posterity.

Normally I’d split this into several scripts, but it’s written in a way that you can cut out parts you don’t need and see the update cycle & spherecasting working as I understood your request in your original post.

This can be run in a server script in ServerScriptService in an empty baseplate. Tweak the parameters for different projectile types and have some fun with it, if you want.

--!strict

local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local AIR_DENSITY = Workspace.AirDensity
local GRAVITY = Workspace.Gravity

export type OnHitCallback = (projectile: Projectile, raycastResult: RaycastResult) -> boolean
export type OnUpdatedCallback = (projectile: Projectile) -> ()
export type OnDestroyedCallback = (projectile: Projectile) -> ()

export type Projectile = {
	projectileType: ProjectileType,

	startPosition: Vector3,
	currentPosition: Vector3,
	lastPosition: Vector3,

	startVelocity: Vector3,
	currentVelocity: Vector3,
	lastVelocity: Vector3,

	thrustForce: number,

	radius: number,
	area: number,
	mass: number,
	dragCoefficient: number,
	liftCoefficient: number,

	startTimeMillis: number,
	lastTimeMillis: number,
	currentTimeMillis: number,
	expireTimeMillis: number,

	onUpdatedCallback: OnUpdatedCallback?,
	onHitCallback: OnHitCallback,
	onDestroyedCallback: OnDestroyedCallback?,
}

export type ProjectileType = "Bullet" | "Rocket"

local raycastParams = RaycastParams.new()

local ProjectileType = {
	Bullet = "Bullet" :: "Bullet",
	Rocket = "Rocket" :: "Rocket",
}

export type stats = {
	speed: number,
	diameter: number,
	mass: number,
	dragCoefficient: number,
	liftCoefficient: number,
	thrustForce: number,
	damage: number,
	maxLifetimeMillis: number,
}

local statsByProjectileType: { [ProjectileType]: stats } = {
	[ProjectileType.Bullet] = {
		speed = 200,
		diameter = 1,
		mass = 1,
		dragCoefficient = 1,
		liftCoefficient = 0,
		thrustForce = 0,
		damage = 10,
		maxLifetimeMillis = 7000,
	},
	[ProjectileType.Rocket] = {
		speed = 30,
		diameter = 2,
		mass = 1,
		dragCoefficient = 1,
		liftCoefficient = 0.35,
		thrustForce = 500,
		damage = 50,
		maxLifetimeMillis = 7000,
	},
}

local function createProjectile(
	projectileType: ProjectileType,
	startPosition: Vector3,
	startVelocity: Vector3,
	thrustForce: number,
	radius: number,
	mass: number,
	dragCoefficient: number,
	liftCoefficient: number,
	startTimeMillis: number,
	expireTimeMillis: number,
	onHitCallback: OnHitCallback,
	onUpdatedCallback: OnUpdatedCallback?,
	onDestroyedCallback: OnDestroyedCallback?
): Projectile
	local projectile: Projectile = {
		projectileType = projectileType,

		startPosition = startPosition,
		currentPosition = startPosition,
		lastPosition = startPosition,

		startVelocity = startVelocity,
		currentVelocity = startVelocity,
		lastVelocity = startVelocity,

		thrustForce = thrustForce,

		radius = radius,
		area = math.pi * radius ^ 2,
		mass = mass,

		dragCoefficient = dragCoefficient,
		liftCoefficient = liftCoefficient,

		startTimeMillis = startTimeMillis,
		lastTimeMillis = startTimeMillis,
		currentTimeMillis = startTimeMillis,
		expireTimeMillis = expireTimeMillis,

		onUpdatedCallback = onUpdatedCallback,
		onHitCallback = onHitCallback,
		onDestroyedCallback = onDestroyedCallback,
	}

	return projectile
end

local function updateProjectile(projectile: Projectile)
	projectile.lastTimeMillis = projectile.currentTimeMillis
	projectile.lastPosition = projectile.currentPosition
	projectile.lastVelocity = projectile.currentVelocity

	projectile.currentTimeMillis = DateTime.now().UnixTimestampMillis

	if projectile.currentTimeMillis > projectile.expireTimeMillis then
		if projectile.onDestroyedCallback then
			projectile.onDestroyedCallback(projectile)
		end
		return
	end

	local deltaTime = (projectile.currentTimeMillis - projectile.lastTimeMillis) / 1000

	if deltaTime <= 0 then
		return
	end

	local horizontalSpeed = Vector3.new(projectile.lastVelocity.X, 0, projectile.lastVelocity.Z).Magnitude
	local liftForce = Vector3.yAxis
		* 0.5
		* projectile.liftCoefficient
		* projectile.area
		* AIR_DENSITY
		* horizontalSpeed ^ 2
	local dragForce = 0.5
		* projectile.dragCoefficient
		* projectile.area
		* AIR_DENSITY
		* projectile.lastVelocity.Magnitude ^ 2
	local localDragForce = -dragForce * projectile.lastVelocity.Unit
	local worldGravityForce = projectile.mass * -GRAVITY * Vector3.yAxis
	local thrustForce = projectile.thrustForce * projectile.lastVelocity.Unit
	local netForce = liftForce + thrustForce + localDragForce + worldGravityForce
	local netAcceleration = netForce / projectile.mass

	projectile.currentPosition = projectile.lastPosition
		+ projectile.lastVelocity * deltaTime
		+ 0.5 * netAcceleration * deltaTime ^ 2

	local currentDirection = projectile.currentPosition - projectile.lastPosition
	projectile.currentVelocity = currentDirection / deltaTime

	if projectile.onUpdatedCallback then
		projectile.onUpdatedCallback(projectile)
	end

	local raycastResult =
		Workspace:Spherecast(projectile.lastPosition, projectile.radius, currentDirection, raycastParams)

	if raycastResult then
		local isValidHit = projectile.onHitCallback(projectile, raycastResult)

		if isValidHit then
			if projectile.onDestroyedCallback then
				projectile.onDestroyedCallback(projectile)
			end
		end
	end
end

local function updateAllProjectiles(allProjectiles: { [Projectile]: true })
	for projectile, _ in allProjectiles do
		updateProjectile(projectile)
	end
end

local function createPartForProjectileType(projectileType: ProjectileType)
	local stats = statsByProjectileType[projectileType]

	local projectilePart = Instance.new("Part")
	projectilePart.Size = Vector3.new(stats.diameter, stats.diameter, stats.diameter)
	projectilePart.Shape = Enum.PartType.Ball
	projectilePart.Color = if projectileType == ProjectileType.Bullet then Color3.new(0, 0, 1) else Color3.new(1, 0, 0)
	projectilePart.Anchored = true
	projectilePart.CanCollide = false
	projectilePart.CanQuery = false
	projectilePart.Transparency = 0
	projectilePart.Name = projectileType

	return projectilePart
end

local allProjectiles: { [Projectile]: true } = {}
local projectilePartByProjectile: { [Projectile]: BasePart } = {}

RunService.PreRender:Connect(function()
	updateAllProjectiles(allProjectiles)
end)

local onHitCallback: OnHitCallback = function(projectile: Projectile, raycastResult: RaycastResult)
	local isValidHit = (raycastResult.Instance :: BasePart).Transparency < 1

	if isValidHit then
		local stats = statsByProjectileType[projectile.projectileType]
		print(`{projectile.projectileType} hit {raycastResult.Instance.Name} dealing {stats.damage} damage`)
	end

	return isValidHit
end

local onUpdatedCallback: OnUpdatedCallback = function(projectile: Projectile)
	local projectilePart = projectilePartByProjectile[projectile]
	local direction = projectile.currentPosition - projectile.lastPosition
	projectilePart.CFrame =
		CFrame.lookAt(projectile.currentPosition, projectile.currentPosition + direction, Vector3.yAxis)
end

local onDestroyedCallback: OnDestroyedCallback = function(projectile: Projectile)
	local projectilePart = projectilePartByProjectile[projectile]
	projectilePart:Destroy()
	projectilePartByProjectile[projectile] = nil
	allProjectiles[projectile] = nil
end

local function createTestProjectileAtPart(originPart: BasePart, projectileType: ProjectileType)
	local stats = statsByProjectileType[projectileType]
	local startPosition = originPart.Position
	local startVelocity = originPart.AssemblyLinearVelocity + originPart.CFrame.LookVector * stats.speed
	local startTimeMillis = DateTime.now().UnixTimestampMillis
	local expireTimeMillis = startTimeMillis + stats.maxLifetimeMillis
	local radius = stats.diameter / 2

	local projectile = createProjectile(
		projectileType,
		startPosition,
		startVelocity,
		stats.thrustForce,
		radius,
		stats.mass,
		stats.dragCoefficient,
		stats.liftCoefficient,
		startTimeMillis,
		expireTimeMillis,
		onHitCallback,
		onUpdatedCallback,
		onDestroyedCallback
	)
	allProjectiles[projectile] = true

	local projectilePart = createPartForProjectileType(projectileType)
	projectilePart.CFrame = CFrame.lookAt(startPosition, startPosition + startVelocity, Vector3.yAxis)
	projectilePartByProjectile[projectile] = projectilePart
	projectilePart.Parent = Workspace
end

local function runTestAsync()
	local originPart = Instance.new("Part")
	originPart.CFrame = CFrame.new(0, 10, 0)
	originPart.Anchored = true
	originPart.Size = Vector3.one
	originPart.Transparency = 0.5
	originPart.Color = Color3.new(0, 1, 0)
	originPart.AssemblyLinearVelocity = Vector3.zero
	originPart.CanCollide = false
	originPart.CanQuery = false
	originPart.Parent = Workspace
	originPart.CFrame = originPart.CFrame * CFrame.Angles(math.pi / 4, 0, 0)

	while true do
		createTestProjectileAtPart(originPart, ProjectileType.Bullet)
		task.wait(0.25)
		createTestProjectileAtPart(originPart, ProjectileType.Rocket)
		task.wait(0.25)
	end
end

task.spawn(runTestAsync)

As a result of trying to incorporate “thrust” for rockets (and subsequently lift and drag), I had to do calculations on a per-frame basis based on values calculated on the previous frame. If you don’t need this and all you have is world-oriented gravity, you could simplify all the math and calculate position deterministically using only the starting values as I explained in an earlier post.

1 Like

Once I’m able to I’ll try modify some of this code into my program and see how it goes.

But what I’ve been trying to do is this:
My bullets are client-sided, the raycasting is meant to be the server’s way of tracking the bullet and seeing if it hit an object.

I am just trying to figure out how to get the current position, since I don’t actually have the bullet for reference. In the lost code snippet it was just meant to show how the equation is done.

So wait, are you doing the actual hit calculations on the client or the server?

1 Like
1 Like

The actual calculations are server sided, the bullet is client-sided.

Ok it turns out, ALL I HAD TO DO was write:

raycast_previous = raycast_origin.Position + raycast_direction

raycast_origin is a reference to the round position part,
raycast_direction is a reference to raycast_origin.CFrame.LookVector * -index

Here is the full code, however as what was pointed out, I may change this for a more consistent updating system. However as of now it meets my demands

local raycast_direction
local raycast_result
			
local raycast_distance = 0
local raycast_previous = raycast_origin.Position

for index = 0, max_range, (max_range / bullet_checks) do
	raycast_direction = raycast_origin.CFrame.LookVector * -index --increases raycast size
	raycast_result = workspace:Raycast(raycast_previous, raycast_direction)
				
	if (raycast_result ~= nil) then
		raycast_distance += raycast_result.Distance --Adds distance of contact onto other raycasts
		break
	elseif(index == max_range) then
		raycast_distance = nil --Distance not allocated within bullet's range
		break
	end
	raycast_distance += index --Since no object was found on this RC, it adds the index to the sum
				
	task.wait((max_range / bullet_checks) / round_SPS) --Wait until new Raycast
	raycast_previous = raycast_origin.Position + raycast_direction --Updates position of where bullet currently is
end

I greatly appreciate everyone’s patience and assistance however, I understand it’s sometimes very difficult to communicate with a problem you cannot fully examine.