Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API to adjust FPS #2889

Open
1 of 11 tasks
erexo opened this issue Jan 22, 2024 · 17 comments
Open
1 of 11 tasks

API to adjust FPS #2889

erexo opened this issue Jan 22, 2024 · 17 comments

Comments

@erexo
Copy link
Contributor

erexo commented Jan 22, 2024

Operating System

  • Windows
  • macOS
  • Linux
  • FreeBSD
  • OpenBSD
  • Android
  • iOS
  • Nintendo Switch
  • PlayStation 5
  • Xbox
  • Web Browsers

What feature would you like to be added?

Currently we are able to somewhat limit the FPS by mingling with the SetVsyncEnabled function.
The problem I have with this approach is that it accepts boolean, so we are in control of the FPS limiting but our control is limited to two states.
What I would really appreciate to see is something like SetMaxFPS which limits the FPS to given value when VSync is disabled.

Why is this needed?

When VSync is off, the game can consume insane amount of processing power which is very inconvenient for a lot of users with older (and newer) PCs.
When VSync is on, players sometimes report that the game is laggy and that generally the performance is throttled, this is usually because their Monitor's FPS has invalid configuration on their Windows system (ie. Monitor is 144hz and Windows states that it's 60hz).
Usually a quick reconfiguration of player's system settings helps, but I encounter this problem over and over and I can't tell how many people just left the game in silence
It would be much much easier for me as a developer to just limit the FPS to 140 in code, so that despite the Monitor settings user will have a smooth experience that is not killing his machine at the same time.

@hajimehoshi
Copy link
Owner

Adjusting FPS from program is impossible so I don't think I'll introduce this feature.

Have you tried SetScreenClearedEveryFrame(false) + vsync off? This could save CPU and GPU consumption.

@hajimehoshi
Copy link
Owner

func main() {
    SetScreenClearedEveryFrame(false)
    SetVsyncEnabled(false)
    if err := ebiten.RunGame(&Game{}); err != nil {
        panic(err)
    }
}

func (g *Game) Draw(screen *ebiten.Image) {
    now := time.Now()
    if now.Sub(g.lastRender) < time.Second / 60 {
        return
    }
    g.lastRender = now

    screen.Clear()
    // ...
}

@erexo
Copy link
Contributor Author

erexo commented Jan 23, 2024

yes I did try something similar long time ago, but then when the vsync was off, the FPS (number of the actual Draw calls) was gigantic and the CPU was still terribly devastated.

It looks like the ebitengine with vsync off is trying it's best to squeeze every ounce of processing power that it can grab, no matter what's happening inside the Draw function

@hajimehoshi
Copy link
Owner

yes I did try something similar long time ago, but then when the vsync was off, the FPS (number of the actual Draw calls) was gigantic and the CPU was still terribly devastated.

Try this with the latest Ebitengine now. This should suppress CPU usages as long as Draw returns immediately.

https://ebitengine.org/en/documents/2.5.html#GPU_Optimization_with_SetScreenClearedEveryFrame(false)

@hajimehoshi
Copy link
Owner

Now I tested this

package main

import (
	"sync"

	"github.com/hajimehoshi/ebiten/v2"
)

type Game struct {
	once sync.Once
}

func (g *Game) Update() error {
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
}

func (g *Game) Layout(width, height int) (int, int) {
	return width, height
}

func main() {
	ebiten.SetScreenClearedEveryFrame(false)
	ebiten.SetVsyncEnabled(false)
	if err := ebiten.RunGame(&Game{}); err != nil {
		panic(err)
	}
}

On Windows, the CPU usage was about 50%, which was not expected. This is a bug and I'll try to fix this.

On macOS, the CPU usage was less than 5%.

@hajimehoshi
Copy link
Owner

@erexo Please try v2.6 (256d403) or main (8551cd3), thanks

@erexo
Copy link
Contributor Author

erexo commented Jan 24, 2024

it works a lot better now, good job!
one thing I've noticed is that the ebiten.ActualFPS() doesn't seems to follow actual meaningful Draw calls, does it mean that the swaps are still happening when they shouldn't?

package main

import (
	"fmt"
	"time"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/ebitenutil"
)

const FPS = 10 // change_me

func main() {
	ebiten.SetScreenClearedEveryFrame(false)
	ebiten.SetVsyncEnabled(false)
	if err := ebiten.RunGame(&Game{}); err != nil {
		panic(err)
	}
}

type Game struct {
	lastRender   time.Time
	lastFpsCheck time.Time
	lastRealFps  int
	realFps      int
}

func (g *Game) Update() error {
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	now := time.Now()
	if now.Sub(g.lastRender) < time.Second/FPS {
		return
	}
	if now.Sub(g.lastFpsCheck) > time.Second {
		g.lastRealFps = g.realFps
		g.realFps = 0
		g.lastFpsCheck = now
	}
	g.realFps++

	g.lastRender = now

	screen.Clear()
	ebitenutil.DebugPrint(screen, fmt.Sprintf("Desired FPS: %v\nReal FPS: %v\n\nFPS: %v\nTPS: %v", FPS, g.lastRealFps, ebiten.ActualFPS(), ebiten.ActualTPS()))
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
	return outsideWidth, outsideHeight
}

Please try to run this code, on my machine when I set the FPS to 10, my ActualFPS is closer to around 80 (on previous versions of ebitengine swaps could be as high as 4k+ per second so we already have a massive upgrade)

@hajimehoshi
Copy link
Owner

hajimehoshi commented Jan 24, 2024

This method doesn' affect the actual FPS (i.e. how many times Draw is called per second), so ActualFPS would be useless, which is expected. However, the actual Draw implementation after if now.Sub(g.lastFpsCheck) > time.Second { should be processed as you expected.

@erexo
Copy link
Contributor Author

erexo commented Jan 24, 2024

and for some weird reason the "real fps" seems to be incorrect as well at higher framerate 🤔 you have any idea why?
image

edit:
it looks like when we exit Draw immediately a few (3) times in a row, then there is a sleep for 30ms, and that results in max FPS set at 33 with this method enabled, are my observations correct?

func (g *Game) Draw(screen *ebiten.Image) {
	now := time.Now()
	if now.Sub(g.lastRender) < time.Millisecond {
		fmt.Println("skip")
		return
	}

	fmt.Println("pass after", now.Sub(g.lastRender))
	g.lastRender = now

	screen.Clear()
}

the result on my machine:

3xskip
pass after 30.321ms
3x skip
pass after 31.4053ms
3x skip
pass after 32.1455ms

Try this one, 1ms of wait time will result usually result in 30ms delay between frames. If you remove the if statement then the Draw will be called a lot faster.

@hajimehoshi
Copy link
Owner

Sure, I'll take a look tomorrow.

g.lastRealFps = g.realFps

I guess this calculation can be more accurate by multiplying by time.Second / now.Sub(g.lastFpsCheck)

@hajimehoshi
Copy link
Owner

hajimehoshi commented Jan 25, 2024

I tested this program based on your program:

package main

import (
	"fmt"
	"time"

	"github.com/hajimehoshi/ebiten/v2"
	"github.com/hajimehoshi/ebiten/v2/ebitenutil"
)

const FPS = 10 // change_me

func main() {
	ebiten.SetScreenClearedEveryFrame(false)
	ebiten.SetVsyncEnabled(false)
	if err := ebiten.RunGame(&Game{}); err != nil {
		panic(err)
	}
}

type Game struct {
	lastRender   time.Time
	lastFpsCheck time.Time
	lastRealFps  float64
	realFps      int
}

func (g *Game) Update() error {
	return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
	now := time.Now()
	if now.Sub(g.lastRender) < time.Second/FPS {
		return
	}
	if delta := now.Sub(g.lastFpsCheck); delta > time.Second {
		g.lastRealFps = float64(g.realFps) * float64(time.Second) / float64(delta)
		g.realFps = 0
		g.lastFpsCheck = now
	}
	g.realFps++

	g.lastRender = now

	screen.Clear()
	ebitenutil.DebugPrint(screen, fmt.Sprintf("Desired FPS: %v\nReal FPS: %v\n\nFPS: %v\nTPS: %v", FPS, g.lastRealFps, ebiten.ActualFPS(), ebiten.ActualTPS()))
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
	return outsideWidth, outsideHeight
}

The result was about 8-9 real FPS.

Try this one, 1ms of wait time will result usually result in 30ms delay between frames. If you remove the if statement then the Draw will be called a lot faster.

I also tried this, and I could reproduce the 30ms things. My guess is that Windows sleeping timer is not so accurate as we expect unfortunately... This results seems to have a contradition with the previous experiment result (8-9 FPS), so I'll investigate more.

@erexo
Copy link
Contributor Author

erexo commented Jan 25, 2024

I can confirm that your program outputs around 8 Real FPS at FPS set to 10. But when you set the FPS to anything over 33, the Real FPS will stay at 30-33 due to previously mentioned 30ms delay between actual frames
image

@hajimehoshi
Copy link
Owner

This results seems to have a contradition with the previous experiment result (8-9 FPS), so I'll investigate more.

OK I think this is not a contradition. (Real) FPS means the average Draw count per second, while there can be a big gap between two Draw calls.

And, I found that the timer precision on Windows is pretty bad, and the smallest sleeping time was about 16[ms] unfortunately. This means that we cannot reach over 60FPS in this way. (Also, this means Ebitengine would not be able to implement good FPS adjustment). Sigh...

@hajimehoshi
Copy link
Owner

@erexo Can other game engines adjust FPS by the way?

@erexo
Copy link
Contributor Author

erexo commented Jan 27, 2024

of course, my previous game client could do that https://github.com/edubart/otclient/blob/e6861d79c90d1808bde3fd41d30b6458d1616bfe/src/framework/core/graphicalapplication.cpp#L198

I do however understand that this isn't that big of an issue and that it may be painful to implement, so please don't bother too much about it

@hajimehoshi
Copy link
Owner

Note for myself: golang/go#44343

hajimehoshi added a commit that referenced this issue Jan 28, 2024
When SetScreenClearedEveryFalse(false) and SetVsyncEnabled(false),
Draw might not be called as often as expected on Windows due to its
timer precision. This change improves the situation.

Updates #2889
@hajimehoshi
Copy link
Owner

@erexo Try the main branch (7e4cdf5). This is not a perfect solution and FPS might not reach to 60, but this should be better than before. In order to emulate higher FPS, we need shorter sleeping, which means more CPU consumption. I don't plan to backport this change as this is a little risky. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants