| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [View Raw Code] [Original HTTPS Page] |
In short, a new experimental effort to abstract the Processing API into a new native, cross-platform library written in Rust and powered by the Bevy game engine. Processing is historically a few different renderers, but most recently (as in 15 years ago) an OpenGL renderer using JOGL. Because of deprecations and lack of support for modern graphics development techniques, it's become desirable for us to prototype a new renderer based around WebGPU as a new cross-platform backend for future Processing efforts, both in Java and other languages.
Bevy is an open-source game engine written in the Rust programming language. Bevy and Rust more generally have a number of characteristics that are desirable for a project like Processing:
There are several layers required to make this work. Starting from the outermost layer of the onion:
We want to expose the Processing API in a kind of procedural style (i.e. imperative, function driven) that mirrors the immediate-mode idiom of Processing sketches.
We want to closely model the existing Processing API, but strategically fix issues that may be a consequence of legacy design. We also want to expose features that Bevy provides that are trivial to do so. For example, Processing does not support different texture formats, but this is super easy in Bevy, so we should make that possible in the API even if Java Processing can't fully exercise it.
We also want to take learnings from p5 where it makes sense! There are some cases where they may have settled on a better naming convention or api style for something and we should feel free to take their learnings, documenting differences where we do.
While this is a lower-level library that has fewer guardrails than the high-level Processing API, we still want it to be something that feels accessible to users who are interested in getting more involved with development of the library. In that respect, we should always prioritize internal documentation, examples, and bread crumbs to facilitate learning. Graphics programming is hard, and in some cases we may need to express patterns that are complicated, but should always make sure that we describe the "why" even where the "how" may elude less experienced contributors.
All exposed C functions should begin with the processing_ namespace and should be further qualified by the type of data they operate on, e.g. processing_graphics, processing_geometry, etc. Because this is a lower-level library, long names are fine and it's better to be descriptive rather than terse. As such, we prefer multiple functions that accomplish similar tasks rather than overloading a single function with complicated parameters, for example we have both processing_graphics_background_color and processing_grpahics_background_image although these are presented in the user-facing API via an overload.
We never return pointers to data that lives in libprocessing. Because we use the ECS to manage Rust data, where longer lifetimes are necessary we return an Entity id, which can be returned to the user as a u64 containing both the index and generation of the ECS entity. Any data not representing an API-level object should be returned on the stack and where an allocation is necessary (e.g. buffers for pixel data), it's the responsibility of the consumer to allocate and provide the pointer to the requisite allocation.
API-level objects should wrap ids and they should never be exposed to the user as a first-class concept. All data which is returned via an Entity id should have a corresponding destructor function which removes the entity from the ECS and frees any associated resources. It's the responsibility of the API wrapper object to call this function when being destroyed.
The central architectural challenge of libprocessing is reconciling two fundamentally different models of how graphics programming works.
Immediate mode treats drawing as a sequence of imperative commands: when you call rect(10, 10, 50, 50) a rectangle appears now. State is global and mutable where the user thinks in terms of a linear script that paints pixels onto a canvas. This is the traditional "sketch" model of Processing.
Retained mode in the case of Bevy's ECS treats the scene as a database of entities with components. Systems query and transform this data, often in parallel. Rendering is a separate phase that happens later, potentially pipelined across frames. The renderer batches draw calls for efficiency and has a number of optimizations that could be considered a form of eventual consistency (think of a game where objects take flicker in and out on screen as assets load). The user thinks in terms of a scene graph that is updated over time, where multiple asynchronous systems are modifying data.
Neither model is wrong! But they very much optimize for different things. Immediate mode is intuitive and exploratory which is why it's so well suited to learning, prototyping, and creative coding. Retained mode is efficient and scalable, perfect for games with thousands or hundreds of thousands of objects or for more complex artworks that require sophisticated rendering techniques.
Our job is to present the former while implementing it atop the latter.
This requires us to invert several of Bevy's defaults:
We work around this in the following manner:
In this way, as long as Camera state is managed correctly, it's totally fine to call app.update() or run individual systems as they will not trigger unnecessary renders and presents to the surface.
Working with raw &mut World in Bevy can lead to frustrating situations with respect to borrows, as many methods require mutable world access which then prevents doing other operations. Because of our immediate mode style, we much more frequently are imperatively modifying the world rather than adding "normal" Bevy systems that run in a schedule.
There are several strategies that can help work around this:
// In a object module define plain systems with `In` params:
pub fn create(
In((width, height, surface_entity)): In<(u32, u32, Entity)>,
mut commands: Commands,
render_device: Res<RenderDevice>,
) -> Result<Entity> {
// implementation that uses Commands, queries, resources, etc.
Ok(entity)
}
// In lib, call the system via run_system_cached_with:
pub fn graphics_create(surface_entity: Entity, width: u32, height: u32) -> error::Result<Entity> {
app_mut(|app| {
app.world_mut()
.run_system_cached_with(graphics::create, (width, height, surface_entity))
.unwrap()
})
}The In<T> parameter receives input data passed via run_system_cached_with(). For multiple parameters, use tuples: In<(T, U, V)>. The In parameter must always be the first system parameter.
As of now, libprocessing is intended to be used in a single-threaded manner on the main thread of the application (which is a specific requirement for macOS window rendering and could be loosened). This is primarily for implementation simplicity; Bevy itself is designed to be highly parallel and does not have this restriction. In the future, we may decide to fully embrace multi-threading if the implementation complexity is worth the performance benefits.
What this means concretely is:
Due to working at the FFI boundary, libprocessing requires interaction with unsafe code. We strive to be memory and panic-safe in all instances, which means that a consumer of libprocessing should not be able to trigger undefined behavior or crash their process simply by calling into libprocessing. However, libProcessing is not intended as a high level API and incorrect use may result in unexpected results, memory leaks, or other undesirable behavior.
In general, our assumption is that errors encountered by consumers indicate exceptional situations in which the correct behavior is to halt program execution. In other words, errors are not intended for user feedback and where necessary the caller is expected to validate input prior to calling into libprocessing. More specifically, we want to expose validation as data, so have functions like validate_shader which returns friendly, user oriented text, rather than something like compile_shader which provides a validation error. The latter should also still validate but without the assumption of surfacing feedback to the user.
We have a ProcessingError enum that uses the thiserror library to help manage error variants (see the documentation for more info). In general, we encourage adding new error variants that are unique to a given situation. As error states are considered exceptional, it's okay if these messages are better oriented towards a bug report that a user may file than informative to the user. Our expectation is that users should never see them in ordinary practice.
We also expose a Result<T, ProcessingError> type alias that should be used in most cases as the return type for top-level functions.
In our rendering code, unwrap and expect should only be used in situations that indicate the renderer itself has a bug, but is totally acceptable where invariants should be held. See this blog post by Burntsushi for some general guidelines here.
Currently, we store the errors state in a thread-local CString, where any non-null value indicates the presence of an error. Consumers are required to call processing_check_error after every operation and throw an exception if any value is present.
Inside the FFI library, all expose functions must:
The convenience function check is provided for working with ProcessingError and should be used in most cases to help with error handling.
| Back | FazBrowse Home | New Git URL |