Accurate Yielding Down to Ten-Thousandths of a Second

This is just a block of code that scripters will find very useful if you ever need to wait or yield for a shorter period of time than a 60th of a second. You will also find it useful if you need a much more accurate and consistent yield than that of the wait() method.

Make sure this code is put in a local script that is only run once, like a playerscript. It binds functions to renderstep so if the script is ran multiple times it will cause issues.

Simply call _G.AccuWait() instead of wait() in your scripts once this is in place. It’s a global function, so all localscripts will be able to call it once it’s running.

This code is really helpful for fast operations. For example calling wait(0) is only waiting for 0.03 seconds. You could call RenderStep:Wait(). But at best you’ll only yield for 0.0167 seconds, and if the user isn’t running at 60 FPS it’ll be longer.

The AccuWait function offers waits as short as 0.00167 seconds, and it rounds duration down if the user is at lower framerates.

local function StepMarker() FasterStep:Fire() end
for i =1,10 do
    local val = Enum.RenderPriority.Camera.Value - 50
    if i >= 5 then val = Enum.RenderPriority.Camera.Value + ((i-5)*10) else val = Enum.RenderPriority.Camera.Value - ((6-i)*10) end
    RunService:BindToRenderStep("StepMarker"..i, val, StepMarker)
end
local function GetSteps(Duration)
    local StepTime = RSTime/10
    local steps = math.floor(Duration/StepTime)
    return steps
end
function _G.AccuWait(Duration) --This is the function to call
    if Duration == nil then Duration = RSTime/10 end
    local steps = GetSteps(Duration)
    if steps > 1 then
    for i = 1,steps do
        FasterStep.Event:Wait()
        local newsteps = GetSteps(Duration) if newsteps < steps and i >= newsteps then break end
    end
    else
        FasterStep.Event:Wait()
    end
end
RunService.Heartbeat:connect(function(step) RSTime = step end)
18 Likes

This topic was automatically closed after 9 minutes. New replies are no longer allowed.

What are the use cases for wanting to do this? If you want to have some kind of interval event that runs >60Hz, you could just call it multiple times per frame instead to get a similar result without as much hacking around?

Are there any game development use cases that you actually need a >60Hz signal for, considering the client/server can only really deliver a frame / do physics steps once per frame anyway?

4 Likes

This system isn’t really “hacky” it just creates an event that fires 10 evenly spaced intervals per frame, then uses that event in conjunction with the user’s framerate to perform a much more precise and consistent yield.

If you had a custom physics engine, rather than roblox’s default, you could increase it’s accuracy by running it >60Hz.

In my own project, I have weapons with set constant fire-rates. Without this system, the firerate would slow down and fluctuate depending on a user’s framerate. Meaning that players with lower-end machines couldn’t even shoot as fast as others.
This system also opens the possibility of adding in weapons with extreme rates of fire, like miniguns, chainguns, etc.

The idea isn’t that you would normally use the system to perform operations frequently, you would most likely use it to wait the correct amount of time before performing an operation again.

It can perform operations very fast, but it’s main purpose is as a more accurate method than wait().

7 Likes

I would contest that it’s both hacky and does not do what you claim. It’s completely dependent on timing assumptions about RenderPriority, which Roblox makes no guarantees of, and which would surprise me if they were true. It relies on the scheduling timing of BindableEvents on top of that, also not anything with guarantees. The TaskScheduler runs at 60Hz, which sets how fast you can yield and resume Lua threads.

If you think it’s doing what you claim–providing a sub-frame yield–I suspect it’s either a case of “works on my machine” coincidence, or there is some flaw in how you’re measuring it. How exactly are you testing this?

If you need something to run multiple times per frame, evenly spaced, you can divide the frame time up and run a while loop that polls tick(), but you can’t yield the thread.

8 Likes

Emily, I understand what you’re saying about render priority, while it’s true that in most games, this won’t be perfectly evenly spaced timing wise, in my own game it is because of other factors.

It measures time accurately, because it accounts for how long it’s taking the user to “render” a frame. That’s the variable ‘RSTime’.

The StepMarker() function fires a bind-able event 10 times throughout the rendering of a frame, you could up this to theoretically any amount, but I’d be concerned about performance at certain amounts.

With the information of RSTime, we know that FStep will fire at least 10 times during that duration. So at 30 FPS, FStep will fire exactly 300 times a second, at 60 it’ll be 600, etc.

Using the that knowledge, the GetSteps function returns an estimated amount of times FStep will fire, in the supplied duration. It rounds down, if the user’s framerate isn’t high enough.

the AccuWait function uses a for loop, which technically can run as fast as the Lua can process, to yield until that amount of steps has passed.

It’s rarely going to be 100% accurate, but nothing ever is. The point is, it’ll be accurate down to a 600th of a second at 60FPS, a 300th at 30, etc. The same cannot be said for the default wait() function, or RenderStep:Wait() or Heartbeat:Wait().

I haven’t taken steps to actually measure it, but I can certainly see the difference. I have multiple devices to test on, each with it’s own capability to play roblox, and I can tell that the wait times are much more consistent, and are pretty accurate even at low framerates.

1 Like

It’s likely evenly spaced because you’re running approximately the same code each time.

It will indeed fire 10 times per RenderStep. But if you isolate your code down to just the event handler, you’ll see that those 10 events will be handled microseconds apart. Nothing about the mechanism is evenly spacing them out over the duration of the frame, you have a burst of 10 events at about every 0.0167 seconds, the true spacing of which is determined entirely by the code you’re running in the event handler. Replace your code with a line that just stuffs the current value of tick() into an array (without any print() statements it in the handler itself, since they take milliseconds each) and you’ll see what I mean.

That’s not yielding in the way you expect, with this sub-frame timing preserved. Threads can’t resume faster than 60Hz. Your whole burst of FasterStep events is queuing up to be processed on the same (next) ThreadScheduler step. The result will be similar to just calling the code 10 times in a for loop.

:neutral_face:

6 Likes

We used a similar system in a game CDD and I developed.

I thought it was pretty cool and had a few good use cases, nice to see he’s sharing it on the forums.

How is this accurate?

Code used for test
local FasterStep = Instance.new"BindableEvent"
local RunService = game:GetService"RunService"
local function StepMarker() FasterStep:Fire() end
for i =1,10 do
    local val = Enum.RenderPriority.Camera.Value - 50
    if i >= 5 then val = Enum.RenderPriority.Camera.Value + ((i-5)*10) else val = Enum.RenderPriority.Camera.Value - ((6-i)*10) end
    RunService:BindToRenderStep("StepMarker"..i, val, StepMarker)
end
local function GetSteps(Duration)
    local StepTime = RSTime/10
    local steps = math.floor(Duration/StepTime)
    return steps
end
function _G.AccuWait(Duration) --This is the function to call
    if Duration == nil then Duration = RSTime/10 end
    local steps = GetSteps(Duration)
    if steps > 1 then
    for i = 1,steps do
        FasterStep.Event:Wait()
        local newsteps = GetSteps(Duration) if newsteps < steps and i >= newsteps then break end
    end
    else
        FasterStep.Event:Wait()
    end
end
RunService.Heartbeat:connect(function(step) RSTime = step end)
wait(1)
coroutine.resume(coroutine.create(function()
	wait(0.5)
	while true do
		for i=1,50000000 do
			
		end
		RunService.Stepped:Wait()
	end
end))
local t = tick()
_G.AccuWait(3)
local t2 = tick()
print(t2-t)

The Result:

0.92535066604614

Input was three seconds, but it actually yielded less than a second. This appears to be less of an issue when there is less lag.

Code used for test 2
local FasterStep = Instance.new"BindableEvent"
local RunService = game:GetService"RunService"
local function StepMarker() FasterStep:Fire() end
for i =1,10 do
    local val = Enum.RenderPriority.Camera.Value - 50
    if i >= 5 then val = Enum.RenderPriority.Camera.Value + ((i-5)*10) else val = Enum.RenderPriority.Camera.Value - ((6-i)*10) end
    RunService:BindToRenderStep("StepMarker"..i, val, StepMarker)
end
local function GetSteps(Duration)
    local StepTime = RSTime/10
    local steps = math.floor(Duration/StepTime)
    return steps
end
function _G.AccuWait(Duration) --This is the function to call
    if Duration == nil then Duration = RSTime/10 end
    local steps = GetSteps(Duration)
    if steps > 1 then
    for i = 1,steps do
        FasterStep.Event:Wait()
        local newsteps = GetSteps(Duration) if newsteps < steps and i >= newsteps then break end
    end
    else
        FasterStep.Event:Wait()
    end
end
RunService.Heartbeat:connect(function(step) RSTime = step end)
wait(1)
local t = tick()
_G.AccuWait(3)
local t2 = tick()
print(t2-t)
The Results (five runs)

First:

2.3152363300323

Second:

2.6842317581177

Third:

2.48002576828

Fourth:

2.9173548221588

Fifth:

3.5661156177521

The differences in the time yielded are enormous, how is this better than wait is any way? wait doesn’t have the issue of resuming before it should, and generally resumes reasonably quickly when it should. (if it not resuming on time is an issue, use the first return value of wait)

Am I doing something wrong?
By replacing _G.AccuWait(3) with wait(3) its outputs change from about 2.3-3.6 to 3-3.02
This makes AccuWait appear inaccurate in comparison to wait, so I’m going to stick with wait as it is far more accurate.

The issue with the short yield on your end is definitely related to the break condition, in the accuwait function. I’m going to do some additional testing and fix that.

As of right now, I’ve only been testing on extremely short intervals, usually less than a second. So I’ll get back with a solution for that, thanks for bringing it to my attention.

Also, did you by anychance make note of what your framerate was given:

   wait(0.5)
   while true do
   	for i=1,50000000 do
   		
   	end
   	RunService.Stepped:Wait()
   end
end))
1 Like

Earlier today I made modifications to the function, so I officially used this code:

local FasterStep = Instance.new"BindableEvent"
local RunService = game:GetService"RunService"
local function StepMarker() FasterStep:Fire() end
for i =1,10 do
    local val = Enum.RenderPriority.Camera.Value - 50
    if i >= 5 then val = Enum.RenderPriority.Camera.Value + ((i-5)*10) else val = Enum.RenderPriority.Camera.Value - ((6-i)*10) end
    RunService:BindToRenderStep("StepMarker"..i, val, StepMarker)
end
local function GetSteps(Duration)
    local StepTime = RSTime/10
    local steps = math.floor(Duration/StepTime)
    return steps
end
function _G.AccuWait(Duration, diag)
    if Duration == nil then Duration = 0.028 end
    local steps = GetSteps(Duration)
	local t1 = tick()
    if steps > 1 then
    for i = 1,steps do
        FasterStep.Event:Wait()
        local newsteps = GetSteps(Duration) if newsteps < steps and i >= newsteps then steps = newsteps break end
    end
	if diag then
	print("Actual Tick Differece is: "..Duration-(tick()-t1), "Pre-calculated difference estimated: "..(RSTime*math.floor(steps/11)))
	end
    else
        FasterStep.Event:Wait()
    end
end
RunService.Heartbeat:connect(function(step) RSTime = step end)
wait(1)
coroutine.resume(coroutine.create(function()
	wait(0.5)
	while true do
		for i=1,50000000 do
			
		end
		RunService.Stepped:Wait()
	end
end))
for i = 1,15 do
_G.AccuWait(3,true)
end

The pre-calculated difference was taking into account something another programmer and I were discussing, so you can ignore that. But for me the official prints were:

You made a fatal error in your test. You ran the wait code, before the game even finished loading in. As you can see with my prints, that yes while the game engine was initializing, the difference was 2.59 entire seconds. But once it got up and runnning, and your renderstep duration officially settled, then it was much more accurate. You can also see by the negative numbers there, that the break condition was trying to make the wait on the sooner end, rather than later, given the user’s framerate.

Also, it may vary depending on your system, but on my system my framerate with your code was 6 FPS.
You can see that my function on average got within 0.08 (at the latest 0.149 and the soonest 0.03) of the passed duration.

1 Like

I did some more tests, but here are a few things worth mentioning:
Having to yield for it to work, as it errors if .Heartbeat hasn’t fired yet.
Being extremely inaccurate at startup. (It yielded for 0.04 seconds when it was supposed to yield for 3 seconds after a wait())

Code
local FasterStep = Instance.new"BindableEvent"
local RunService = game:GetService"RunService"
local function StepMarker() FasterStep:Fire() end
for i =1,10 do
    local val = Enum.RenderPriority.Camera.Value - 50
    if i >= 5 then val = Enum.RenderPriority.Camera.Value + ((i-5)*10) else val = Enum.RenderPriority.Camera.Value - ((6-i)*10) end
    RunService:BindToRenderStep("StepMarker"..i, val, StepMarker)
end
local function GetSteps(Duration)
    local StepTime = RSTime/10
    local steps = math.floor(Duration/StepTime)
    return steps
end
function _G.AccuWait(Duration, diag)
    if Duration == nil then Duration = 0.028 end
    local steps = GetSteps(Duration)
	local t1 = tick()
    if steps > 1 then
	    for i = 1,steps do
	        FasterStep.Event:Wait()
	        local newsteps = GetSteps(Duration) if newsteps < steps and i >= newsteps then steps = newsteps break end
	    end
		if diag then
			print("time taken: "..tick()-t1)
		end
    else
        FasterStep.Event:Wait()
    end
end
RunService.Heartbeat:connect(function(step) RSTime = step end)
wait()
coroutine.resume(coroutine.create(function()
	wait(0.5)
	while true do
		for i=1,50000000 do
			
		end
		RunService.Stepped:Wait()
	end
end))
delay(15,function()
	while true do
		for i=1,100000000 do
			
		end
		wait(1)
	end
end)
for i = 1,15 do
	_G.AccuWait(3,true)
end

Results

  time taken: 0.049731492996216
  time taken: 0.86077117919922
  time taken: 1.4059293270111
  time taken: 2.7949814796448
  time taken: 2.9742755889893
  time taken: 2.9873900413513
  time taken: 2.9897673130035
  time taken: 1.5776875019073
  time taken: 0.98627018928528
  time taken: 2.1731650829315
  time taken: 1.0044887065887
  time taken: 2.1889901161194
  time taken: 0.99253511428833
  time taken: 2.1731827259064
  time taken: 0.99124073982239

It has issues when framerate is fluctuating, as it appears to be getting more accurate, but then when the section which is delayed 15 seconds starts running, it starts becoming more inaccurate. (Added larger intervals to make framerate more inconsistent)

I also did a test where i changed the wait() to wait(15)

Results

  time taken: 0.90632128715515
  time taken: 2.0160901546478
  time taken: 3.9948163032532
  time taken: 3.6421763896942
  time taken: 2.9893133640289
  time taken: 2.1786572933197
  time taken: 0.99229264259338
  time taken: 2.181449174881
  time taken: 0.98998093605042
  time taken: 2.1763231754303
  time taken: 0.99111604690552
  time taken: 2.1812975406647
  time taken: 0.99193382263184
  time taken: 2.1780078411102
  time taken: 0.99372291564941

This followed a similar pattern, getting more accurate, and then having issues when the framerate was inconsistent. I ran this test twice, and the second time at one point it yielded for over 4 seconds.

I must say I’m intrigued by your results, and I’m glad to have them. I created this function for my games because actual memory usage and queued yields were causing serious inaccuracies with the default wait() function. Also the framerate drops on my projects are caused by GPU limitations if anything else. This most likely explains why in your particular test the default wait() seems better. The coroutine loop method drives up CPU usage, but everything remains consistently low.

I guess I would recommend this system more for high part count places, and not games that are more tasking on the CPU’s end.

1 Like

Never do this.

This is snake oil. You’re not going to get an accurate timer by messing with RenderPriority.

You’re better off running a loop each frame and adjusting iteration count based on framerate.

12 Likes

This is a pretty bad idea as there is no way to make this as stable as you claim in any way. If you really have issues with low FPS players not firing as fast as the ones with high-end systems in shooters, you should queue up the shots and fire them all in the next frame. Just don’t forget to add a bit of a position offset depending on movement and a limit to avoid people “raining” by suspending Roblox for a few frames.

1 Like

There’s better ways of handling fire rate for low fps players.

Queuing and then firing all of their shots on the next frame is bad practice since it gives them an unfair advantage - a lot of guns become shotguns at that point.

It’s better to use tick and delta time imo.

But how does that handle having a weapon that shoots around 40 times a second run the same for people with 60 FPS and 20 FPS?

Well @nooneisback is on the right track and all. The Accuwait system basically queues up the firing until the next frame automatically because my fire function is recursive and only yields for the Accuwait.

@TheRings0fSaturn Made a point about this potentially causing problems on low framerates, as it’s possible for multiple rounds to be dumped in a single frame. However, it’s a moot point, since the firerate for weapons in FPS games varies anywhere from 60 RPM (1 shot per second) and 1350 RPM (22 shots per second). On the high end of firerates, in order for multiple rounds to be fired in a single frame, the user would have to be running slower than 25 FPS. Even then, you’d only be seeing 2 rounds fired sub-frame. So it being a “shotgun” isn’t really all the much of a possibility.

Just for fun we can calculate when that’d start being a problem. Lets assume players would never be lower than 15 FPS (Because why would you play the game at that point). In order to have a decent shotgun effect, you’d need 8 or more rounds fired in a single frame. So their weapon’s firerate would need to be at least 7200 RPM.

Finally I’d like to wrap up why the Accuwait system seems better for me than using normal waits or RunService.Stepped:wait(). It’s an elegant solution that allows me to use the replace tool to replace any usage of wait() with _G.AccuWait() and it doesn’t negatively affect functionality. It however, instantly opens up the possibility of sub-frame operations, something not possible with RunService.Stepped:wait() and not even close to possible with the normal wait(). Alot of my repeating functions utilize recursion, and then an event yield. So I didn’t have to make any additional changes/modifications anywhere, it just worked instantly without issue.

1 Like

I’m going to put this here, I’ve really been trying to avoid advertising any of my projects in this thread, but I feel like it exemplifies both the issue, and how the AccuWait function solved it nicely.

This is Fray 1, it uses version 4.2 of my Apposition Engine. The Fire function is recursive much like version 4.9, but it still uses the default wait() function just about everywhere.

In Fray 1, you’ll notice a few things. The weapons don’t feel as responsive, the firerates and animations seem almost locked to the framerate. The slower the game runs, the slower everything is.

This is Fray 2.0 (to be renamed). It runs on version 4.9 of my engine, among lots of improvements, it uses the AccuWait() system for everything including Animations and the fire() function. You can definitely notice a huge difference, despite the stats on most of the weapons being the same as Fray 1.

2 Likes