We’re working hard on the Orca runtime, and while this is keeping us busy with code, I figured it shouldn’t make us silent either! So here’s a small update followed by a not-so-small overview of the runtime’s architecture.
Ben Visness has been working on improving the build process, as well as writing example apps, all in order to make apps developers’ first steps as easy as possible. We’ve been joined by Ilia Demianenko, who is fleshing out Orca’s UI system, and Reuben Dunnington, who is working on interfacing his own WebAssembly interpreter with Orca, so that we can work together on a better WebAssembly debugging experience in the near future. As for me, I’ve been improving our vector graphics renderer performance and memory usage, as well as playing whack-a-mole with broken bits and platform-specific quirks that popped up now that my code is exposed to three other people!
Overall, I think we’re making good progress and we hope to have an early MVP and open the codebase sometime in the beginning of fall. Meanwhile, I thought it would be interesting to give you an overview of Orca’s architecture. But before that, here’s a small breakout game running in Orca on both windows and macOS:
When building an app with Orca, we want you to be able to use your existing toolchain and language of choice, as long as it can call into the Orca API and target WebAssembly. For now we’re focusing on a C interface, but will provide bindings for other languages later. Once you have built your app’s wasm module, you’ll run Orca’s bundling script, which will bundle your module with the Orca runtime and all of your app’s resources.
Some features of Orca are directly compiled to WebAssembly, while
others are native functions provided by the runtime. Those are tagged as
ORCA_IMPORT
. These need not be defined when the app’s wasm
module is built, and end up in the module’s import section.
Your module also exposes a number of functions to the runtime, by
tagging them as ORCA_EXPORT
, which makes sure they end up
referenced in the wasm module’s export section. These are mostly event
handlers that get called by the runtime when some event needs to be
processed by your app.
Now here’s an outline of what happens when you launch your app:
ORCA_IMPORT
APIs that your
module references to their native implementation.ORCA_EXPORT
functions exposed by your app.OnInit()
handler,
if you defined one.OnKeyDown()
handler is called and passes the appropriate
key code. OnFrameResize()
is called whenever the app’s
window is resized, and so on.You don’t need to define all handlers or do anything special to
register events you’re interested in. You just define the handlers for
the events you want to process, making sure they’re visible to the
runtime by marking them with ORCA_EXPORT
, and the runtime
will pick them up and call them when it receives the corresponding
events.
Your application can request one or more graphics surfaces to draw inside its window. You can currently request a canvas surface, which allows you to draw 2D vector graphics, and a GLES surface to which you can render 3D graphics using OpenGL ES 3.1.
You get a surface handle by calling a surface-specific creation function, e.g.:
g_surface surface = g_surface_create_gles();
Using the graphics surface then amounts to preparing the surface, issuing draw commands using the appropriate API, and then presenting the surface to display it to the app’s window:
g_surface_prepare(surface);
glClearColor(1, 0, 1, 1);
glClear(GL_COLOR_BUFFER_BIT);
//...
g_surface_present(surface);
Graphics surfaces are overlaid on top of each other, so that you can compose different kinds of graphics layers: for example you could draw a 3D scene in a surface, and overlay a 2D UI on top of it. The runtime also has its own debug overlay that can be displayed on top of the app’s graphics.
The canvas API makes use of a canvas object, which provides context for the drawing commands (e.g. it holds the current color, the current stroke width and joints types, the current source image, etc.), and stores them in a command buffer. You’ll typically create a canvas object and set it as current at initialization time like so:
g_canvas canvas = g_canvas_create();
g_canvas_set_current(canvas);
Once a canvas has been set current, it is implicitly used by all graphics functions in the same thread. You can then build paths using lines, Bézier curves, and basic shapes, tweak drawing attributes like color and stroke width, and issue stroke and fill commands. For instance, you could draw a smiling face like so:
// background
g_set_color_rgba(0, 1, 1, 1);
g_clear();
g_set_color_rgba(1, 0, 1, 1);
g_rectangle_fill(0, 0, 100, 100);
// head
g_set_color_rgba(1, 1, 0, 1);
g_circle_fill(x, y, 200);
// smile
g_set_color_rgba(0, 0, 0, 1);
g_set_width(20);
g_move_to(x-100, y+100);
g_cubic_to(x-50, y+150, x+50, y+150, x+100, y+100);
g_stroke();
// eyes
g_ellipse_fill(x-70, y-50, 30, 50);
g_ellipse_fill(x+70, y-50, 30, 50);
Once you’ve filled the canvas’s command buffer, you can render them onto the surface:
g_surface_prepare(surface);
g_render(surface, canvas);
g_surface_present(surface);
This will result in this image:
The canvas surface is implemented by a Metal backend on macOS and an OpenGL backend on Windows. Vector graphics has been a recurring rabbithole of mine, and I’ve written a few posts about it already (here, here, here and there), but Orca’s renderer one is using yet another method which is much faster and robust. This will probably be the subject of a future writeup!
For 3D graphics, Orca apps need an API and shading language that is implemented on all supported platforms (otherwise it would defeat the portability goal). This doesn’t leave a great deal of choice. Desktop GL is not officially supported anymore on Apple platforms, and won’t be available if we want to port Orca on mobile. Vulkan could be an option, but apart from its verbosity it seems a bit difficult to use from inside a sandboxed environment like WebAssembly. GLES can be run on desktop platforms using ANGLE, although support on macOS is in practice limited to 3.1. WebGPU can be run on desktop platforms using Dawn, but it is still a moving target and there’s not a lot of documentation or good tooling available yet.
For the Jam MVP, we settled on exposing GLES 3.1 to Orca apps, but we plan on supporting WebGPU later on.
Orca will come with a default UI system, which will be the basis for indexability, accessibility and inter-app transclusion features.
The UI system provides an immediate mode API that allows building a tree of UI boxes. Each box can have a number of features enabled or disabled by the flags used when creating them. For instance, you can enable some default rendering behaviour like drawing the box’s border, background, or text. You can also make the box clickable or scrollable, or make it block mouse clicks for boxes behind it, etc.
UI widgets are built by composing such boxes to form a subtree of the overall UI tree. For instance, a button is a single box that is clickable, and draws its background, border, and label. A slider is a box representing the track, which contains a clickable box for the thumb, along with two invisible “spacer” boxes on each side of the thumb. A scrollable panel is a frame box, which contains a bigger contents box, as well as two slider widgets for the scroll bars. The UI system comes with a number of such widget helpers, that are just functions building a UI subtree by combining boxes or other widgets.
All UI contextual state is kept in a ui_context
structure. Much like the canvas object, you’ll start by initializing the
UI context, and then making it the current context. It is then
implicitly used by other UI functions.
ui_context context = {0};
ORCA_EXPORT void OnInit()
{
ui_init(&context);
ui_set_context(&context);
//...
}
Each frame of the UI is computed through the following phases:
To feed input events into the UI, you’ll use an event handler called
OnRawEvent()
. This gives you an event struct, that you can
pass directly to the ui_process_event()
function. This
avoids having to pass events to the UI in each and every possible event
handler.
ORCA_EXPORT void OnRawEvent(mp_event* event)
{
ui_process_event(event);
}
In the OnFrameRefresh()
handler (or any other function
that gets called when you need to update the UI), you use the
ui_frame_begin()
and ui_frame_end()
functions
to declare and build the next UI frame. The convenience
ui_frame()
macro can be used to create a block which will
implicitly surround the contained code with
ui_frame_begin()
and ui_frame_end()
. You pass
it the frame size, and a default style and style mask (more on this
later):
ui_frame(frameSize, &defaultStyle, defaultMask)
{
//UI building code goes there
}
You can then use the UI builder functions or widget helper functions to create boxes and compose the UI tree. For instance, the following code creates a background panel on top of which are drawn two buttons.
ui_container("background", UI_FLAG_DRAW_BACKGROUND)
{
if(ui_button("Button A").clicked)
{
log_info("Button A was clicked!");
}
if(ui_button("Button B").clicked)
{
log_info("Button B was clicked!");
}
}
Boxes that can be interacted with can be queried for a signal struct
describing if they were hovered, clicked, dragged, etc. Here the
ui_button()
helper returns that struct so that you can
check if the button was clicked directly in the UI building code.
The layout and visual appearance of boxes is controlled by style attributes. Contrary to most immediate mode UIs, Orca doesn’t use style stacks to determine the style attributes of boxes at build time. Rather, it uses a rule-based system that allows you to select sets of boxes that match a certain pattern, and apply a set of style attributes to those boxes. This allows overriding the style of boxes deep inside the subtree of a widget helper, without having to control or modify the code for that helper.
You define style rules to apply to subtrees while building your UI, and at the end of the frame the system does a styling pass to compute the actual style attributes to apply to each box. A more detailed explanation of the styling system and the specification of style rules is given in this post.
Once styling is done, the system computes the layout of the boxes according to their size, layout and floating style attributes. These attributes represent constraints that are then used to compute actual box rectangles using several layout passes. The semantics of those attributes and the constraint resolution algorithm are also described in this post.
Once your UI frame is built, styled, and laid out, you can call
ui_draw()
to draw the UI. This will actually call the
canvas API to issue commands to the current canvas context. You can then
commit the context’s command buffer to a canvas surface to render the
UI.
// build UI here...
// draw the UI into a canvas context
g_canvas_set_current(canvas);
ui_draw();
// render the canvas context on a canvas surface
g_surface_prepare(surface);
g_render(surface, canvas);
g_surface_present(surface);
Orca apps can access files on the host system through a
capability-based API. To read or write to a file, you must obtain a file
handle by opening the file using the file_open_at()
or
file_open()
functions:
file_handle file_open_at(file_handle dir,
str8 path,
file_access_rights rights,
file_open_flags flags);
file_handle file_open(str8 path,
file_access_rights rights,
file_open_flags flags);
The file_open_at()
function takes a dir
file handle as the first argument. This handle represents the
capability to read or write files inside the subtree of the
associated directory. On startup, an Orca app is only granted a
capability to read, write and create files inside a private
data
folder located inside the app bundle. The
file_open()
function implicitly uses this capability.
For now, other capabilities can only be obtained by opening or
creating a folder inside an already existing capability with equal or
greater rights, so this means Orca apps are pretty much limited to using
files inside their private data
folder. In the very near
future we’ll add two ways of requesting capabilities outside that
folder:
File handles can then be used to read and write bytes to files in a
pretty familiar way using such APIs as file_read()
,
file_write()
, file_seek()
, etc.
The APIs presented above are actually just wrappers around a lower-level API based on packets. These packets are structures that encode IO requests like opening a file, reading or writing bytes, etc., and completions returning the result of such operations:
typedef u32 io_op;
enum _io_op
{
IO_OP_OPEN_AT = 0,
IO_OP_CLOSE,
IO_OP_FSTAT,
IO_OP_SEEK,
IO_OP_READ,
IO_OP_WRITE,
IO_OP_ERROR,
//...
};
typedef struct io_req
{
io_req_id id;
io_op op;
file_handle handle;
i64 offset;
u64 size;
char* buffer;
union
{
struct
{
file_access_rights rights;
file_open_flags flags;
} open;
file_whence whence;
};
} io_req;
typedef struct io_cmp
{
io_req_id id;
io_error error;
union
{
i64 result;
u64 size;
i64 offset;
file_handle handle;
//...
};
} io_cmp;
Usage consists of setting up a request packet in wasm memory, pointing it to the appropriate input or output buffers, requesting the runtime to process this structure, and waiting for it to return a completion packet. Currently this is done with the following synchronous function exposed by the runtime:
io_cmp io_wait_single_req(io_req* req);
However, that API can easily be extended to asynchronous IO by using two queues residing in wasm memory. Request packets are submitted to a request queue that is read asynchronously by the runtime, which then puts completion packets in a completion queue that can be checked or waited on by the app.
This concludes our tour of the Orca runtime in its current state. If you’ve stuck around until now, I hope you found it interesting! Don’t hesitate to come discuss with us in the Handmade Network Discord server and subscribe to our newsletter if you want to be notified of updates like this one!
Sign up to get updates on the Orca project.