FR: Animation frame rate limits

Would it be possible to get some kind of limiter on the animation updates? I don’t think it should strictly follow the vblank, for example, fading in a button at 240hz is pointless.

Normally when I use a VBlankAttachement, I add some logic in to not have it render much faster than 60fps, as those extra paints make no difference and just eat cpu.

This does lead to a lot of bloat, though, and having it baked into the juce class would be a nice addition.

Thoughts?

Love this idea. I do a lot of animation and would like to cap to 60fps app-wide.

3 Likes

This is what I’m using in house at the moment, as food for thought:

class FrameLimitedVBlankAttachment
{
public:
	FrameLimitedVBlankAttachment(juce::Component* componentToAttachTo,
								 std::function<void()> functionOnVblank,
								 double targetFPS = 60.0) : 
		onVBlank(std::move(functionOnVblank)),
		minTimeBetweenFramesMs((1000.0 / targetFPS)-earlyFrameToleranceMs),
		lastVBlankTime(0),
		vblankAttachment(componentToAttachTo, [this] { update(); })
	{

	}

private:
	void update()
	{
		auto now = juce::Time::getMillisecondCounterHiRes();
		auto elapsed = now - lastVBlankTime;

		if (elapsed < minTimeBetweenFramesMs)
			return;

		lastVBlankTime = now;

		if (onVBlank)
			onVBlank();
	}

	std::function<void()> onVBlank;
	const double earlyFrameToleranceMs = 1.0;
	const double minTimeBetweenFramesMs;
	double lastVBlankTime;
	juce::VBlankAttachment vblankAttachment;
};

class VBlankAnimatorUpdater : private juce::AnimatorUpdater
{
public:
	/** Constructs a VBlankAnimatorUpdater that is synchronised to the refresh rate of the monitor
		that the provided Component is being displayed on.
	*/
	explicit VBlankAnimatorUpdater(juce::Component* c) : vBlankAttachment(c, [this] { update(); })
	{

	}

	using juce::AnimatorUpdater::addAnimator, juce::AnimatorUpdater::removeAnimator;

private:
	FrameLimitedVBlankAttachment vBlankAttachment;

};
2 Likes

Although, I would be keen to hear from the JUCE team, and perhaps @matt on this matter. Is this an old way of thinking now with the changes made in JUCE 8? It used to really make a difference to not repaint on every vBlank at higher frame rates when this was all on the cpu. Between mac and windows on the current version of JUCE, is this still an area for significant gains?

This is what I’m using in house at the moment

Thanks for sharing!

is this still an area for significant gains?

It is for me, as I’m often recalculating things like paths/gradients and so on for each animation frame. Having the option to do that @ 60fps vs @ 120fps would make me very happy.

1 Like

I’m in favor of a frame rate limiter.

I like your class; might want to pass the elapsed milliseconds to the onVBlank callback.

Matt

2 Likes

Good call.

Not sure if possible, but it would be cool if that elapsed time could be determined at some global top level, and then passed down to all the vBlankAttachments. This would prevent things falling out of relative sync. A good example is a scrolling waveform, where the processing time on generating one path can be enough to kick the next one a pixel out. I’ve seen when I have 2 overlapping they can fall out of sync from this.

1 Like

Voted. That’s a reason why I am still using friz module instead of juce8 built-in animation. And it would be great if we could have another VBlankAttachment of which we can change the refresh rate (or even better if we could control the refresh rate of all components, not sure whether it is technically possible).

I made a separate post for the other part of this. I have found a global timestep is seemingly quite simple to add to the chain, it’s locked all my animations into sync here. I hope the JUCE team can look into this soon, otherwise I’ll have to fork (ew!).

Frame rate limiting using juce::Time can lead to frame pacing issues. I did some basic frame analysis on your code and found that it drops frames even with a light workload. Using a 500 frame sample size, I checked for dropped frames by utilizing your FrameLimitedVBlankAttachment to call repaint on a component with an empty paint function.

Test setup:
Windows 11
JUCE 8 Release Build (Dev branch)
120 Hz Refresh rate

Frame Skip Interval:
Min: 0
Max: 3
Mean: 1.06702
Mode: 1 (60 Hz effective)
SD: 0.26566
Dropped: 51

Elapsed Times:
Min: 0.4264 ms
Max: 28.9183 ms
Mean: 12.8009 ms
Mode: 16.6702 ms
SD: 4.57221 ms

As shown in the data, the elapsedTime local variable in your update() function fluctuates significantly, leading to inconsistent frame pacing and visible judder.

Another issue with your code is that if the current monitor refresh rate does not divide evenly by 60, then your actual frame rate will be much different from your target.

Here is the same test but with a 144hz refresh rate:

Frame Skip Interval:
Min: 0
Max: 3
Mean: 1.97346
Mode: 2 (36 Hz effective)
SD: 0.179455
Dropped: 2

Elapsed Times:
Min: 1.2825 ms
Max: 29.8596 ms
Mean: 13.7132 ms
Mode: 6.8919 ms
SD: 5.78849 ms

The test results at 144 Hz demonstrate that the effective frame rate drops to 36 Hz, which is far below the intended 60 Hz target. Since each frame at 144 Hz is 6.94 ms compared to 8.33 ms at 120 Hz, skipping just one frame results in 13.88 ms, prompting the code to skip 2 frames, leading to a lower frame rate than intended.

Additionally, multi-monitor setups with different refresh rates are not handled correctly. If the editor window is dragged from a 120 Hz monitor to a 144 Hz monitor, then the issue I described above will occur.

A more robust solution would be to dynamically obtain the current monitor refresh rate and use that for your calculations. On Windows, you can retrieve this information from the HWND handle of the editor window. On macOS, you can obtain it from the CGDisplayModeRef or CVDisplayLinkRef associated with the monitor.

2 Likes

Thanks for sharing your results. It doesn’t align to what I’m seeing here, but you raise a lot of valid points. I’m likely just getting lucky with my chosen times and refresh rate to not be seeing such low effective frame rates.

I agree that the target should be an integer division of the displays native rate, so if we are targeting 60, and the display is at 100, we use 50. At 144 we use 72. etc.

Is there an issue within juce::Time itself? I was playing around with establishing the time at the top of the vBlank chain and passing it down through the callbacks, and I noticed that there was jitter present, even there.

If the display framerate is known then a good solution would be to drop frames to get the closest evenly spaced frames (ie constant framerate) corresponding to a given target.
So for example with a target of 60Hz, here is what you would end up with with common display frame rates:

  • 50Hz: drop 0 frame
  • 60Hz: drop 0 frame
  • 120Hz: drop 1 frame out of 2, resulting framerate 60Hz
  • 144Hz: drop 1 frame out of 2, resulting framerate 72Hz
  • 165Hz: drop 2 frames out of 3, resulting framerate 55Hz
  • 280Hz: drop 3 frames out of 4, resulting framerate 70Hz
  • 360Hz: drop 5 frames out of 6, resulting framerate 60Hz
  • 540Hz: drop 8 frames out of 9, resulting framerate 60Hz
3 Likes

Yeah, this is what I had in mind too, great illustration.