VFX Speed Reliant on FPS

I’ve started creating my first ever ability with VFX. This one consists of an ice ability, which rapidly shoots three projectiles that follow the path of varying bezier curves with the destination being the current location of the mouse. I am handling all effects on client, and the hitbox on the server.

My issue is that I have the projectile effects client side, which is what I believe to be the ideal place for the effects to take place. However, at the moment, the speed of the effects are greatly influenced by the players FPS. This makes for an unsynced effect and hitbox unless the player is at a steady 60 FPS. I am unsure as to why this is the case.

Video examples

60 FPS (works as intended):

Above 60FPS (hitbox on usual timing, effects too fast):

I’ve provided my code below. Now, I must warn you, my code is very messy as I am still learning, but I’ve tried to add some comments to help you venture through the mess :> (tips on improving any of it also appreciated haha). Side note, I do plan on adding debounce!! (Although, throwing down an unholy amount of spells onto your friends is far too fun).

Local script
--Local script--

--Services--
local UserInputService = game:GetService("UserInputService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Debris = game:GetService("Debris")
local player = game:GetService("Players").LocalPlayer
local TweenService = game:GetService("TweenService")
local character = player.Character or player.CharacterAdded:Wait()

--Constant variables--
local MAX_DISTANCE = 70 --Max distance target can be
local MAX_VIEW_DISTANCE = 400 --Max distance player can see VFX from

local remote = ReplicatedStorage:WaitForChild("IceBallRemote")
local mouse = player:GetMouse()

--Check if player has pressed Q
local function DetectInput(input, gameprocessed)
	if gameprocessed then return end

	if input.KeyCode == Enum.KeyCode.Q then
		local position = character.HumanoidRootPart
		for i = 1, 3 do
			--Check if desired target is in range
			if (position.Position - mouse.Hit.Position).Magnitude < MAX_DISTANCE and character:WaitForChild("Humanoid").Health > 0 then
				local position = character.HumanoidRootPart
				remote:FireServer(mouse.Hit, position)
				task.wait(0.3)
			end
		end
	end
end


--All iceball VFX--
local function IceBallBezier(mouseHit, randomNumber, position)
	local iceBall = ReplicatedStorage.FX:WaitForChild("IceBall"):Clone()
	iceBall.Parent = workspace.WorkspaceFX
	local HumanoidRootPart = position
	Debris:AddItem(iceBall, 1.5)
	
	--Bezier curve calculations
	local function Curve(t, P0, P1, P2)
		local A = P0:Lerp(P1, t)
		local B = P1:Lerp(P2, t)
		return A:Lerp(B, t)
	end
	
	local Mid = (HumanoidRootPart.Position - mouseHit.Position).Magnitude

	local Part0 = HumanoidRootPart.Position + Vector3.new(0, 0, -2)
	local Part1 = (HumanoidRootPart.CFrame * CFrame.new(-Mid/randomNumber, Mid/4, -Mid/4)).Position
	local Part2 = mouseHit.Position
	
	iceBall["Fire Crackling Sound"]:Play()
	
	--Move iceball across curve
	for i = 0,1,0.045 do
		local Bezier = Curve(i,Part0,Part1,Part2)
		iceBall.Position = Bezier
		task.wait()
	end
	
	--Disable active particles in iceball
	local function StopParticles()
		iceBall.Transparency = 1
		iceBall["Fire Crackling Sound"]:Stop()
		for i, particle in ipairs(iceBall.Middle:GetChildren()) do
			if particle:IsA("ParticleEmitter") then
				if particle.Name ~= "Vortex" then
					particle.Enabled = false
				else
					particle:Destroy()
				end
				task.wait()
			end
		end
	end
	
	--Explosion function
	local function Explosion()
		iceBall:FindFirstChild("ActiveLight"):Destroy()
		--Part for explosion location
		local explosionPoint = Instance.new("Part", workspace.WorkspaceFX)
		explosionPoint.Anchored = true
		explosionPoint.CanCollide = false
		explosionPoint.CanQuery = false
		explosionPoint.CanTouch = false
		explosionPoint.Position = mouseHit.Position
		explosionPoint.Transparency = 1
		local light = Instance.new("PointLight", explosionPoint)
		light.Color = Color3.fromRGB(143, 188, 255)
		light.Brightness = 12
		light.Range = 12
		light.Shadows = true
		--Tween info for explosion light
		local tweenInfo = TweenInfo.new(
			0.7, 
			Enum.EasingStyle.Sine, 
			Enum.EasingDirection.Out, 
			0, 
			false, 
			0)
		--Tween explosion light off
		TweenService:Create(light, tweenInfo, {Brightness = 0}):Play()
		local attatchment = Instance.new("Attachment", explosionPoint)
		Debris:AddItem(explosionPoint, 3.5)
		--Play explosion sounds
		local hitSound = ReplicatedStorage.SFX["Ice Spawn SFX"]:Clone()
		hitSound.Parent = explosionPoint
		hitSound:Play()
		local hitSound2 = ReplicatedStorage.SFX["Ice 2 SFX"]:Clone()
		hitSound2.PlaybackSpeed = 1.5
		hitSound2.Parent = explosionPoint
		hitSound2:Play()
		local hitSound3 = ReplicatedStorage.SFX["water_magic"]:Clone()
		hitSound3.Parent = explosionPoint
		hitSound3:Play()
		--Emit explosion particles
		for i, particle in ReplicatedStorage.FX.Explosion:GetChildren() do
			local newParticle = particle:Clone()
			newParticle.Parent = attatchment
			--Emit current particle amount in EmitCount attribute
			newParticle:Emit(newParticle:GetAttribute("EmitCount"))
		end
	end
	
	task.spawn(StopParticles)
	task.spawn(Explosion)
end

--Client side
local function OnClient(mouseHit, position)
	--If player is further away than the max viewing distance, return
	if (character.HumanoidRootPart.Position - mouseHit.Position).Magnitude > MAX_VIEW_DISTANCE then return end
	
	local finalNum
	local randomNum = math.random(1,2)
	if randomNum == 1 then
		finalNum = 1
	else
		finalNum = -1
	end
	local function foo()
		IceBallBezier(mouseHit, (math.random(20,60)/10)*finalNum, position)
	end
	task.spawn(foo)	
end

UserInputService.InputBegan:Connect(DetectInput) 
remote.OnClientEvent:Connect(OnClient)
Server script
--Server script--

--Services--
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TweenService = game:GetService("TweenService")
local Debris = game:GetService("Debris")

local remote = ReplicatedStorage:WaitForChild("IceBallRemote")

--Constant variables--
local HITBOX_SIZE = Vector3.new(14,10,14)
local DAMAGE = 15

--Hitbox creation--
local function CreateHitBox(mouseHit, position)
	local overlapParams = OverlapParams.new()
	local hitContents = workspace:GetPartBoundsInBox(mouseHit*CFrame.new(0,HITBOX_SIZE.Y/2,0), HITBOX_SIZE, overlapParams)
	
	local hitList = {}
	for i, v in pairs(hitContents) do
		local targetCharacter = v.Parent
		local humanoid = targetCharacter:FindFirstChild("Humanoid")
		
		if humanoid then
			local character = position.Parent
			--Make sure not to damage player who cast the spell
			if not hitList[humanoid] and targetCharacter ~= character then
				hitList[humanoid] = true
				humanoid:TakeDamage(DAMAGE) --Deal specified damage
			end
		end
	end
end

--Server side
local function ServerRecieved(player, mouseHit, position)
	local function spawnHitbox()
		task.wait(0.43) --How long until hitbox spawns from when Q is pressed
		CreateHitBox(mouseHit, position)
	end
	task.spawn(spawnHitbox)
	remote:FireAllClients(mouseHit, position)
end

remote.OnServerEvent:Connect(ServerRecieved)

Apologies for the lengthiness of the scripts and thank you for your time! If anyone knows any good bezier curve modules that can help simplify the process for me I’d also be extremely grateful :smiley:

3 Likes

I believe the problem is a simple delta time configuration.

Delta in the context of RunService, is how much time has passed between each frame/tick. the larger the fps, the smaller the delta. This helps throttle values to move at the same speed, as the fps they are rendered in changes.

--Move iceball across curve
for i = 0,1,0.045 do
    local DeltaTime = game:GetService("RunService").RenderedStep:Wait()
	local Bezier = Curve(i * DeltaTime * 60, Part0, Part1, Part2) --multiplied by 60 so the small number DeltaTime wont slow down the effect
	iceBall.Position = Bezier
end

I haven’t tested this snippet of code, but the overall idea is here. Hope this helps! :grin:

1 Like

Thank you so much for your help! I ended up using a slightly different method, but it followed a similar idea that yours did by referencing this post: How to make lerp Bezier Curves with RunService! [Chapter 1]

New code:

--New variables
local currentElapsed = 0
local moveTime = 0.43 --Time it takes for projectile to reach target (same time for hitbox to spawn)
	
--Bezier curve calculations
local function Curve(t, P0, P1, P2)
	local A = P0:Lerp(P1, t)
	local B = P1:Lerp(P2, t)
	return A:Lerp(B, t)
end
	
local Mid = (HumanoidRootPart.Position - mouseHit.Position).Magnitude

local Part0 = HumanoidRootPart.Position + Vector3.new(0, 0, -2)
local Part1 = (HumanoidRootPart.CFrame * CFrame.new(-Mid/randomNumber, Mid/4, -Mid/4)).Position
local Part2 = mouseHit.Position
	
--Move iceball across curve
while currentElapsed < moveTime do
	currentElapsed += RunService.Heartbeat:Wait()
	iceBall.Position = Curve(math.min(1, currentElapsed/moveTime),Part0,Part1,Part2)
end

This has perfectly synced the path of the projectile regardless of player FPS/framerate; note the framerate in the video demonstration below (with some extra pizzazz added to the VFX)!!

This topic was automatically closed 14 days after the last reply. New replies are no longer allowed.