-
Notifications
You must be signed in to change notification settings - Fork 2
Description
I'd like to discuss a bit our high-level library design. There are basically two different ways that we could have structured our library that comes down to the main event loop that drives the user's application forward: internal or external. Right now, we are structuring things in a way where we expect the external caller/FFI implementer to drive things forward. I think this is the right architecture but it's worth articulating the design trade-offs here.
Internal event loop and the callback handler architecture
In the internal event loop model, the "Processing runtime" owns the window and drives everything forward. What this means concretely from an API design perspective is that rather than having a series of imperative FFI functions, we'd rely on the user to provide us function pointers to callbacks that we fire whenever something happens: e.g. setting up your sketch, drawing each frame, receiving user input, etc.
This is how I built things for Nannou. Basically, we store a bunch of function pointers for the user's sketch and then call their handlers each frame when asked to do so by the engine. More specifically, here we leave frame pacing to the engine and by default it's using Bevy's game oriented "run as fast as possible" pacing.
In other words, the user doesn't decide when to present to a surface, they instead exist inside an event loop that drives all their handlers when the engine decides to.
There are advantages to this design in that it means that all the integration between windowing and the user's application can be contained and doesn't need to be exposed. This is also the most performant way to write a modern application and allows decoupling concerns. For example, we can enable a low-power / reactive event loop where we only re-render on user input quite easily here because the event loop is telling us when to render, not the other way around.
From an FFI perspective, however, this has a big drawback in that it requires expressing everything in terms of function pointers. Typically when codegening bindings in other languages, this makes things a bit more annoying because we have to wrap our functions rather than just having the tooling generate a function we can call. It also means defining a ton more data structures at the FFI layer; you have to have types for all the events, for the arguments to each function handler, etc.
External event loop and the procedural style
For this reason, when scaffolding libprocessing, it made sense to go for an imperative or procedural style with an external event loop. In this case, the implementer calls into our library at each logical step of the event loop. Our functions are exposed in terms of different operations that update some kind of draw/rendering state but are themselves stateless in their sense of time; you can call "start frame" and then wait an hour and call "end frame" and from the perspective of the library that's totally fine. The caller decides when to drive things forward.
Of course, any application that needs a window is still subject to an event loop, but rather than an application handler it can be implemented more via a polling/pumping mechanism like we do in GLFW. That is, GLFW doesn't tell us when to render, we just run in a loop and check in with GLFW to see if there are any new events.
In terms of the FFI boundary, this greatly simplifies things, and means that we can expose the API in terms of procedures. For the most part, we don't even need to define any custom FFI structs, as most things can be simple primitive arguments to functions, or getters for some logical API object's properties. This, in turn, makes it very easy to codegen. The implementer will get a list of a bajillion functions that they need to integrate with, but for the most part, their implementation will just be a pretty small layer over calling these functions. As demonstrated by our examples, setting up a basic GLFW loop is just a handful of lines of code.
The engine-eventing interface
So what would the advantage of integrating with winit in Bevy be? First, it would mean that implementers would also not have to worry about managing a window and setting up a surface. Instead, they would pull events from us each frame in a similar fashion to glfwPollEvents. This would be nice because it would mean that we wouldn't rely on implementers having to figure out what windowing libraries are available in their language and could reliably just expect us to manage the window for them.
But, secondly, it would also mean that events are more easily propagated within the engine itself. Right now, if we want to react to user input in Rust, we have to create an API to feed events into libprocessing. For the core engine, this isn't necessary because outside of window resizes there's no engine code that depends on certain user actions (button presses, etc.). However, there are potentially engine features in the future that may require such events. For example, render picking which requires casting a ray from the pointers position, etc.
The current limit in Bevy
Right now, Bevy uses an internal event loop, and although winit has support for external event loops it's not easy to bridge. I feel confident that we want to continue to maintain an external event loop. The entire Processing API is designed around the assumption that you can call certain methods like beginDraw/endDraw on a graphics object, and this only makes sense in the context where we imperatively drive things forward / decide when to present. Although this is a certain kind of inefficiency with respect to super high performance graphics, for most art and art installations it's conceptually way simpler.
So, for now, I feel confident that external event loop is the right design, but we'll eventually probably want to bring windowing into libprocessing so that users don't have to deal with it!