SDL, game loop and Swift Concurrency

Game loop boils down to such pseudocode:

let renderer = /*create renderer*/
var quit = false
while !quit {
 quit = fetchEvents()
 updateState()
 render()
 SDL_RenderPresent(renderer) // if VSYNC is ON, it will block main thread
}

Without Vsync it is a busy loop, with Vsync it is a loop which constantly blocks main thread. In any case it is not Swift concurrency friendly, since main actor executor has no chance to process any jobs except the one with the loop.
One possible solution is to wrap this loop into async function and call Task.suspend() before SDL_RenderPresent. Is it ok?

This is very reminiscent of a run loop and that’s the way that I’d approach it. Keep this as a synchronous function, have it bound to the main actor, and then block within RunLoop itself, which will take care of servicing the main queue.

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

1 Like

Actually, before migrating to strict concurrency I used RunLoop.

func run() {
  let renderer = /*create renderer*/
  var quit = false
  while !quit {
   quit = fetchEvents()
   updateState()
  
  _ = RunLoop.current.run(mode: .default, before: .now)
  
   render()
   SDL_RenderPresent(renderer) // if VSYNC is ON, it will block main thread
  }
}

Things like Timer work as expected, but tasks isolated to main actor will never be executed.

func actionHandler() {
  // won't execute
  Task { @MainActor in
       try await Task.sleep(for: .seconds(0.5))
       self.doNextAction()
   }
}

BTW, I'm talking about Windows here. Not sure how much Windows implementation of RunLoop is different from the version for Darwin.

Have you tried RunLoop.main.run(mode: .default, before: .now) instead of .current?

It works the same. The method with runloop is isolated to MainActor, so current should be equal to main.

Update: it doesn't work the same, actually. RunLoop.main breaks timers too.

Example code:

self.animationTimer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { [weak self] _ in
	print("execute when RunLool is current")
	Task { @MainActor in
		print("won't execute with any RunLoop")
		self?.unroll()
	}
}

My assumption about equality of RunLoop.current and RunLoop.main withing @MainActor isolated method is incorrect too.

// inside run method
print("Current RunLoop is main: \(RunLoop.current == RunLoop.main)")

prints Current RunLoop is main: false

Hmmm, Windows, that’s definitely not my strong suit )-:

it doesn't work the same, actually. RunLoop.main breaks timers too.

Yeah. The run loop’s ‘run’ methods only ever make sense on RunLoop.current. Trying to run another thread’s run loop is always a mistake.

I assumed that the run() function in your earlier post was being called directly from ‘main’, but now I’m not so sure. What is calling run()?

Also, this trivial program prints true on macOS:

import Foundation

func main() {
    print(RunLoop.current == RunLoop.main)
}

main()

What do you get?

Share and Enjoy

Quinn “The Eskimo!” @ DTS @ Apple

It indirectly runs from run method of AsyncParsableCommand from ArgumentParser which is bound to main actor. In this case I get false. If ran from ParsableCommand it prints true as expected. I started another thread about such behavior @MainActor behaves different on Windows and macOS It seems, on Windows DispatchQueue.main is not bound to main thread (thread which executed main function).

Try running RunLoop with a timer.

// install timer on UI-thread, win32 only.
let timerProc: TIMERPROC = {            
(_: HWND?, elapse: UINT, timerId: UINT_PTR, _: DWORD) in
    while true {
        let next = RunLoop.main.limitDate(forMode: .default)
        let s = next?.timeIntervalSinceNow ?? 1.0
        if s > 0.0 {
            break
        }
    }
}
let timer = SetTimer(nil, 0, UINT(USER_TIMER_MINIMUM), timerProc)

let renderer = /*create renderer*/
var quit = false
while !quit {
 quit = fetchEvents()  // maybe a timer proc will be invoked periodically here
 updateState()
 render()
 SDL_RenderPresent(renderer) // if VSYNC is ON, it will block main thread
}

This will make short periodic calls to RunLoop from within the GetMessage or PeekMessage of the Win32 message loop.

It seems, on Windows DispatchQueue.main is not bound to main thread (thread which executed main function)

My guess is that the way Swift detects the main thread on Windows is through the initialization of global variables during DLL initialization.
The problem with this method is that the main thread can be different if the DLL was injected by another process before all global variables or the DLL was initialized.

I think this insight is worth to add to the issue `DispatchQueue.main` is not bound to main thread on Windows · Issue #846 · apple/swift-corelibs-libdispatch · GitHub :slight_smile:

Timers don't fire if RunLoop.main used. I think it is because they are registered to RunLoop of MainActor' thread which is not main thread. What are advantages of this method comparing to direct calls to run loop inside game loop?

You have to use the Win32 timer (SetTimer), not the Swift dispatch queue timer.

The Win32 timer is called periodically when the window's message loop is running. It can also be installed before the message loop starts. It is important to manually call RunLoop from within the Win32 timer.
If you can't touch the message loop, this seems like the simplest way to go.

Dou you mean I should not use dispatch timers at all? Why bother with RunLoop then? Also I want the implementation to be portable as much is possible.

Of course, you can use the dispatch timer.
But since Win32 message loop is running on the main thread, you need to run the dispatch queue inside the Win32 message loop, and that's how you use the Win32 timer I mentioned.
Once the win32 timer is installed, the rest should work fine.

I did this. Unfortunately, code which uses timers run on main actor which is not bound to main thread as it turned out. Hence when Win32 timer callback pushes RunLoop.main no timers fired.

Also my issue is not about timers and RunLoop, but Swift Concurrency itself and how to integrate game loop into it.

I did this. Unfortunately, code which uses timers run on main actor which is not bound to main thread as it turned out. Hence when Win32 timer callback pushes RunLoop.main no timers fired.

That's too bad.
It was actually working code, but I think something might be interfering with the process. Like a DLL injection from a debugging tool.

In game development, monitoring and analyzing tools like NSight and RenderDoc are working that way, and it conflicts with the Swift runtime with a main-thread issue.

Also my issue is not about timers and RunLoop, but Swift Concurrency itself and how to integrate game loop into it.

I think it would be better to remove the dependency on other libraries and make it simple and test it. I've no experience with SDL, though, so I'm not sure what the problem is.

SDL itself is not a problem too.

Game loop is either busy loop or blocking loop (your code calls WaitMessage which suspends current thread). Neither is good for Swift Concurrency since both force Main Actor executor to focus on the job with game loop and neglect all other jobs scheduled to it. For now I decided to use Task.yeld() from game loop to give executor a chance to execute other jobs. It seems work.
Another problem I found out while digging in is what Main actor is not bound to main thread which is problematic since some win32 API and 3rd party libraries ask you to use them from main thread. SDL is such library.

It should work just fine as long as Swift Concurrency nor DispatchQueue are used :slight_smile: The moment you leave the real main thread there is no way back it seems.

Game loop is either busy loop or blocking loop (your code calls WaitMessage which suspends current thread). Neither is good for Swift Concurrency since both force Main Actor executor to focus on the job with game loop and neglect all other jobs scheduled to it.

Yes you are right.
However, thread blocking is the reason why I recommended a timer using a timer procedure. (This will not raise the WM_TIMER event).
Normally, once a Win32 window enters a modal state, it does not return to the main event loop until it is done. You can easily see this by dragging the window with the mouse. If you are drawing in the main event loop, the screen will freeze until you release the mouse drag.
And if the blocking time of the main thread becomes longer, it will cause problems with the Swift dispatch queue.

1 Like

Orthogonal, but I tried to get a patch into SDL to opt into changing this behavour, ie drive SDL rendering from an external timer source. My use-case was running a UI based on Wayland (Linux) frame timers, but this was ultimately turned down by the SDL devs. Relevant here as my Linux app is written in swift and I use a RunLoop to drive the event loop and my GCD and async code.

Thats too bad. Although when reading the PR comments, it sounds like perhaps the SDL maintainers do see the value in the future, but are unsure how best to provide this feature in the API.

In the mean time, how much do you depend on SDL? Would an alternative like Raylib be an option? IIUC it offers the direct control over the runloop you are looking for:

// Custom frame control functions
    // NOTE: Those functions are intended for advance users that want full control over the frame processing
    // By default EndDrawing() does this job: draws everything + SwapScreenBuffer() + manage frame timing + PollInputEvents()
    // To avoid that behaviour and control frame processes manually, enable in config.h: SUPPORT_CUSTOM_FRAME_CONTROL
    void SwapScreenBuffer(void);                                // Swap back buffer with front buffer (screen drawing)
    void PollInputEvents(void);                                 // Register all input events
    void WaitTime(double seconds);                              // Wait for some time (halt program execution)

Source: raylib - cheatsheet
Swift bindings: https://github.com/STREGAsGate/Raylib

Does that feature from upcoming SDL3 covers your use case?