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

New main loop structure to better allow running PyGame in browser #3187

Open
davidfstr opened this issue Oct 21, 2024 · 13 comments
Open

New main loop structure to better allow running PyGame in browser #3187

davidfstr opened this issue Oct 21, 2024 · 13 comments
Labels

Comments

@davidfstr
Copy link

Currently it is possible to run PyGame applications in a web browser using projects like Pyodide and pygbag (see these demos), however both projects require restructuring the PyGame event loop in a non-standard way:

Regular PyGame event loop:

clock = pygame.time.Clock()
fps = 60
def run_game():
    while True:
        do_something()
        draw_canvas()
        clock.tick(fps)

Asyncified PyGame event loop:

import asyncio

async def run_game():
    while True:
        do_something()
        draw_canvas()
        await asyncio.sleep(1 / fps)

This is a problem because PyGame applications cannot be run as-is in the web browser. They must be first edited to use a non-standard structure that only works in the browser.

Imagine if PyGame provided a new event loop API that worked both inside AND outside the web browser. SDL 3 has a new main callbacks API which allows an SDL-based program using it to run normally both instead and outside a browser. Perhaps PyGame could provide a new event loop API that could target this new main callbacks API.

@davidfstr
Copy link
Author

I've also started a conversation upstream in SDL about allowing SDL-based programs like PyGame to run inside a web worker, which is a different approach that would allow PyGame programs to work in the browser using the classic PyGame event loop structure.

@gresm
Copy link
Contributor

gresm commented Oct 22, 2024

Sounds like an interesting idea, but few things to note:

  • This is purely SDL3 feature.
  • How the API would look like - decorators sound sensible.
  • A simple workaround for SDL2 would be to implement a python version of this, but it might not have the benefits of working on pyoide/pygbag (depending on how it's implemented).

@davidfstr
Copy link
Author

This is purely SDL3 feature.

Aye. It looked like there was interest in eventually upgrading PyGame to use SDL3, based on a number of issues tagged "sdl3".

A simple workaround for SDL2 would be to implement a python version of this [...]

Very interesting idea. At least Pyodide packages its own version of PyGame specially, so if there was a PyGame API for supporting an interruptible main loop, Pyodide could patch in its own implementation that would work in a browser context.

How the API would look like - decorators sound sensible.

Ideally IMHO the API would support Pyodide/pygbag's need to define a main loop function that is interruptible, which could also be targeted at SDL3's "main callbacks API" in the future.

A very rough initial API sketch:

import pygame

setup_globals()

@pygame.set_main_loop
async def main_loop():
    is_running = True
    while is_running:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                is_running = False
        update_everything()
        draw_everything()
        yield

I suspect getting a more finalized API will take a couple of passes.

@gresm
Copy link
Contributor

gresm commented Oct 23, 2024

Aye. It looked like there was interest in eventually upgrading PyGame to use SDL3, based on a number of issues tagged "sdl3".

There is, but it'll take time to migrate over SDL3.

SDL3 uses SDL_AppInit, SDL_AppIterate, SDL_AppEvent and SDL_AppQuit, so if using that, I imagine that the API would have to look like this (SDL_AppInit is likely not necessary, but who knows...):

import pygame

pygame.init()

window = pygame.Window()

@pygame.step
def game_step(dt: float): # Maybe provide delta time.
    surf = window.get_surface()
    surf.fill("black")
    pygame.draw.rect(surf, "white", (50, 50, 50, 50))
    return True  # Or maybe pygame.DO_CONTINUE?

@pygame.process_event
def event(ev: pygame.Event):
    if ev.type == pygame.QUIT:
        return False  # Or maybe pygame.QUIT_APP?
    return True  # Explicit True to avoid accidental hanging apps (None is falsy value), but can be done in other ways, like not giving the choice to ignore QUIT event and immediately call quit callback (or maybe add an option to abort quitting in the quit callback)

@pygame.on_quit
def quitting():
    print("Quitting the game")

pygame.run()

Or maybe (here using SDL_AppInit):

import pygame

@pygame.setup
def setup():
    window = pygame.Window()
    return window  # State passed to callbacks, can be any python object.


@pygame.step
def game_step(state):
    surf = state.get_surface()
    surf.fill("black")
    pygame.draw.rect(surf, "white", (50, 50, 50, 50))
    return True  # Or maybe pygame.DO_CONTINUE?


@pygame.process_event
def event(state, ev: pygame.Event):
    if ev.type == pygame.QUIT:
        return False  # Or maybe pygame.QUIT_APP?
    return True  # Explicit True to avoid accidental hanging apps (None is falsy value), but can be done in other ways, like not giving the choice to ignore QUIT event and immediately call quit callback (or maybe add an option to abort quitting in the quit callback)


@pygame.on_quit
def quitting(state):
    print("Quitting the game")

pygame.init(run_callbacks=True)

Or maybe even:

import pygame

class MyApp(pygame.App):
    def __init__(self):
        super().__init__()
        self.window = pygame.Window()
        self.running = True  # Or maybe pygame.DO_CONTINUE?

    def step(self, dt: float):
        surf = self.window.get_surface()
        surf.fill("black")
        pygame.draw.rect(surf, "white", (50, 50, 50, 50))
        return self.running()

    def process_event(self, event: pygame.Event):
        if event.type == pygame.QUIT:
            self.running = False  # Or maybe pygame.QUIT_APP?

        # If pygame.QUIT events are handled by library instead:
        if something_else_that_should_cause_to_close_the_app():
            self.quit()

    def on_quit(self):
        print("Well, closing the app")
        # If pygame.QUIT events are handled by library instead:
        if i_feel_fancy_to():
            self.abort_quitting()

pygame.init()
MyApp().run()

@bilhox bilhox added event pygame.event sdl3 labels Oct 23, 2024
@JiffyRob
Copy link
Contributor

I'm against this for several reasons, outlined below. Disclaimer: I have a decent amount of experience with pygbag but have never used pyodide, and I haven't touched pygbag in several months, so some of my knowledge there might be out of date.

IMHO if you take away the main game loop from the programmer you have just promoted pygame from a framework to a game engine, which is a big no-no.

Aside from that, if we adopt a new game loop API, all existing code would be different anyway, and any people who switch to pygame ce from upstream will have different code as well. Maybe if you hone your API enough this won't be the case, but with the current example, switching from an upstream or non-web ce style game loop to this thing will have substantially more code rewriting involved than converting it to the current asyncio style.

Aside from that, assuming we do adopt an API like this, the old style loop would have to be maintained for backwards compatibility, on both the pygbag and pygame side. This seems like undue maintenance burden for a feature that might not even have widespread use.

Aside from that, how involved do you want this loop to be? Fix Your Timestep has several examples of different loop architectures, and different pygamers can implement any of these at the moment. I'm not going to say that the vast majority of pygamers either use or don't use delta time, for example, but that's something that would be a serious refactoring headache if someone is forced to switch. All of a sudden framerates aren't nearly as configurable as well. Maybe I want my game to cap at 30FPS, while somebody else wants to cap at 120FPS. Maybe both of us want our physics to chug along at 60FPS. Is this something that you are going to support? How? What if somebody comes along and wants to do something really convoluted, like physics, game logic, rendering, and audio all having their own separate framerates? One of thee great things about pygame is that you can actually do that. I am in favor of keeping it that way.

I do understand the need to support different platforms though. I don't think that it complicates things too much to make async loops a requirement of truly cross-platform pygame code. You already are supposed to os.path.join your paths (or just use pathlib), use sys to figure out where to put your config data, etc, so having to change code to support more platforms isn't new. Should 'async'ing things by default be promoted and considered best practice? Maybe. Maybe web building is an advanced feature that shouldn't be tackled by newbies.

One last argument, even assuming that this API was so simple that barely any refactoring would be needed, and everybody loved the new pygame loop so much that people just unilaterally switched to using it, there still would be things that need to be done for web builds:

  1. Not all pygame functions and modules are supported, at least by pygbag
  2. OpenGL support is limited to zengl and not the more common moderngl
  3. Opening multiple windows and more complex window handling does not and probably never will work
  4. pygbag is way slower than native pygame (5x is the number I use), so there is probably some optimization needed
  5. No WAV audio
  6. Semi-specific method of organizing your files so the right stuff gets bundled
  7. The specifier for needed 3rd party libraries at the top of the main file
  8. You actually need a specific main file
  9. Persistent data needs to be remapped to the JS storage thingy (I don't remember what it is called but I remember it was annoying to implement)
  10. No realtime music or other realtime threading
  11. Horrid FPS control, no framerate limiting on pygame.time.Clock
  12. I'm probably forgetting some stuff.

@oddbookworm
Copy link
Member

One other major concern is that we already are not completely thread-safe, and introducing thread wackery behind the scenes here is far, far in the future after we fix thread safety. I honestly cannot see us approving something like this if it dramatically changes how the end user's code functions. Backwards compatibility with old source code is one of pygame-ce's primary tenets (including compat with the original pygame). Yes, we've discussed some breaking changes when we swap to pygame-ce 3.0.0, but I think it'll still be mostly functional changes, not many huge API changes.

@oddbookworm
Copy link
Member

I'd like thoughts from the following individuals though:
@Starbuck5
@ankith26
@MyreMylar
@pmp-p

@ankith26
Copy link
Member

I don't think this is a proposal we can straight out reject, but I also agree that backwards compatibility and support for the classic way of doing things should be the priority.

I have thought about adding a tick_async method to pygame.Clock so that people can do await clock.tick_async(fps) but that should probably be discussed in a separate issue. I'm just putting it out here as it is one way to make the Clock API play well with wasm targets better. It would still require the user to make their applications async aware, but hopefully should be clearer than the await asyncio.sleep(0) thrown around to support pygbag.

@pmp-p
Copy link
Member

pmp-p commented Oct 31, 2024

@davidfstr nb: pygbag web browser async loop use await asyncio.sleep(0) not await asyncio.sleep(1 / fps) and there's a reason for that. Pygbag and Pyodide ARE different since their asyncio implementation is different.


Pygbag is frame based and does not yield for time but instead for VSYNC or custom clocks (eg slow mo / game replay ) .


Though pyodide could use same loop as pygbag loop automatically (cf zengl using RequestAnimationFrame on pyodide) that does not mean pyodide is the right runtime for running game engines.

@JiffyRob Optimization has costs. Everything else could be probably fixed at pygbag level and would probably be out of scope/maintenance burden for pygame-ce. You maybe got FPS wrong : browsers get it right for you but GPU drivers will break it nevertheless ( insert Linus-Nvidia video here with some tearing ).


Subtles differences beetween runtimes make it that loop must remain in user control ( also +1 for JiffyRob's not-a-framework )

There are other backends for pygbag / pygame-ce than web ( there's WASI, and console/mobile C89 retargeting). Async is required for using sub interpreters on web. Async is often required on consoles.

The only other sane loop model would be arduino style : setup + loop (which is confusing and should be called step) and which sounds more like SDL3 loop model. But going that way will cut everyone1 from async I/O on main thread (because doing anything I/O related on web browsers has to be over-complicated for some reason).

I would advise against using web workers : they are not meant for running games libraries which are expected to live on GUI thread and sync'ed to device VSYNC. Workers are for physics engines, DSP/SFX and heavy computations. Non specialized use of workers encourage bad pratices, the web is async. People also need to learn async and implement it better (and everywhere).

My advices would be get CPython a real "platform" module that can at least setup a framebuffer and access "eval" on javascript hosts, stop relying on OS thread for main loop by default and use asyncio console instead (it is already there since 3.8). Pygame is doing it just fine on its own.

Footnotes

  1. normal people that make games, not the ones who package typescript javascript, try to sell SaaS on a .com for a living and eat COEP/COOP/CORP/CORS/CORB/service workers/etc for breakfast.

@Starbuck5
Copy link
Member

I feel like maybe I'm not seeing something, but I don't get what the issue is?

SDL3 has this new loop API optionally. We generally try to port as many as SDL3 APIs as possible, so lets say we port it and have a new optional loop API. Cool. That doesn't threaten backwards compatibility.

This is a problem because PyGame applications cannot be run as-is in the web browser. They must be first edited to use a non-standard structure that only works in the browser.

@davidfstr I don't think this is true, as far as I know the async main loops work fine locally.

You mention students in your SDL issue, if you'd like to teach a class with this and hide that complexity away from the students I think the best way would be to make a single file helper library to import. Could have lots of other goodies as well. From my own experience in school it seems like professors often do things like that.

I would advise using web workers they are not meant for running games libraries which are expected to live on GUI thread and sync'ed to device VSYNC.

@pmp-p I assume you mean you would NOT advise, given the second half of your sentence?

In terms of asynchronous-ness in the web in general it is very baffling to me as as someone whose experience is all in synchronous programming languages. But I've heard about stack switching from Pyodide and I wonder if this technology will allow web ported programs to behave more normally with regards to sync-ness. https://blog.pyodide.org/posts/0.26-release/#improvements-to-stack-switching-support

@pmp-p
Copy link
Member

pmp-p commented Oct 31, 2024

@Starbuck5 indeed, thx edited

Pyodide really lives in the future of "maybe that shiny new wasm X.X feature will get implemented on that browser" ( intrinsics / jspi / bulk memory / EH / workers etc) . imho Pygame-ce must stand reference to keep backward compatibility for everyone, including people who don't get updates for their devices's browsers or wasi runtime and are stuck forever on wasm 1.0 (mvp). Leave it to pyodide to patch up all the new features, and of course test them.

btw: the more features used, the more bugs. Especially when Emscripten is involved ...

@davidfstr
Copy link
Author

Some of my takeaways from the discussion above:

  • The way that various platforms (SDL, Pyodide, pygbag) want a main loop to be phrased differs, and has differing levels of control/customization available for each platform, so it would be difficult to define an API that works effectively on all platforms.
  • Having two ways of defining a main loop increases maintenance cost. Perhaps also adds to confusion for new users. And creates a noticeable delta from upstream pygame (non-CE) which is undesirable.
  • For the majority of folks developing a small number of individual games, it may not be too much of a burden to put in a small amount of platform-specific code at the main loop layer to support each target platform of interest.

Reading the room, I don't see a lot of support for this feature.

@pmp-p
Copy link
Member

pmp-p commented Nov 2, 2024

* The way that various platforms (SDL, Pyodide, pygbag) want a main loop to be phrased differs, and has differing levels of control/customization available for each platform, so it would be difficult to define an API that works effectively on all platforms.

I insist, this a a python C-API problem : not pygame's problem, the game loop belongs to python not to SDL ( and iirc the sdl_main default loop was shipped separately). A good hack example of how python handles async rendering loop ( used for Tk / idle REPL) can be seen here https://github.com/python/cpython/blob/bd4be5e67de5f31e9336ba0fdcd545e88d70b954/Modules/readline.c#L1394

* Having two ways of defining a main loop increases maintenance cost. Perhaps also adds to confusion for new users. And creates a noticeable delta from upstream pygame (non-CE) which is undesirable.

multiple loop models give more power to user requiring more control and less OS specific code. Eg The pygbag default loop is valid anywhere. But it's true it could be enhanced with some c-api support python/cpython#79424, on desktop you may want to add a time.sleep or a sched_yield to avoid cpu burn.

* For the majority of folks developing a small number of individual games, it may not be too much of a burden to put in a small amount of platform-specific code at the main loop layer to support each target platform of interest.

Reading the room, I don't see a lot of support for this feature.

It is not just a pygame-ce loop matter : it is the tip of an iceberg and its base is a python/async/os & non-os/GPU/IO relationship problem. If CPython was more versatile for toplevel loop - by default - we probably could do something nicer.

I think the cost to change that is quite high for just some game loops around that indeed only need a few lines adjusted.

@pmp-p pmp-p added WebAssembly Android New API This pull request may need extra debate as it adds a new class or function to pygame async May collide with sync API and removed New API This pull request may need extra debate as it adds a new class or function to pygame labels Nov 2, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

8 participants