How can I make a replay system run at a fixed 60fps?

I’m trying to make a replay system for an obby game at the moment but I’m having trouble with different client framerates. What is the best method for playing a replay at 60fps? If not, 30fps?
I tried while task.wait(1/60) do but it seems to work on higher fps, lags behind on lower fps caps (60). PostSimulation/Heartbeat also with an accumulated time system/deltatime didn’t seem to be entirely accurate either (although I stole the method off devforum so unsure)

Here’s my replay function that works well on 60fps: (reading off keyframes)

	if data then
		local connection
		local counter = 1
		local dummy = models.Replay:Clone()
		dummy:PivotTo(CFrame.new(data[1][1],data[1][2],data[1][3]))
		dummy.Parent = workspace
		connection = RunService.PostSimulation:Connect(function()
			if not data[counter] then 
				connection:Disconnect()
					return 
				end
			local pos = CFrame.new(data[counter][1], data[counter][2], data[counter][3])
			dummy.Torso.CFrame = pos * CFrame.Angles(data[counter][4], data[counter][5], data[counter][6])
			counter += 1
		end)
	end

Video:

Here’s what the recorded frames look like in a table.

[1] = {pos1, pos2, pos3, ori1, ori2, ori3}
[2] = etc..
1 Like

ideally you should update the model every frame and lerp between different keypoints depending on the deltatime of the frame.

This will be tricky but roblox has actually already done it for us using the Keyframe instance.
The documentation of it explains exactly what you’re trying to do so the best solution would be to export your sequences as animations.

small edit:
This is also what Roblox recommends for better performance.
link

2 Likes

Is it possible to dynamically record animations during a speedrun? Or can I convert my table of “keyframes” (indexes with positions) into actual keyframes/animation?

edit: I think I see what you mean Thanks
edit2: maybe not actually :thinking:

1 Like

here’s what i’d do (explanation at the bottom):

  • store the current time of the client (using os.clock() or something similar to get the time)
  • pivot the character to the first position
  • start a frame-locked loop
    • find how long it’s been (current time (os.clock()) minus the time you stored earlier
    • convert the above to your counter by multiplying it by your fixed rate (since its 60fps in your case, it should be something like timeSinceStart * 60)
    • if the counter is equal to or larger than the amount of keyframes you have (aka the length of the data table), you’ve reached the end.
      • pivot the guy to the last cframe, disconnect/cleanup/stop this loop and break.
    • edgecase: if the counter is a whole number (nearly impossible) then check your table for the cframe that’s at the counter + 1 and pivot to it. dont forget to add that +1 to the counter or else you’ll be one frame behind
    • else if not a whole number, do the following:
      • get the counter, rounded down, and set it as a variable. this will be the “last pose”
      • get the counter, rounded up, and set it as a variable. this will be the “next pose”
      • find out how close percentage-wise the counter is to the next pose using the formula: (counter - lastPose) / (nextPose - lastPose) (source)
        • ^ dont forget to store this as a variable
      • construct the cframe for the last pose by using the data stored at data[lastPose + 1]. dont forget the +1
      • construct the cframe for the next pose by using the data stored at data[nextPose + 1]. again, don’t forget the +1
      • now pivot the character by the lastPoseCFrame, lerped to the nextPoseCFrame using the percentage-wise variable as the value. aka, lastPoseCFrame:lerp(nextPoseCFrame, percentageWiseVariable)

the philosophy behind this is that the pose shown should be based on the time and not on a rigid counter, so the time is used to “query”/figure out what counter we’re either on or next to, and if were next to a counter, we have to calculate how it’d look like inbetween the two counters so we simply linearly interpolate between the two positions and rotations based on how close the time is to the next pose.
i use +1 a few times, and that’s because i designed this in mind as if i was using a programming language that uses arrays that start counting at 0. i.e, 0, 1, 2, 3, 4. because roblox starts at 1 for it’s arrays/tables, i adjust the code to not break with roblox by adding a +1 when indexing the tables
also feel free to ask any more questions if anythings confusing. this theoretically should work but i havent tested it

3 Likes

task.wait(…) will return the time it used in waiting. if you have it wait for 1/60, but it could wait longer than that (like 1/30).

we can make use of the returned value

local replayTime = 0
while true do
  sampleReplayAt(replayTime)
  local dt = task.wait(1/60)
  replayTime += dt
end
3 Likes

this will probably answer your question

1 Like

You can try using deltaTime to achieve what you want.

If you don’t know what deltaTime is, it’s simply the time elapsed between the new frame and the previous frame. You could maybe, on the whole record, tell when each frame happens, and then add at each render step the delta time to the global time in the record playing script and move the part to the position of the frame with the nearest time.

Otherwisely said:

  • You have three frames, 1 at 0.2, 2 at 0.3, and three at 0.5. The second number is when the frame happens in the record.
  • Create a new variable in the replay script called “time”
  • At the PostSimulation function, add “delta” as an argument (function(delta))
  • Each time the function fires, add the delta time to the time variable.
  • Then, check which frame time is the nearest to your “time” variable, and move the character’s body parts to the ones in the “best frame”.

Hope it helps. It might be unclear. If it is, please let me know.

1 Like

You can’t run something at a higher FPS than the client allows. It’s impossible. Regardless of the loop type you’re using, the minimum wait time will always be limited by the client’s FPS. This means that your loop will run at 60 FPS for every client that runs Roblox at 60 FPS or higher, or at the current client FPS when it is below 60.

1 Like

I’m having some trouble implementing this method (without the edgecase or orientation), can you have a look at what could be wrong here? Forgive me if I missed a step.

	if data then
		local connection
		local counter = 1
		local dummy = models.Replay:Clone()
		local current_time = os.clock()
		dummy:PivotTo(CFrame.new(data[1][1],data[1][2],data[1][3]))
		dummy.Parent = workspace
		connection = RunService.PostSimulation:Connect(function(dt)
			local current_frame = (os.clock()-current_time)*60
			
			if not data[counter] or (current_frame > #data) then
				connection:Disconnect()
				return 
			end
			local last_pose = math.floor(current_frame)
			local next_pose = math.ceil(current_frame)
			local percent = (current_frame - last_pose) / (next_pose - last_pose)
			local last_pose_cf = CFrame.new(data[last_pose+1][1], data[last_pose+1][2], data[last_pose+1][3])
			local next_pose_cf = CFrame.new(data[next_pose+1][1], data[next_pose+1][2], data[next_pose+1][3])
			dummy:PivotTo(last_pose_cf:Lerp(next_pose_cf, percent))
			counter += 1
		end)
	end
end

Video:

Trying to understand how this works but I’m really unsure how how f / the remaining factor per second is calculated (I’m terrible at math)

i haven’t read this at all but here’s the main server part of a replay system i coded at like 5am over a few days that i never finished because a friend found a more efficient method to store data and save even further performance, hope it helps in some way

function ReplayService.mainLoop()
	debug.profilebegin("main loop")
	
	for _, self : Replay in CurrentlyActive do
		if self.internalTimer <= 0 then
			CurrentlyActive[self.GUID] = nil
			self:export()
		else
			for limb : string, instance: BasePart in self.limbcache do
					local instancePosition : Vector3 = instance.Position
					local instanceRotation : Vector3 = instance.Rotation
					
					local currentLimbBuffer : buffer = buffer.create(12)
					
					debug.profilebegin("writing to buffer") 
					writef16(currentLimbBuffer, 0, instancePosition.X)
					writef16(currentLimbBuffer, 2, instancePosition.Y)
					writef16(currentLimbBuffer, 4, instancePosition.Z)
					
					writef16(currentLimbBuffer, 6, instanceRotation.X)
					writef16(currentLimbBuffer, 8, instanceRotation.Y)
					writef16(currentLimbBuffer, 10, instanceRotation.Z)
					debug.profileend()
					
					self.cframes[limb][self.index] = currentLimbBuffer
			end
			
			self.index += 1
			self.internalTimer -= 1/ReplayService.FPS
			
		end
	end
	
	debug.profileend()
end


function ReplayService.createReplay(data : data)
	debug.profilebegin("replay creation")
	local self = table.clone(Replay) :: Replay

	for type, value in data do
		self[type] = value
	end
	
	self.internalTimer = self.duration
	self.absolutepositions = {} -- meant for delta compression but probably no point adding
	self.limbcache = {}
	self.cframes = {}

	--TODO: add support for custom rigs

	for limb : string, _ in Rigs.Test do --  rig profiles 
		self.limbcache[limb] = self.player[limb] 
	end

	for limb, instance in self.limbcache do
		self.cframes[limb] =  {}
	end

	self.GUID = httpService:GenerateGUID(false) -- TODO: implement batchstore alternative
	self.index = 1
	
	CurrentlyActive[self.GUID] = self
	debug.profileend()
	
	return self
end

Thanks for the contribution. How did you calculate the self.internalTimer / ReplayService.FPS?

try removing the counter variable and the not data[counter] part of the if statement and replace the > in current_frame > #data with >=.

(if it’s just > then the loop should theoretically error on 60+ fps. the error is technically harmless but would show up in the output and… might as well not have any errors yknow)

the if statement should look something like if current_frame >= #data then

dont forget to remove the counter increment that’s at the bottom too

1 Like

holy man thank you so much, the method seemingly actually works. simultaneously impressed and very happy.

video:

1 Like

self.internalTimer is set to self.duration when the clip starts, then that ticks down to 0 by deducting 1/FPS every heartbeat (i probably shouldve used delta in hindsight) which is stored in the actual table of the module just as a sort of config, it was half baked 5am code

1 Like

I know it has already been solved but i’ll leave my solution here anyway because i think it’s easier.

Yes you can dynamically record animations. However you will still need to manually export them to Roblox to be able to play them in your game.

This is a small test place i made which showcases how to record an animation, convert it to keypoints and then play it on a rig.

AnimationRecorder.rbxl (60.8 KB)

Code
local KeyframeSequenceProvider = game:GetService("KeyframeSequenceProvider")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

local player = Players.LocalPlayer
local character = player.Character
local humanoidRootPart = character.HumanoidRootPart

local recordButton = script.Parent.StartRecording
local playAnimation = script.Parent.PlayRecording

local animationFolder = Instance.new("Folder")
animationFolder.Name = "Animations"
animationFolder.Parent = workspace

local function startRecording()
	
	local animationData = {
		startCFrame = humanoidRootPart.CFrame,
		keyframes = {}
	}
	local totalTime = 0
	
	local recording = RunService.PreSimulation:Connect(function(deltaTime)
		totalTime += deltaTime
		table.insert(animationData.keyframes, {humanoidRootPart.CFrame, totalTime})
	end)
	
	return recording, animationData
end

local function convertToKeyframeSequence(animationData)
	
	local keyframeSequence = Instance.new("KeyframeSequence")
	keyframeSequence:SetAttribute("StartCFrame", animationData.startCFrame)
	
	local keyframeAmount = #animationData.keyframes - 1
	
	for frame, keyframeData in animationData.keyframes do
		local cframe, time = table.unpack(keyframeData)
		
		local pose1 = Instance.new("Pose")
		pose1.Name = "HumanoidRootPart"
		
		local pose = Instance.new("Pose")
		pose.Name = "Part"
		pose.CFrame = cframe
		
		local keyframe = Instance.new("Keyframe")
		keyframe.Time = time
		keyframe:AddPose(pose1)
		pose1:AddSubPose(pose)
		
		keyframeSequence:AddKeyframe(keyframe)
		
		if frame == keyframeAmount then
			local marker = Instance.new("KeyframeMarker")
			marker.Name = "LastFrame"
			keyframe:AddMarker(marker)
		end
	end
	
	return keyframeSequence
end

playAnimation.Activated:Connect(function()
	local animations =  animationFolder:GetChildren()
	local mostRecentAnimation = animations[#animations]
	
	local temporaryAnimation = KeyframeSequenceProvider:RegisterKeyframeSequence(mostRecentAnimation)
	
	local model = Instance.new("Model")
	local rootPart = Instance.new("Part")
	rootPart.Anchored = true
	rootPart.Name = "HumanoidRootPart"
	rootPart.Size = Vector3.one
	rootPart.Parent = model
	model.PrimaryPart = rootPart
	
	local animatedPart = Instance.new("Part")
	animatedPart.Size = Vector3.one
	animatedPart.Parent = model
	
	local motor6D = Instance.new("Motor6D")
	motor6D.Part0 = rootPart
	motor6D.Part1 = animatedPart
	motor6D.Parent = rootPart
	
	local animationController = Instance.new("AnimationController")
	local animator = Instance.new("Animator")
	animator.Parent = animationController
	animationController.Parent = model
	model.Parent = workspace
	
	local animation = Instance.new("Animation")
	animation.AnimationId = temporaryAnimation

	local animationTrack = animator:LoadAnimation(animation)
	animationTrack.Looped = false
	animationTrack:Play(0)
	
	animationTrack:GetMarkerReachedSignal("LastFrame"):Wait()
	animationTrack:AdjustSpeed(0)
	task.wait(2)
	model:Destroy()
end)

while true do
	
	recordButton.Activated:Wait()
	recordButton.Text = "Recording"
	recordButton.BackgroundColor3 = Color3.new(1, 0, 0)
	playAnimation.Visible = false
	
	local loop, data = startRecording()
	
	recordButton.Activated:Wait()
	recordButton.Text = "Record"
	recordButton.BackgroundColor3 = Color3.new(0, 1, 0)
	loop:Disconnect()
	
	local keyframeSequence = convertToKeyframeSequence(data)
	keyframeSequence.Name = `Animation_{#animationFolder:GetChildren() + 1}`
	keyframeSequence.Parent = animationFolder
	
	playAnimation.Visible = true
end

2 Likes