Orca Architecture Preview

By Martin Fouilleul — 2023-08-01

Orca Architecture Preview

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:

Running your app’s wasm module

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:

  • The Orca runtime gets started and initializes the environment.
  • It then locates and loads your wasm module into the interpreter. We’re currently using wasm3, but have plans to switch to Reuben’s interpreter after the first MVP.
  • The runtime then binds all ORCA_IMPORT APIs that your module references to their native implementation.
  • The runtime then looks up and registers ORCA_EXPORT functions exposed by your app.
  • The runtime then calls your module’s OnInit() handler, if you defined one.
  • Finally, the runtime enters its runloop, pumping events from the operating system and calling the appropriate handlers exposed by your module. For instance, when the user presses a key, the 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.

Graphics

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

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!

Why GLES 3.1?

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.

UI

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:

Passing Events

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);
}

Building the UI tree and computing UI signal

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.

Styling

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.

Layout

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.

Drawing

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);

File IO

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:

  • Requesting a capability for an absolute or current-directory-relative path, which will display a pop-up prompting the user to accept or deny this request.
  • Displaying an open dialog and letting the user pick a directory or file they want to give access to.

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.

Packet-based IO API

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.

Closing words

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!