-
Notifications
You must be signed in to change notification settings - Fork 2k
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
Mainloop control inversion #6785
Comments
My stuff that uses SDL already supports this model for that reason - it actually wasn't that complicated at all to change my code to make it work, but I don't know how typical that is. On a practical level what sort of pros/cons does that approach have (aside from power usage as was already mentioned)? I suppose some control over framerate is given up? |
Absolutely, but I'm pretty sure all the ones doing this at the system level are doing it so you can only render at the monitor refresh rate for battery life, CPU/GPU workload, and cooperating with the compositor. (I wouldn't be surprised if RetroArch clamps to vsync too, but it only has application-level control over this.) More aggressive options would include what macOS does with App Nap...if you aren't visible, they have the option of not calling the callback at all. The biggest con is that it requires apps to make at least mild modifications, although in most cases it comes down to a few int main() {
// other setup stuff here
#if USE_CALLBACK_RENDERING
register_rendering_callback(render_one_frame);
#else
while (!time_to_stop) {
render_one_frame();
}
#endif ...and that's it. BUT...even well defined apps can have That One Function Somewhere That Blocks: // this gets called 100 stack frames down, passing through a scripting language on the way, etc.
void confirm_question() {
int answer = -1;
while (answer == -1) {
answer = render_are_you_sure_message();
}
do_something_with_answer(answer);
} ...and if you have a bunch of these sprinkled around your codebase, you can be in for a world of hurt trying to convert your program. Which is why, even if I wasn't a control freak about things, would never recommend this being the primary way that one writes SDL programs. I'm just trying to decide if there's a nice way to make either approach work well. |
Maybe it's as simple as "OPTIONALLY, you can call SDL_SetRenderCallback, but the usual ways usually work too" ... on platforms that prefer this, we take advantage of the system-level APIs. On platforms that don't offer this, it's trivial to implement it inside SDL itself. On platforms that require it, you either need to use it for your app, or have those I dunno, I'm just thinking out loud here. |
The only way I know of to "magically" turn a regular loop into a callback is to use some sort of threading. (generators, fibers, or OS threads) That's obviously fraught with peril in this case. Fibers aren't portable, and threads are generally off the table too for many reasons. I don't think there is any other way to escape turning the loop into a state machine without making the user do it in their own code. |
Android is running the usual SDL way (no callback). because in fact we create a SDL thread beside the android activity. But it could also be implemented with a callback ( https://developer.android.com/reference/android/view/Choreographer.FrameCallback ). not planning to use it though ... The great force of SDL is to write once your app, and it can run without any change to most platform... so it if you need to two different way, it starts to be less convenient . ( I don't mind one or two #ifdef) but if you have nested windows / dialog box with it's own event polling , it's more difficult. |
(100%, if we do something here at all, it won't be to change this to require a callback. That's a total non-starter.) |
On iOS, you actually need to run your code in the display callback to correctly interoperate with other UI components. That's why we have |
Unfortunately I don't think there's a magical solution here. If we required a callback we can make all platforms behave identically. If we start the application code on its own thread, we can make all platforms behave identically (but introduces multi-threading issues and potential latency). But if we try to keep everything on the main thread and have the application drive the frame loop then we're stuck with what we have now. |
I wonder if this is an opportunity to beef up the SDL_main functionality. Maybe we provide a header-only library that allows you a few ways to start your main function or set dispatch callbacks, and existing applications can call into SDL functions the way they always have? |
Dumb thought: So fibers might not be portable enough to be used everywhere, but they are relatively easy on ARM or x64 platforms and could be an option provided by SDL_main. They are complicated on Emscripten/WASM though. Asyncify exists, but I don't know much about it. Sounds like it has a lot of compile time overhead. |
I believe asyncify ends up running one's code as an interpreted bytecode (in addition to WASM itself being a bytecode format as well), so it can start and stop at will to not starve the main thread...it's a non-starter to require it. |
Oh? I just remember it warning that it does some sort of whole program analysis and generates a ton of unused code if you disable certain optimizations. Anyway, I guess my point was it might not fix the problem for web platforms, but it could still be a nice optional fix for all the others. |
There was an older version around 2016 that did so, but this is not true anymore. I believe it was around 2019 it changed. It will at most slow down the code by 50% - at least in benchmarks. So both are saying things that were/are correct, but slembcke has the more updated info. There's a blogpost in the docs that has more details. |
I might be beating a dead horse here, but I thought I would whip up an example: This implements an SDL-like main loop API on top of Sokol App, a callback style API.
Downsides:
I'm not familiar with the details, but this should make fixing the "window interactions are blocking on Windows" issue too. If you run the blocking call on a separate fiber, you can yield back to the main loop during the repaint callbacks. |
Emscripten supports fibers via Asyncify. It's quite inefficient compared to a native assembly implementation (performance is comparable to ucontext on Linux and the code size bloats up), but it seems to be doing well enough for the master branch of Taisei, which uses hundreds of coroutines to drive game logic. You certainly would not feel the overhead if you have only one such fiber in your program, at least in terms of runtime performance. Code size is another matter, but it's possible to cut down on it a lot if you know which code paths should and shouldn't be instrumented. Then there is also the wasm stack switching proposal that is supposed to obsolete Asyncify… sometime in the future. My yet-another stackful coroutine C library has an emscripten fiber backend, for reference. |
Exactly. Asyncify comes with a bunch of gotchas like that. To the application developer, restricting yields across indirect calls could be something they design around. On the other hand, it would be really awkard for SDL to say you can't wait for events, swap buffers, or something like that inside an indirect call though. Especially if virtual calls count as indirect. (I assume?) If WASM has the biggest problem with control inversion, and also is the most problematic to apply co-routines to... Eh, I'm not going to be surprised when somebody says no, even if I like solving problems with coroutines. :p |
This would also be a natural model if people want to plug SDL into a GTK or Qt app: for instance SDL rendering game graphics inside the main content rectangle, but with GTK or Qt chrome (menubar, etc.) around it. The GLib event model used in GTK can be used either way round (integrated into a third-party main loop, or used as the main loop that third-party code integrates into) but the usual way is for it to be in overall control of everything that happens on the main thread, with application code registering callbacks for "do this in about x milliseconds' time", "do this once per frame", "do this whenever you are otherwise idle" or "wake me up when this socket becomes readable". It's been a while since I did Qt, but if I remember correctly it's conceptually quite similar. I suspect mobile OSs' GUI toolkits also behave like this, because a steady state where literally nothing is happening (with the application blocked in a
In the GLib and Qt worlds, these tend to be somewhere between undesired and forbidden, with callback-based async I/O strongly encouraged for things like networking and D-Bus (as well as the windowing/GUI system). This also makes it much less likely to get into a deadlock where the GUI is no longer updating or responsive. However, I know from experience that nice async APIs for things like D-Bus are hard to do unless you have a more general async framework like GLib's GAsyncResult/GCancellable that an application developer only has to learn once. |
On which platforms will the new callbacks be required? Also (hypothetically), will it be possible in the SDL3 to create custom main loops, without using callbacks? I hope that the API will remain compatible with what we had before, i.e. that it will be possible to implement the main loop yourself, at least on desktop platforms. |
I am not a fan of the model, but agree that fighting the inertia here is probably not worthwhile. There's some advantages to be had from SDL taking over the main loop even on platforms that don't require it
Having it both ways seems perfectly feasible here, except some targets (like single-threaded emscripten) may not be worth trying to support with the classic model. |
I'm going to take a run at this idea and see what happens. |
Related to this, I recently red this write-up about re-implementing wipeout.
Looks like sokol is callback based. |
In theory, on most platforms these can be implemented in the app itself, but this saves some `#ifdef`s in the app and lets everyone struggle less against some platforms, and might be more efficient in the long run, too. On some platforms, it's possible this is the only reasonable way to go, but we haven't actually hit one that 100% requires it yet (but we will, if we want to write a RetroArch backend, for example). Using the callback entry points works on every platform, because on platforms that don't require them, we can fake them with a simple loop in an internal implementation of the usual SDL_main. But the primary way we expect people to write SDL apps is with SDL_main, and this is not intended to replace it. If the app chooses to use this, it just removes some platform-specific details they might have to otherwise manage, and maybe removes a barrier to entry on some future platform. Fixes libsdl-org#6785.
This lets apps optionally have a handful of callbacks for their entry points instead of a single main function. If used, the actual main/SDL_main/whatever entry point will be implemented in the single-header library SDL_main.h and the app will implement four separate functions: First: int SDL_AppInit(int argc, char **argv); This will be called once before anything else. argc/argv work like they always do. If this returns 0, the app runs. If it returns < 0, the app calls SDL_AppQuit and terminates with an exit code that reports an error to the platform. If it returns > 0, the app calls SDL_AppQuit and terminates with an exit code that reports success to the platform. This function should not go into an infinite mainloop; it should do any one-time startup it requires and then return. Then: int SDL_AppIterate(void); This is called over and over, possibly at the refresh rate of the display or some other metric that the platform dictates. This is where the heart of your app runs. It should return as quickly as reasonably possible, but it's not a "run one memcpy and that's all the time you have" sort of thing. The app should do any game updates, and render a frame of video. If it returns < 0, SDL will call SDL_AppQuit and terminate the process with an exit code that reports an error to the platform. If it returns > 0, the app calls SDL_AppQuit and terminates with an exit code that reports success to the platform. If it returns 0, then SDL_AppIterate will be called again at some regular frequency. The platform may choose to run this more or less (perhaps less in the background, etc), or it might just call this function in a loop as fast as possible. You do not check the event queue in this function (SDL_AppEvent exists for that). Next: int SDL_AppEvent(const SDL_Event *event); This will be called once for each event pushed into the SDL queue. This may be called from any thread, and possibly in parallel to SDL_AppIterate. The fields in event do not need to be free'd (as you would normally need to do for SDL_EVENT_DROP_FILE, etc), and your app should not call SDL_PollEvent, SDL_PumpEvent, etc, as SDL will manage this for you. Return values are the same as from SDL_AppIterate(), so you can terminate in response to SDL_EVENT_QUIT, etc. Finally: void SDL_AppQuit(void); This is called once before terminating the app--assuming the app isn't being forcibly killed or crashed--as a last chance to clean up. After this returns, SDL will call SDL_Quit so the app doesn't have to (but it's safe for the app to call it, too). Process termination proceeds as if the app returned normally from main(), so atexit handles will run, if your platform supports that. The app does not implement SDL_main if using this. To turn this on, define SDL_MAIN_USE_CALLBACKS before including SDL_main.h. Defines like SDL_MAIN_HANDLED and SDL_MAIN_NOIMPL are also respected for callbacks, if the app wants to do some sort of magic main implementation thing. In theory, on most platforms these can be implemented in the app itself, but this saves some #ifdefs in the app and lets everyone struggle less against some platforms, and might be more efficient in the long run, too. On some platforms, it's possible this is the only reasonable way to go, but we haven't actually hit one that 100% requires it yet (but we will, if we want to write a RetroArch backend, for example). Using the callback entry points works on every platform, because on platforms that don't require them, we can fake them with a simple loop in an internal implementation of the usual SDL_main. The primary way we expect people to write SDL apps is with SDL_main, and this is not intended to replace it. If the app chooses to use this, it just removes some platform-specific details they might have to otherwise manage, and maybe removes a barrier to entry on some future platform. Fixes #6785. Reference PR #8247.
I want to start this by saying I don't like this direction that various platforms are taking, but they are taking it regardless of my feelings, so we should talk about this for SDL3.
We have seen several platforms that would prefer a control inversion in the app model: instead of the app having a main loop that collects input, thinks, then renders, they want the apps to provide a callback that fires once per frame, and the platform handles the main loop.
The notable one in SDL2 is Emscripten, where you have literally no choice: your SDL-based app can share most of its code, but you have to have some ifdefs that provide a callback function and return from main() to start your actual app lifecycle. If you don't, you can make OpenGL calls and call SDL_GL_SwapBuffers, but nothing renders (and other problems) unless you use the callback.
Another up and coming platform is RetroArch, where it would be amazing if any SDL-based app could become a "libretro core" and treat the libretro API as the platform layer...but it also demands this callback interface to function.
Other platforms don't require this but prefer it (iOS or Android? Wayland? I can't remember) since it can reduce battery usage.
It might even solve #1059 if SDL worked this way.
I would not want to force this on SDL users, but it might be nice to find a way for apps to be able to adapt cleanly to this way of thinking and/or have the option to work this way.
I do not have a solution--there might not be an acceptable solution--but in the initial SDL3 window, this is the time to talk about this sort of thing.
The text was updated successfully, but these errors were encountered: