Skip to content

Architecture Overview

Zest is built around two core objects and one execution model. Understanding these is key to using the library effectively.

The Two Core Objects

Device (zest_device)

The device is a singleton that represents your GPU and manages global resources:

// Currently using the Vulkan backend with SDL2
zest_window_data_t window_data = zest_implsdl2_CreateWindow(50, 50, 1280, 768, 0, "My App");
zest_device device = zest_implsdl2_CreateVulkanDevice(&window_data, false);

What it owns:

  • Graphics API instance and physical device
  • Shader library (compiled shaders)
  • Pipeline template cache
  • Bindless descriptor sets
  • GPU memory pools (buffers, images)
  • Sampler cache

Lifecycle: Create once at startup, destroy at shutdown. All contexts share the same device.

Context (zest_context)

The context represents a render target (window) and manages per-frame resources:

zest_create_context_info_t create_info = zest_CreateContextInfo();
zest_context context = zest_CreateContext(device, &window_data, &create_info);

What it owns:

  • Swapchain and presentation
  • Frame synchronization primitives
  • Command buffer pools
  • Frame graph cache
  • Linear allocators for frame-lifetime data

Lifecycle: One per window. Can have multiple contexts sharing one device.

┌─────────────────────────────────────────────────┐
│                    Device                       │
│  - Graphics API instance                        │
│  - Shader library                               │
│  - Bindless descriptors                         │
│  - Memory pools                                 │
├─────────────────────────────────────────────────┤
│         │                       │               │
│    ┌────▼────┐             ┌────▼────┐          │
│    │ Context │             │ Context │          │
│    │ (Win 1) │             │ (Win 2) │          │
│    └─────────┘             └─────────┘          │
└─────────────────────────────────────────────────┘

The Frame Graph

The frame graph is Zest's execution model. Instead of manually managing barriers, semaphores, and resource states, you declare what resources each pass reads and writes.

Why Frame Graphs?

Traditional low-level graphics APIs (Vulkan, D3D12, Metal) require:

  • Manual barrier/synchronization insertion
  • Explicit resource state tracking
  • Render pass management
  • Cross-queue synchronization

Frame graphs handle all of this automatically by analyzing resource dependencies.

Building a Frame Graph

if (zest_BeginFrameGraph(context, "My Graph", &cache_key)) {
    // 1. Import external resources
    zest_ImportSwapchainResource();

    // 2. Define passes
    zest_BeginRenderPass("Pass Name"); {
        zest_ConnectSwapChainOutput();       // Output to swapchain
        zest_SetPassTask(MyCallback, data);  // Render callback
        zest_EndPass();
    }

    // 3. Compile
    frame_graph = zest_EndFrameGraph();
}

// For custom resources, use:
// zest_ConnectInput(some_resource);    // Read from resource
// zest_ConnectOutput(other_resource);  // Write to resource

What Happens at Compile Time

When you call zest_EndFrameGraph(), the compiler:

  1. Builds the dependency graph - Analyzes which passes depend on which resources
  2. Inserts barriers - Adds synchronization barriers for resource transitions
  3. Culls unused passes - Removes passes that don't contribute to final output
  4. Handles synchronization - Manages synchronization between queues
  5. Creates transient resources - Allocates temporary images/buffers

Pass Types

Pass Type Purpose Example
Render Pass Graphics pipeline, drawing Scene rendering, UI
Compute Pass Compute shaders Particle simulation, post-processing
Transfer Pass Data uploads Staging buffer copies
zest_BeginRenderPass("Draw Scene");   // Graphics
zest_BeginComputePass(compute, "Sim"); // Compute
zest_BeginTransferPass("Upload");      // Transfer

Frame Graph Caching

Compiling frame graphs has CPU cost. Cache them when the structure doesn't change:

// Generate cache key (can include custom data)
zest_frame_graph_cache_key_t key = zest_InitialiseCacheKey(context, custom_data, size);

// Try to get cached graph
zest_frame_graph graph = zest_GetCachedFrameGraph(context, &key);

// Only build if not cached
if (!graph) {
    if (zest_BeginFrameGraph(context, "Graph", &key)) {
        // ... build graph ...
        graph = zest_EndFrameGraph();
    }
}

Bindless Descriptors

Zest uses a bindless descriptor model. Instead of creating and binding descriptor sets per object, all resources are indexed into global arrays.

// Acquire an index when creating a texture
zest_uint tex_index = zest_AcquireSampledImageIndex(device, image, zest_texture_2d_binding);

// Pass the index to shaders via push constants (inside a render callback)
push_data.texture_index = tex_index;
zest_cmd_SendPushConstants(command_list, &push_data, sizeof(push_data));

In shaders:

layout(set = 0, binding = 0) uniform sampler2D textures[];

void main() {
    vec4 color = texture(textures[push.texture_index], uv);
}

Benefits:

  • Bind descriptor sets once per frame
  • No descriptor set management per object
  • Unlimited textures (within GPU limits)
  • Simpler shader code

Memory Management

Zest uses a TLSF allocator for both CPU and GPU memory:

  • Minimal fragmentation - Two-level segregated fit algorithm
  • O(1) allocation - Constant time allocate and free
  • Auto-expanding pools - Pools grow when needed

Pool Types

Pool Purpose Typical Size
Device Buffer Pool GPU-only buffers 64 MB
Staging Buffer Pool CPU-visible uploads 32 MB
Image Memory Pool Textures 256 MB
// Configure pool sizes after creating device
zest_SetStagingBufferPoolSize(device, zloc__KILOBYTE(256), zloc__MEGABYTE(128));
zest_SetGPUBufferPoolSize(device, zloc__KILOBYTE(256), zloc__MEGABYTE(64));

Typical Frame Structure

while (running) {
    zest_UpdateDevice(device);          // 1. Update device state
    HandleInput();                       // 2. Process input

    if (zest_BeginFrame(context)) {      // 3. Begin frame
        // Update uniforms, instance data, etc.
        UpdateGameState();

        // Get or build frame graph
        zest_frame_graph graph = GetOrBuildFrameGraph();

        // Execute graph and present
        zest_EndFrame(context, graph);   // 4. Execute & present
    }
}

Next Steps