Make it possible to do more work within unused portions of a frame

In one of my projects we have a lot of non-replicated client-side work that needs to be done over multiple frames. This work relates to the DataModel, so it is pretty slow and can’t be siloed off from the rest of the frame pipeline like Actors or pure Luau number crunching might be.

I am encountering three problems with this:

  1. Currently we use Heartbeat to do the work, as Heartbeat runs in parallel with rendering. But we can’t safely use up large portions of the 16.67ms of frame time we have available, as we cannot predict how long it will take for post-Heartbeat work (such as sending packets) or Heartbeat work outside of our control (which may happen to execute after our code, e.g. core scripts) to be completed.

  2. We can’t make use of long rendering durations. If rendering takes a long time it creates space where we could be doing work, and being able to make use of that space would be helpful. Long frame times also mean we have less of our work being done overall if we are unable to make use of the free time.

  3. It’s difficult to do basic time management, such as knowing how long the current frame has taken so far, or when it should ideally end. We are not given the time the frame started at and we can’t reliably execute our own timing code until BindToRenderStepped occurs (which may be some time into the frame already) EDIT: We can’t even do this anymore because you guys have changed the relative ordering of events within a frame and recommended not to rely on the ordering (Heartbeat executes in between input callbacks and RenderStepped on mobile - #8 by prpht).

Solutions to any of these problems would be very helpful.

As you can see there is a large portion of space in the frame :sob: It would be great to be able to make use of it without risking some frames that go over 16.67ms (or whatever they would have taken if we didn’t do the work).

My stupid naive solution to first and second problems

Have a RunService.PostEverything signal which fires at the end of a frame when it is guaranteed that the engine will do nothing else afterwards (except for stuff happening in parallel, such as rendering). Callbacks to this signal will use a polling strategy to decide when to stop doing work.

The engine will provide some efficiently accessible information like “IsFrameCompleted” and “FrameCompleteAt”.

FrameCompleteAt is a prediction based on the start time of the frame and the target frame rate, usable like AreWeThereYet = os.clock() >= FrameCompleteAt.

IsFrameCompleted is boolean, which becomes true either when reaching FrameCompleteAt (if rendering is finished by this time) or when the rendering pipeline reaches some milestone (if rendering is not finished when FrameCompleteAt is reached).

I say “reaching some milestone” because it would probably be a bad idea to directly use the point where rendering finishes. That would mean the frame would continue for extra time until the current PostEverything callback next polls, i.e. frames become longer than necessary, we are no longer using just “free time”.

So instead IsFrameCompleted becomes true when the rendering pipeline reaches some point near to the end but not quite there yet, so there is a short buffer to give the current PostEverything callbacks (and whatever ones are after it) time to poll IsFrameCompleted.

I would be fine with this, since the goal is to make use of most of the free time within a frame, not perfectly use all of it. Buffers as large as 1ms would be fine by me (we can start doing extra work in the free time when frames start taking longer 17.67ms, provided rendering is the bottleneck here), and we can expect callbacks’ polling periods to be much shorter than 1ms in any reasonable code of this nature.

Anyway, I expect this solution doesn’t work for some reason. I’m not an expert on the subject :man_shrugging:

11 Likes