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

Mainloop control inversion #6785

Closed
icculus opened this issue Dec 8, 2022 · 21 comments · Fixed by #8247
Closed

Mainloop control inversion #6785

icculus opened this issue Dec 8, 2022 · 21 comments · Fixed by #8247

Comments

@icculus
Copy link
Collaborator

icculus commented Dec 8, 2022

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.

@slime73
Copy link
Contributor

slime73 commented Dec 8, 2022

The notable one in SDL2 is Emscripten, where you have literally no choice

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?

@icculus
Copy link
Collaborator Author

icculus commented Dec 8, 2022

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 #ifdefs around the startup code to either set up the callback and get out, or go into a main loop that breaks when it's time to terminate the application. In "well-designed" apps, it ends up looking like:

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.

@icculus
Copy link
Collaborator Author

icculus commented Dec 8, 2022

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 #ifdefs in place to use SetRenderCallback on those platforms, like you would have to do anyhow.

I dunno, I'm just thinking out loud here.

@slembcke
Copy link

slembcke commented Dec 8, 2022

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.

@1bsyl
Copy link
Contributor

1bsyl commented Dec 8, 2022

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.

@icculus
Copy link
Collaborator Author

icculus commented Dec 8, 2022

(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.)

@slouken
Copy link
Collaborator

slouken commented Dec 8, 2022

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 SDL_iOSSetAnimationCallback()

@slouken
Copy link
Collaborator

slouken commented Dec 8, 2022

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.

@slouken
Copy link
Collaborator

slouken commented Dec 8, 2022

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?

@slembcke
Copy link

slembcke commented Dec 11, 2022

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.

@icculus
Copy link
Collaborator Author

icculus commented Dec 11, 2022

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.

@slembcke
Copy link

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.

@ericoporto
Copy link
Contributor

ericoporto commented Dec 11, 2022

I believe asyncify ends up running one's code as an interpreted bytecode

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.

@slembcke
Copy link

slembcke commented Dec 12, 2022

I might be beating a dead horse here, but I thought I would whip up an example:
https://gist.github.com/slembcke/f66773c99c33bced69823a6073d8fcce

This implements an SDL-like main loop API on top of Sokol App, a callback style API.
Benefits:

  • Everything runs on the main thread (no context switches or thread synchronization)
  • Transparent to the user (...mostly)
  • Support for ARM and x64 platforms is straightforward
  • Easy to implement as optional at runtime if(fiber != NULL) resume(fiber); else do_normal_thing; etc

Downsides:

  • May never work well on the web because of how javascript and WASM work

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.

@Akaricchi
Copy link
Contributor

Akaricchi commented Dec 13, 2022

May never work well on the web because of how javascript and WASM work

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. ASYNCIFY_IGNORE_INDIRECT=1 is an easy and huge win if you know that your code will not suspend across indirect function calls, and if you only have a few well known suspension points, you can whitelist their call chains in ASYNCIFY_ONLY and eliminate almost all of size overhead.

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.

@slembcke
Copy link

slembcke commented Dec 13, 2022

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

@smcv
Copy link
Contributor

smcv commented Dec 13, 2022

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

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 poll() that will wake up as soon as something interesting happens) is good for power consumption.

BUT...even well defined apps can have That One Function Somewhere That Blocks

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.

@flowCRANE
Copy link

flowCRANE commented Dec 14, 2022

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.

@nfries88
Copy link

nfries88 commented Jan 14, 2023

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

  • can make framerate limiting an internal feature, and probably do a better job of it than the average attempt too
  • easier to implement async disk I/O when OS threads aren't available - just do smaller reads and writes inbetween callbacks. Not that it's common besides emscripten
  • good opportunity to add more asynchronous APIs, especially a main thread task queue (pretty sure emscripten, Cocoa, UIKit, Android, and Win32 all provide their own ways to do this already). This would actually make this inverted control more favorable to me personally, since nearly all of my non-trivial main loop logic is async stuff, I don't know about anyone else though.
  • done right, aligns SDL more closely with the Win32 model, fixing the eternal modal loop hiccups there.

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.

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.

@icculus
Copy link
Collaborator Author

icculus commented Sep 11, 2023

I wonder if this is an opportunity to beef up the SDL_main functionality.

I'm going to take a run at this idea and see what happens.

@madebr
Copy link
Contributor

madebr commented Sep 11, 2023

Related to this, I recently red this write-up about re-implementing wipeout.
I think the following quote is related to this feature:

Currently it compiles with two different platform backends: SDL2 and Sokol. Both of these support multiple platforms (e.g Windows, macOS, Linux, Android, iOS…). Adding a new platform backend – say, for the Nintendo Switch – is straight forward and (in theory) doesn't necessitate any changes to the game code.

I initially developed with the SDL backend and later added the Sokol libraries. Both are an absolute pleasure to work with.

I was especially impressed with how smooth the compilation for WASM with Sokol went. You compile the whole thing with emcc and it just works. Rendering, input, sound, everything was there. Not a single change in the code needed.

Looks like sokol is callback based.

icculus added a commit to icculus/SDL that referenced this issue Nov 1, 2023
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.
icculus added a commit that referenced this issue Nov 1, 2023
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.