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