SageLang Vulkan/GPU Guide

June 14, 2026 · View on GitHub

A comprehensive guide to the SageLang GPU graphics engine — from opening a window to building universe simulations.

Table of Contents

  1. Architecture Overview
  2. Getting Started
  3. Core Concepts
  4. Windowing & Input
  5. Buffers & Memory
  6. Images & Textures
  7. Shaders & Pipelines
  8. Render Passes & Framebuffers
  9. Command Recording
  10. Synchronization & Submission
  11. Descriptors & Uniforms
  12. 3D Math Library
  13. Meshes & Geometry
  14. Interactive Camera
  15. High-Level Renderer
  16. PBR Materials
  17. Post-Processing (HDR, Bloom, Tone Mapping)
  18. Shadows
  19. Deferred Rendering & Screen-Space Effects
  20. Compute Shaders
  21. Advanced Features
  22. Engine Libraries Reference
  23. Demo Catalog
  24. Troubleshooting
  25. API Reference
  26. OpenGL 4.5 Backend
  27. UI Widget Library

Architecture Overview

The GPU engine has five layers, with three execution paths:

Layer 5:  Sage Demos (examples/gpu_*.sage)
            |
Layer 4:  Engine Libraries (lib/graphics/renderer.sage, lib/graphics/scene.sage, ...)
            |
Layer 3:  Builder Libraries (lib/graphics/vulkan.sage, lib/graphics/opengl.sage, ...)
            |
Layer 2:  Execution Path (one of three):
            |-- Interpreter:  src/c/graphics.c (Value-based wrappers)
            |-- LLVM Compiled: src/c/llvm_runtime.c -> sage_rt_gpu_* -> sgpu_*
            |-- Bytecode VM:   src/vm/vm.c -> BC_OP_GPU_* -> sgpu_*
            |
Layer 1:  Pure C GPU API (include/gpu_api.h, src/c/gpu_api.c)
            |
          Vulkan SDK / OpenGL 4.5 + GLFW

Layer 1 (C GPU API): Backend-agnostic pure C API (sgpu_* functions) with ~100 functions. No interpreter dependency (no Value types). Supports Vulkan and OpenGL backends via SAGE_HAS_VULKAN/SAGE_HAS_OPENGL compile flags.

Layer 2 (Execution Paths):

  • Interpreter path: graphics.c (~5700 lines) wraps sgpu_* with Value-based argument extraction. Used when running sage game.sage.
  • LLVM compiled path: llvm_runtime.c provides 103 sage_rt_gpu_* bridge functions. Used when compiling with sage --compile-llvm game.sage.
  • Bytecode VM path: 30 dedicated BC_OP_GPU_* opcodes call sgpu_* directly. Used for frame-loop hot paths in the VM runtime.

Layer 3 (Sage builders): String-based helpers that wrap the verbose gpu.* calls. vulkan.buffer("storage") instead of gpu.create_buffer(1024, gpu.BUFFER_STORAGE, gpu.MEMORY_DEVICE_LOCAL). Also includes opengl.sage as a drop-in backend replacement.

Layer 4 (Engine): Application-level systems — scene graph, materials, PBR, shadows, post-processing, deferred rendering, TAA.

Layer 5 (Demos): Complete examples from "hello triangle" to N-body galaxy simulations.

Build Requirements

# Auto-detect Vulkan + GLFW + OpenGL (default)
make

# Force enable/disable
make VULKAN=1    # Force Vulkan
make VULKAN=0    # Disable Vulkan (stub mode)
make OPENGL=1    # Force OpenGL
make OPENGL=0    # Disable OpenGL

# Compile shaders
make shaders     # Compiles all .vert/.frag/.comp to .spv

# Compile a game to native executable with GPU support
sage --compile-llvm game.sage -o game

Without Vulkan SDK: the gpu module loads in stub mode — all constants are available, functions return error values gracefully.

Without GLFW: headless compute works, windowed rendering is disabled (gpu.has_window is false).

OpenGL Backend

To use OpenGL instead of Vulkan, use lib/graphics/opengl.sage:

import graphics.opengl

# Initializes with OpenGL 4.5 core profile instead of Vulkan
opengl.init_windowed("My App", 800, 600)

# Rest of the API is identical to gpu module
print opengl.device_name()
let buf = opengl.create_buffer(1024, gpu.BUFFER_VERTEX, gpu.MEMORY_HOST_VISIBLE)
# ...
opengl.shutdown_windowed()

For GLSL shaders (OpenGL path), use gpu.load_shader_glsl(source, stage) instead of gpu.load_shader(path, stage) which loads SPIR-V.

LLVM-Compiled GPU Programs

Games compiled via --compile-llvm get native-speed GPU access:

# Compile a game to a standalone executable
sage --compile-llvm my_game.sage -o my_game

# The executable links against Vulkan/GLFW/OpenGL automatically
./my_game

The C LLVM backend resolves GPU constants at compile time (including from gpu import CONST) and emits direct calls to sage_rt_gpu_* functions, which are linked from obj/gpu_api.o.


Getting Started

Minimal Window

import gpu

gpu.init_windowed("My App", 800, 600, "Window Title", false)
print gpu.device_name()

# Create render pass
let attach = {}
attach["format"] = gpu.FORMAT_SWAPCHAIN
attach["load_op"] = gpu.LOAD_CLEAR
attach["store_op"] = gpu.STORE_STORE
attach["initial_layout"] = gpu.LAYOUT_UNDEFINED
attach["final_layout"] = gpu.LAYOUT_PRESENT
let rp = gpu.create_render_pass([attach])
let fbs = gpu.create_swapchain_framebuffers(rp)

# Sync objects
let cmd_pool = gpu.create_command_pool()
let cmd = gpu.create_command_buffer(cmd_pool)
let img_sem = gpu.create_semaphore()
let rdr_sem = gpu.create_semaphore()
let fence = gpu.create_fence(true)

# Render loop
while gpu.window_should_close() == false:
    gpu.poll_events()
    gpu.wait_fence(fence)
    gpu.reset_fence(fence)

    let idx = gpu.acquire_next_image(img_sem)
    if idx >= 0:
        gpu.begin_commands(cmd)
        gpu.cmd_begin_render_pass(cmd, rp, fbs[idx], [[0.1, 0.2, 0.3, 1.0]])
        gpu.cmd_end_render_pass(cmd)
        gpu.end_commands(cmd)
        gpu.submit_with_sync(cmd, img_sem, rdr_sem, fence)
        gpu.present(idx, rdr_sem)

gpu.device_wait_idle()
gpu.shutdown_windowed()

Using the High-Level Renderer

For most applications, use lib/renderer.sage which handles all boilerplate:

from graphics.renderer import create_renderer, begin_frame, end_frame, shutdown_renderer, aspect_ratio

let r = create_renderer(1024, 768, "My Scene")

let frame = begin_frame(r)
while frame != nil:
    let cmd = frame["cmd"]
    let t = frame["time"]

    # Your draw calls here
    # gpu.cmd_bind_graphics_pipeline(cmd, pipeline)
    # gpu.cmd_draw(cmd, 3, 1, 0, 0)

    end_frame(r, frame)
    frame = begin_frame(r)

shutdown_renderer(r)

The renderer automatically creates: depth buffer, render pass (color + depth), swapchain framebuffers, command pool/buffers, per-frame sync (2 frames in flight), viewport/scissor setup.


Core Concepts

Handle System

Every GPU resource is represented by an integer handle. Invalid handles are -1 (gpu.INVALID_HANDLE).

let buf = gpu.create_buffer(1024, gpu.BUFFER_STORAGE, gpu.MEMORY_DEVICE_LOCAL)
if buf < 0:
    print "failed!"
# ... use buf ...
gpu.destroy_buffer(buf)

Constants

All Vulkan enums are exposed as module-level constants with bitwise OR composition:

# Buffer usage (combine with |)
let usage = gpu.BUFFER_STORAGE | gpu.BUFFER_TRANSFER_DST

# Memory properties
let mem = gpu.MEMORY_HOST_VISIBLE | gpu.MEMORY_HOST_COHERENT

# Image formats
gpu.FORMAT_RGBA8        # 8-bit RGBA
gpu.FORMAT_RGBA16F      # 16-bit float RGBA (HDR)
gpu.FORMAT_RGBA32F      # 32-bit float RGBA
gpu.FORMAT_R32F         # 32-bit float Red
gpu.FORMAT_RG32F        # 32-bit float RG
gpu.FORMAT_R8           # 8-bit Red
gpu.FORMAT_DEPTH32F     # 32-bit float depth
gpu.FORMAT_DEPTH24_S8   # 24-bit depth, 8-bit stencil
gpu.FORMAT_SWAPCHAIN    # Auto-resolves to actual swapchain format

# Shader stages
gpu.STAGE_VERTEX | gpu.STAGE_FRAGMENT    # Both stages
gpu.STAGE_COMPUTE                         # Compute only
gpu.STAGE_ALL                             # All stages

Windowing & Input

Window Management

gpu.init_windowed(app_name, width, height, title, validation?)  # Create window + Vulkan
gpu.window_should_close()     # Check close button
gpu.poll_events()             # Process OS events
gpu.set_title(new_title)      # Update window title
gpu.window_size()             # Returns {width, height}
gpu.swapchain_extent()        # Returns {width, height}
gpu.recreate_swapchain()      # Rebuild after resize
gpu.window_resized()          # Check and clear resize flag
gpu.shutdown_windowed()       # Destroy everything

Keyboard Input

gpu.update_input()                   # Call once per frame
gpu.key_pressed(gpu.KEY_W)           # Held down?
gpu.key_just_pressed(gpu.KEY_SPACE)  # First frame pressed?
gpu.key_just_released(gpu.KEY_E)     # First frame released?

Key constants: KEY_W, KEY_A, KEY_S, KEY_D, KEY_Q, KEY_E, KEY_R, KEY_F, KEY_SPACE, KEY_ESCAPE, KEY_ENTER, KEY_TAB, KEY_SHIFT, KEY_CTRL, KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT, KEY_1 through KEY_5.

Mouse Input

gpu.mouse_pos()                     # Returns {x, y} in pixels
gpu.mouse_delta()                   # Returns {dx, dy} since last frame
gpu.mouse_button(gpu.MOUSE_LEFT)    # Button held?
gpu.mouse_just_pressed(gpu.MOUSE_RIGHT) # First frame?
gpu.scroll_delta()                   # Returns {x, y}, consumed on read
gpu.set_cursor_mode(gpu.CURSOR_DISABLED)  # Capture mouse for FPS camera

Text Input

if gpu.text_input_available():
    let cp = gpu.text_input_read()  # Returns Unicode codepoint
    print "Typed: " + chr(cp)

Time

gpu.get_time()    # GLFW high-resolution timer (seconds since init)

Buffers & Memory

Creating Buffers

# Host-visible (CPU can read/write directly)
let buf = gpu.create_buffer(size_bytes, usage_flags, memory_flags)

# Common patterns:
let staging = gpu.create_buffer(1024, gpu.BUFFER_STAGING, gpu.MEMORY_HOST_VISIBLE | gpu.MEMORY_HOST_COHERENT)
let ssbo = gpu.create_buffer(4096, gpu.BUFFER_STORAGE, gpu.MEMORY_DEVICE_LOCAL)
let vbo = gpu.create_buffer(1024, gpu.BUFFER_VERTEX | gpu.BUFFER_TRANSFER_DST, gpu.MEMORY_DEVICE_LOCAL)

Upload / Download

# Upload float array to host-visible buffer
gpu.buffer_upload(buf, [1.0, 2.0, 3.0, 4.0])

# Download as float array
let data = gpu.buffer_download(buf)

# Upload to device-local (uses staging buffer internally)
let device_buf = gpu.upload_device_local([1.0, 2.0, 3.0], gpu.BUFFER_VERTEX)

# Upload raw bytes (for index buffers, binary data)
let ibuf = gpu.upload_bytes([0, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0], gpu.BUFFER_INDEX)

Uniform Buffers

let ubo = gpu.create_uniform_buffer(256)    # Persistent mapped
gpu.update_uniform(ubo, [1.0, 0.0, 0.0, 0.0, ...])  # Fast write (no staging)

Buffer Usage Flags

FlagValuePurpose
BUFFER_STORAGE0x01Shader storage buffer (SSBO)
BUFFER_UNIFORM0x02Uniform buffer (UBO)
BUFFER_VERTEX0x04Vertex buffer
BUFFER_INDEX0x08Index buffer
BUFFER_STAGING0x10CPU-to-GPU transfer source
BUFFER_INDIRECT0x20Indirect draw/dispatch args
BUFFER_TRANSFER_SRC0x40Copy source
BUFFER_TRANSFER_DST0x80Copy destination

Memory Flags

FlagValuePurpose
MEMORY_DEVICE_LOCAL0x01Fast GPU memory (not CPU accessible)
MEMORY_HOST_VISIBLE0x02CPU can read/write
MEMORY_HOST_COHERENT0x04No manual flush needed

Images & Textures

Creating Images

# 2D image
let img = gpu.create_image(width, height, 1, gpu.FORMAT_RGBA8, gpu.IMAGE_SAMPLED)

# 3D volume texture
let vol = gpu.create_image_3d(64, 64, 64, gpu.FORMAT_RGBA16F, gpu.IMAGE_STORAGE | gpu.IMAGE_SAMPLED)

# Cubemap (6 faces)
let cube = gpu.create_cubemap(512, gpu.FORMAT_RGBA8, gpu.IMAGE_SAMPLED | gpu.IMAGE_TRANSFER_DST)

# Depth buffer (auto-detects best format)
let depth = gpu.create_depth_buffer(width, height)

Loading Textures from Files

# PNG, JPG, BMP, TGA supported (via stb_image)
let tex = gpu.load_texture("textures/diffuse.png")
let dims = gpu.texture_dims(tex)
print dims["width"] + "x" + dims["height"]

Mipmaps & Samplers

# Generate mipmap chain
gpu.generate_mipmaps(image_handle, width, height)

# Simple sampler
let smp = gpu.create_sampler(gpu.FILTER_LINEAR, gpu.FILTER_LINEAR, gpu.ADDRESS_REPEAT)

# Advanced sampler with anisotropy and mipmaps
let smp = gpu.create_sampler_advanced(gpu.FILTER_LINEAR, gpu.FILTER_LINEAR, gpu.ADDRESS_REPEAT, 16.0, 8.0)

Shaders & Pipelines

Loading Shaders

Shaders must be pre-compiled to SPIR-V format:

glslc shader.vert -o shader.vert.spv
glslc shader.frag -o shader.frag.spv
glslc shader.comp -o shader.comp.spv
let vert = gpu.load_shader("shader.vert.spv", gpu.STAGE_VERTEX)
let frag = gpu.load_shader("shader.frag.spv", gpu.STAGE_FRAGMENT)
let comp = gpu.load_shader("compute.comp.spv", gpu.STAGE_COMPUTE)

# Hot-reload (replace shader module without destroying pipeline)
gpu.reload_shader(vert, "shader_v2.vert.spv")

Compute Pipelines

let layout = gpu.create_pipeline_layout([desc_layout], push_size, gpu.STAGE_COMPUTE)
let pipeline = gpu.create_compute_pipeline(layout, comp_shader)

Graphics Pipelines

let cfg = {}
cfg["layout"] = pipe_layout
cfg["render_pass"] = render_pass
cfg["vertex_shader"] = vert
cfg["fragment_shader"] = frag
cfg["topology"] = gpu.TOPO_TRIANGLE_LIST    # or LINE_LIST, POINT_LIST
cfg["cull_mode"] = gpu.CULL_BACK            # NONE, FRONT, BACK
cfg["front_face"] = gpu.FRONT_CCW           # or FRONT_CW
cfg["depth_test"] = true
cfg["depth_write"] = true
cfg["blend"] = true                          # Alpha blending
cfg["vertex_bindings"] = [binding_desc]
cfg["vertex_attribs"] = [attr_descs]

let pipeline = gpu.create_graphics_pipeline(cfg)

Render Passes & Framebuffers

Standard Render Pass

# Color-only
let attach = {}
attach["format"] = gpu.FORMAT_SWAPCHAIN
attach["load_op"] = gpu.LOAD_CLEAR
attach["store_op"] = gpu.STORE_STORE
attach["initial_layout"] = gpu.LAYOUT_UNDEFINED
attach["final_layout"] = gpu.LAYOUT_PRESENT
let rp = gpu.create_render_pass([attach])

Render Pass with Depth

let color = {}
color["format"] = gpu.FORMAT_SWAPCHAIN
color["load_op"] = gpu.LOAD_CLEAR
color["store_op"] = gpu.STORE_STORE
color["initial_layout"] = gpu.LAYOUT_UNDEFINED
color["final_layout"] = gpu.LAYOUT_PRESENT

let depth = {}
depth["format"] = gpu.FORMAT_DEPTH32F
depth["load_op"] = gpu.LOAD_CLEAR
depth["store_op"] = gpu.STORE_DONTCARE
depth["initial_layout"] = gpu.LAYOUT_UNDEFINED
depth["final_layout"] = gpu.LAYOUT_DEPTH_ATTACH

let rp = gpu.create_render_pass([color, depth])
let fbs = gpu.create_swapchain_framebuffers_depth(rp, depth_image)

Multiple Render Targets (Deferred/G-Buffer)

let formats = [gpu.FORMAT_RGBA16F, gpu.FORMAT_RGBA16F, gpu.FORMAT_RGBA8, gpu.FORMAT_RGBA16F]
let rp = gpu.create_render_pass_mrt(formats, true)  # 4 color + depth

Offscreen Render Targets

let target = gpu.create_offscreen_target(512, 512, gpu.FORMAT_RGBA16F, true)
# Returns: {image, depth, render_pass, framebuffer, width, height}

Command Recording

let pool = gpu.create_command_pool()
let cmd = gpu.create_command_buffer(pool)

gpu.begin_commands(cmd)

# Compute dispatch
gpu.cmd_bind_compute_pipeline(cmd, compute_pipe)
gpu.cmd_bind_descriptor_set(cmd, pipe_layout, 0, desc_set)
gpu.cmd_push_constants(cmd, pipe_layout, gpu.STAGE_COMPUTE, [dt, 0.0, 0.0, 0.0])
gpu.cmd_dispatch(cmd, 256, 1, 1)

# Barrier
gpu.cmd_pipeline_barrier(cmd, gpu.PIPE_COMPUTE, gpu.PIPE_FRAGMENT, gpu.ACCESS_SHADER_WRITE, gpu.ACCESS_SHADER_READ)

# Render pass
gpu.cmd_begin_render_pass(cmd, rp, framebuffer, [[0.0, 0.0, 0.0, 1.0]])
gpu.cmd_set_viewport(cmd, 0, 0, width, height, 0.0, 1.0)
gpu.cmd_set_scissor(cmd, 0, 0, width, height)

gpu.cmd_bind_graphics_pipeline(cmd, graphics_pipe)
gpu.cmd_push_constants(cmd, pipe_layout, gpu.STAGE_VERTEX, mvp_matrix)
gpu.cmd_bind_vertex_buffer(cmd, vbo)
gpu.cmd_bind_index_buffer(cmd, ibo)
gpu.cmd_draw_indexed(cmd, index_count, 1, 0, 0, 0)

gpu.cmd_end_render_pass(cmd)
gpu.end_commands(cmd)

Indirect Drawing (GPU-Driven)

gpu.cmd_draw_indirect(cmd, indirect_buffer, offset, draw_count, stride)
gpu.cmd_draw_indexed_indirect(cmd, indirect_buffer, offset, draw_count, stride)
gpu.cmd_dispatch_indirect(cmd, indirect_buffer, offset)

Secondary Command Buffers

For complex scenes, recording can be parallelized using secondary command buffers:

let pool = gpu.create_command_pool()
let cmd_primary = gpu.create_command_buffer(pool)
let cmd_sec = gpu.create_secondary_command_buffer(pool)

# Record secondary
gpu.begin_secondary(cmd_sec, render_pass, framebuffer, 0)
gpu.cmd_bind_graphics_pipeline(cmd_sec, pipeline)
gpu.cmd_draw(cmd_sec, 3, 1, 0, 0)
gpu.end_commands(cmd_sec)

# Execute from primary
gpu.begin_commands(cmd_primary)
gpu.cmd_begin_render_pass(cmd_primary, render_pass, framebuffer, [[0,0,0,1]])
gpu.cmd_execute_commands(cmd_primary, [cmd_sec])
gpu.cmd_end_render_pass(cmd_primary)
gpu.end_commands(cmd_primary)

Font Rendering

The native module provides hardware-accelerated text rendering:

let font = gpu.load_font("fonts/Roboto.ttf", 32)
let atlas = gpu.font_atlas(font)  # Image handle containing glyphs

# Measure text
let dims = gpu.font_measure(font, "Hello World", 1.0)
print "Width: " + str(dims["width"])

# Generate vertex data for a string
# out_verts is a float array [x,y,u,v, x,y,u,v, ...]
let out_verts = []
let count = gpu.font_text_verts(font, "Hello World", 0.0, 0.0, 1.0, out_verts, 1024)

Synchronization & Submission

Fences and Semaphores

let fence = gpu.create_fence(true)        # Signaled initially
let sem = gpu.create_semaphore()

gpu.wait_fence(fence)                     # Block until signaled
gpu.reset_fence(fence)                    # Reset for reuse

Frame Synchronization Pattern

# Per-frame sync (2 frames in flight)
let max_frames = 2
let img_sems = []
let rdr_sems = []
let fences = []
let fi = 0
while fi < max_frames:
    push(img_sems, gpu.create_semaphore())
    push(rdr_sems, gpu.create_semaphore())
    push(fences, gpu.create_fence(true))
    fi = fi + 1

# In render loop:
let cf = frame % max_frames
gpu.wait_fence(fences[cf])
gpu.reset_fence(fences[cf])
let idx = gpu.acquire_next_image(img_sems[cf])
# ... record commands ...
gpu.submit_with_sync(cmd, img_sems[cf], rdr_sems[cf], fences[cf])
gpu.present(idx, rdr_sems[cf])

Descriptors & Uniforms

Descriptor Layout

let b0 = {}
b0["binding"] = 0
b0["type"] = gpu.DESC_STORAGE_BUFFER   # or UNIFORM_BUFFER, SAMPLED_IMAGE, STORAGE_IMAGE, COMBINED_SAMPLER
b0["stage"] = gpu.STAGE_COMPUTE
b0["count"] = 1

let layout = gpu.create_descriptor_layout([b0])

Pool, Set, and Binding

let ps = {}
ps["type"] = gpu.DESC_STORAGE_BUFFER
ps["count"] = 4
let pool = gpu.create_descriptor_pool(2, [ps])
let desc_set = gpu.allocate_descriptor_set(pool, layout)

# Bind buffer to descriptor
gpu.update_descriptor(desc_set, 0, gpu.DESC_STORAGE_BUFFER, buffer_handle)

# Bind combined image sampler
gpu.update_descriptor_image(desc_set, 1, image_handle, sampler_handle)

3D Math Library

lib/math3d.sage provides all vector/matrix operations needed for 3D rendering.

Vectors

from graphics.math3d import vec3, v3_add, v3_sub, v3_scale, v3_dot, v3_cross, v3_normalize, v3_length

let a = vec3(1.0, 0.0, 0.0)
let b = vec3(0.0, 1.0, 0.0)
let c = v3_cross(a, b)         # [0, 0, 1]
let d = v3_dot(a, b)           # 0.0
let n = v3_normalize(vec3(3, 0, 4))   # [0.6, 0, 0.8]

Matrices

Matrices are column-major flat arrays of 16 floats (matching GLSL/Vulkan):

from graphics.math3d import mat4_identity, mat4_mul, mat4_translate, mat4_scale
from graphics.math3d import mat4_rotate_x, mat4_rotate_y, mat4_rotate_z
from graphics.math3d import mat4_perspective, mat4_ortho, mat4_look_at
from graphics.math3d import radians, pack_mvp

# Transform chain
let model = mat4_mul(mat4_translate(0, 1, 0), mat4_rotate_y(radians(45)))
let view = mat4_look_at(vec3(0, 2, 5), vec3(0, 0, 0), vec3(0, 1, 0))
let proj = mat4_perspective(radians(60), aspect, 0.1, 100.0)

# Pack for push constants
let mvp = pack_mvp(model, view, proj)
gpu.cmd_push_constants(cmd, layout, gpu.STAGE_VERTEX, mvp)

The perspective matrix is Vulkan-convention: Y-flipped, depth range 0-1.

Cameras

from graphics.math3d import camera_orbit, camera_fps

# Orbit camera (for object inspection)
let view = camera_orbit(angle_x, angle_y, distance, target_vec3)

# FPS camera
let view = camera_fps(position_vec3, yaw, pitch)

Meshes & Geometry

lib/mesh.sage provides procedural mesh generation and OBJ loading.

Vertex Format

All meshes use: [px, py, pz, nx, ny, nz, u, v] per vertex (32 bytes stride).

Procedural Meshes

from graphics.mesh import cube_mesh, plane_mesh, sphere_mesh, upload_mesh

let cube = upload_mesh(cube_mesh())           # 24 verts, 36 indices
let floor = upload_mesh(plane_mesh(10.0))     # 4 verts, 6 indices
let sphere = upload_mesh(sphere_mesh(24, 48)) # UV sphere

# Returns {vbuf, ibuf, vertex_count, index_count}

OBJ File Loading

from graphics.mesh import load_obj, upload_mesh

let mesh = load_obj("models/teapot.obj")
if mesh != nil:
    let gpu_mesh = upload_mesh(mesh)
    print "Loaded: " + str(mesh["vertex_count"]) + " vertices"

Supports: v (positions), vn (normals), vt (UVs), f (faces with triangulation).

Drawing Meshes

from graphics.mesh import mesh_vertex_binding, mesh_vertex_attribs

# For pipeline creation:
cfg["vertex_bindings"] = [mesh_vertex_binding()]
cfg["vertex_attribs"] = mesh_vertex_attribs()

# For drawing:
gpu.cmd_bind_vertex_buffer(cmd, gpu_mesh["vbuf"])
gpu.cmd_bind_index_buffer(cmd, gpu_mesh["ibuf"])
gpu.cmd_draw_indexed(cmd, gpu_mesh["index_count"], 1, 0, 0, 0)

Important: Index buffers must be uploaded as uint32 bytes (not floats). upload_mesh() handles this automatically using gpu.upload_bytes().


Interactive Camera

lib/camera.sage provides a ready-to-use FPS/orbit camera with WASD + mouse look.

from graphics.camera import create_camera, update_camera, camera_position

let cam = create_camera(0.0, 2.0, 5.0)   # Starting position

# In render loop:
gpu.update_input()  # Must call before camera update
let view = update_camera(cam, delta_time)

# Controls:
# WASD      - Move forward/back/left/right
# Space     - Move up
# Shift     - Move down
# Mouse     - Look around (when captured)
# Right MB  - Capture mouse
# Escape    - Release mouse
# Scroll    - Adjust movement speed

High-Level Renderer

lib/renderer.sage manages the full frame lifecycle:

from graphics.renderer import create_renderer, begin_frame, end_frame, shutdown_renderer
from graphics.renderer import aspect_ratio, check_resize, update_title_fps

let r = create_renderer(1024, 768, "My Scene")

let frame = begin_frame(r)
while frame != nil:
    check_resize(r)                    # Handle window resize
    update_title_fps(r, "My Scene")    # FPS in title bar

    let cmd = frame["cmd"]
    let t = frame["time"]

    # Draw your scene...

    end_frame(r, frame)
    frame = begin_frame(r)

shutdown_renderer(r)  # Prints FPS stats

PBR Materials

lib/pbr.sage provides physically-based rendering material definitions.

Material Presets

from graphics.pbr import pbr_gold, pbr_silver, pbr_copper, pbr_plastic_red, pbr_rubber, pbr_ceramic

let gold = pbr_gold()           # metallic=1.0, roughness=0.3
let plastic = pbr_plastic_red() # metallic=0.0, roughness=0.5
let emissive = pbr_emissive([1.0, 0.5, 0.0], 5.0)  # Glowing orange

Custom Materials

from graphics.pbr import create_pbr_material, pack_pbr_material

let mat = create_pbr_material([0.9, 0.1, 0.1], 0.8, 0.2, 1.0)
# albedo=[R,G,B], metallic, roughness, ambient_occlusion

let ubo_data = pack_pbr_material(mat)  # 16 floats for UBO

PBR Shader

The pbr.frag shader implements Cook-Torrance BRDF with:

  • GGX/Trowbridge-Reitz Normal Distribution Function
  • Smith Geometry Function (Schlick-GGX)
  • Schlick Fresnel Approximation
  • Reinhard HDR tone mapping
  • Gamma correction

Post-Processing

lib/postprocess.sage manages HDR rendering, bloom, and tone mapping.

from graphics.postprocess import create_postprocess, tonemap_params

let pp = create_postprocess(1024, 768)
pp["exposure"] = 1.5
pp["bloom_intensity"] = 0.4
pp["tonemap_mode"] = TONEMAP_ACES   # or REINHARD, UNCHARTED2

Bloom Pipeline

Three shader passes in examples/shaders/:

  1. bloom_extract.frag — Extract pixels above brightness threshold
  2. bloom_blur.frag — 5-tap Gaussian blur (horizontal + vertical)
  3. bloom_composite.frag — Combine scene + bloom with tone mapping (ACES or Reinhard)

Shadows

lib/shadows.sage provides shadow mapping utilities.

from graphics.shadows import create_shadow_map, create_shadow_pass, create_cascade_shadows, compute_light_matrix

# Single shadow map
let sm = create_shadow_map(2048)
let sp = create_shadow_pass()

# Cascade shadows (4 cascades for large scenes)
let csm = create_cascade_shadows(2048, 4)

# Compute light-space matrix
let light_mat = compute_light_matrix(light_direction, scene_min, scene_max)

Two-Pass Shadow Rendering

  1. Depth pass: Render scene from light's perspective using shadow_depth.vert/.frag
  2. Main pass: Sample shadow map in fragment shader, compare depth

Deferred Rendering

lib/deferred.sage provides G-buffer management and screen-space effects.

G-Buffer

from graphics.deferred import create_gbuffer, create_ssao_context, create_ssr_context

let gb = create_gbuffer(1024, 768)
# Creates 4 color attachments + depth:
# 0: Position (RGBA16F)
# 1: Normal (RGBA16F)
# 2: Albedo (RGBA8)
# 3: Emission (RGBA16F)
# + Depth (auto format)

SSAO (Screen-Space Ambient Occlusion)

let ssao = create_ssao_context(1024, 768)
ssao["radius"] = 0.5
ssao["kernel_size"] = 32

SSR (Screen-Space Reflections)

let ssr = create_ssr_context(1024, 768)
ssr["max_steps"] = 64
ssr["max_distance"] = 50.0

Compute Shaders

Basic Compute Dispatch

let comp = gpu.load_shader("compute.comp.spv", gpu.STAGE_COMPUTE)
let layout = gpu.create_pipeline_layout([desc_layout], 16, gpu.STAGE_COMPUTE)
let pipeline = gpu.create_compute_pipeline(layout, comp)

gpu.cmd_bind_compute_pipeline(cmd, pipeline)
gpu.cmd_bind_descriptor_set(cmd, layout, 0, desc_set)
gpu.cmd_push_constants(cmd, layout, gpu.STAGE_COMPUTE, [dt, param1, param2, param3])
gpu.cmd_dispatch(cmd, workgroup_x, workgroup_y, workgroup_z)

Ping-Pong Pattern (Double Buffering)

Used for particle systems and physics simulations:

# Create two SSBOs
let buf_a = gpu.create_buffer(size, gpu.BUFFER_STORAGE, gpu.MEMORY_HOST_VISIBLE | gpu.MEMORY_HOST_COHERENT)
let buf_b = gpu.create_buffer(size, gpu.BUFFER_STORAGE, gpu.MEMORY_HOST_VISIBLE | gpu.MEMORY_HOST_COHERENT)

# Two descriptor sets: A->B and B->A
let desc_ab = gpu.allocate_descriptor_set(pool, layout)
let desc_ba = gpu.allocate_descriptor_set(pool, layout)
gpu.update_descriptor(desc_ab, 0, gpu.DESC_STORAGE_BUFFER, buf_a)  # Read from A
gpu.update_descriptor(desc_ab, 1, gpu.DESC_STORAGE_BUFFER, buf_b)  # Write to B
gpu.update_descriptor(desc_ba, 0, gpu.DESC_STORAGE_BUFFER, buf_b)  # Read from B
gpu.update_descriptor(desc_ba, 1, gpu.DESC_STORAGE_BUFFER, buf_a)  # Write to A

# Alternate each frame
if ping == 0:
    gpu.cmd_bind_descriptor_set(cmd, layout, 0, desc_ab)
else:
    gpu.cmd_bind_descriptor_set(cmd, layout, 0, desc_ba)

Advanced Features

Temporal Anti-Aliasing (TAA)

from graphics.taa import create_taa, taa_jitter_projection, taa_advance, pack_taa_params

let taa = create_taa(1024, 768)
let jittered_proj = taa_jitter_projection(projection_matrix, taa)
# ... render with jittered projection ...
taa_advance(taa)  # Swap history buffers, advance frame

Scene Graph

from graphics.scene import create_node, add_child, world_transform, traverse, find_node

let root = create_node("root")
let cube = create_node("cube")
cube["transform"] = mat4_translate(2.0, 0.0, 0.0)
add_child(root, cube)

# Recursive traversal
proc draw_node(node):
    let wt = world_transform(node)
    # draw with wt as model matrix

traverse(root, draw_node)

Material System

from graphics.material import create_material, build_pipeline, bind_material

let mat = create_material("vert.spv", "frag.spv", descriptor_bindings, push_size)
build_pipeline(mat, render_pass, vertex_bindings, vertex_attribs, config)

# In render loop:
bind_material(cmd, mat)

Asset Cache

from graphics.asset_cache import load_shader_cached, load_texture_cached, cache_mesh

let shader = load_shader_cached("shader.spv", gpu.STAGE_VERTEX)  # Deduped
let tex = load_texture_cached("texture.png")                      # Deduped

Frame Graph

from graphics.frame_graph import create_frame_graph, create_pass, fg_add_pass, fg_compile, fg_execute

let fg = create_frame_graph()
let shadow = create_pass("shadows", PASS_GRAPHICS)
pass_writes(shadow, "shadow_map")
let main = create_pass("main", PASS_GRAPHICS)
pass_reads(main, "shadow_map")
fg_add_pass(fg, shadow)
fg_add_pass(fg, main)
fg_compile(fg)   # Topological sort by dependencies
fg_execute(fg, cmd)

Debug UI

from graphics.debug_ui import create_debug_ui, debug_frame, debug_fps, debug_set, debug_print

let ui = create_debug_ui()
debug_frame(ui, delta_time)
debug_set(ui, "particles", 65536)
debug_set(ui, "draw_calls", 42)
if gpu.key_just_pressed(gpu.KEY_F):
    debug_print(ui)   # Prints FPS, GPU name, custom values to console

Screenshot

# Save current frame to PNG
gpu.save_screenshot("output.png")

# Raw pixel data
let shot = gpu.screenshot()   # {width, height, pixels}

Spatial & Optimization Utilities

Octree Culling (graphics.octree)

The octree provides efficient spatial partitioning for frustum culling and neighbor queries:

import graphics.octree

# Create octree covering 1000 unit cube
let tree = octree.create_octree(vec3(0,0,0), 500)

# Insert objects
octree.insert(tree, object_index, position)

# Query objects within radius
let results = []
octree.query_radius(tree, center, 100, results)

Level of Detail (graphics.lod)

import graphics.lod

# Config: [full, med, low, billboard, point] distances
let cfg = lod.create_lod_config([50, 200, 1000, 5000, 20000])

let level = lod.compute_lod(cfg, camera_pos, object_pos)
if level == lod.LOD_FULL:
    # render high-poly

Trails & Orbit Prediction (graphics.trails)

import graphics.trails

# Create trail with 100 points
let t = trails.create_trail(100, 0.5)
trails.trail_add_point(t, x, y, z)

# Get vertices for line rendering
let verts = trails.trail_get_vertices(t)

Engine Libraries Reference

All graphics library modules live in lib/graphics/ and are imported with the graphics. prefix (e.g., import graphics.vulkan binds as vulkan).

ImportFilePurpose
import gpu (native)C moduleCore Vulkan operations
import graphics.vulkanlib/graphics/vulkan.sageBuilder pattern helpers
import graphics.math3dlib/graphics/math3d.sageVectors, matrices, camera, projection
import graphics.octreelib/graphics/octree.sageSpatial partitioning and culling
import graphics.lodlib/graphics/lod.sageLevel of Detail management
import graphics.trailslib/graphics/trails.sageParticle trails and orbit lines
import graphics.meshlib/graphics/mesh.sageProcedural meshes, OBJ loading, GPU upload
import graphics.cameralib/graphics/camera.sageInteractive FPS/orbit camera
import graphics.rendererlib/graphics/renderer.sageHigh-level frame loop
import graphics.pbrlib/graphics/pbr.sagePBR materials and lights
import graphics.postprocesslib/graphics/postprocess.sageHDR, bloom, tone mapping
import graphics.shadowslib/graphics/shadows.sageShadow map management
import graphics.deferredlib/graphics/deferred.sageG-buffer, SSAO, SSR
import graphics.taalib/graphics/taa.sageTemporal anti-aliasing
import graphics.scenelib/graphics/scene.sageScene graph (node hierarchy)
import graphics.materiallib/graphics/material.sageMaterial system
import graphics.asset_cachelib/graphics/asset_cache.sageResource deduplication
import graphics.frame_graphlib/graphics/frame_graph.sagePass dependency ordering
import graphics.debug_uilib/graphics/debug_ui.sageFPS and debug overlay
import graphics.gltflib/graphics/gltf.sageglTF 2.0 model loading
import graphics.gpulib/graphics/gpu.sageHigh-level compute helpers
import graphics.opengllib/graphics/opengl.sageOpenGL backend (drop-in replacement)

Demo Catalog

DemoFileWhat it shows
Empty Windowexamples/gpu_window.sageWindow creation, clear color, frame loop
Triangleexamples/gpu_triangle.sageVertex/fragment shaders, SPIR-V loading
3D Hello Worldexamples/gpu_hello3d.sageLine rendering, push constants, perspective
Spinning Cubeexamples/gpu_cube.sageDepth buffer, indexed drawing, 3D transforms
Phong Sceneexamples/gpu_phong.sagePhong/Blinn-Phong lighting, orbit camera
GPU Particlesexamples/gpu_particles.sageCompute shader, ping-pong SSBO, 65536 particles
Planetexamples/gpu_planet.sageFullscreen raymarching, procedural terrain, atmosphere
N-Body Galaxyexamples/gpu_nbody.sageN-body gravity, shared-memory compute, 8192 stars
PBR Materialsexamples/gpu_pbr.sageCook-Torrance BRDF, metallic/roughness grid

Run any demo:

./sage examples/gpu_planet.sage

Troubleshooting

"Vulkan not available"

  • Install Vulkan SDK: sudo apt install vulkan-tools libvulkan-dev
  • Verify: vulkaninfo | head
  • Rebuild: make clean && make

"window creation failed"

  • Install GLFW: sudo apt install libglfw3-dev
  • On Wayland, the engine forces X11 via GLFW_PLATFORM_X11 to avoid libdecor crashes

Black screen / no rendering

  • Check shader compilation: make shaders
  • Verify pipeline creation returns >= 0
  • Ensure cmd_set_viewport and cmd_set_scissor are called

Cube/mesh not visible

  • Index buffers must use gpu.upload_bytes() with uint32 byte packing (not float upload)
  • upload_mesh() handles this automatically
  • Check view matrix direction — Vulkan is right-handed with -Z forward

Validation errors about semaphores

  • Use per-frame-in-flight sync (2+ semaphore/fence sets)
  • Don't reuse a semaphore that's still pending from a previous present

API Reference

Context

FunctionReturnsDescription
gpu.has_vulkan()boolVulkan available?
gpu.has_windowboolGLFW available?
gpu.initialize(name, validation?)boolHeadless Vulkan init
gpu.init_windowed(name, w, h, title, validation?)boolWindowed init
gpu.get_active_backend()int1=Vulkan, 2=OpenGL
gpu.last_error()stringLast error message
gpu.shutdown()nilDestroy headless
gpu.shutdown_windowed()nilDestroy window + Vulkan
gpu.device_name()stringGPU name
gpu.device_limits()dictDevice capability limits
gpu.device_wait_idle()nilWait for GPU idle

Buffers

FunctionReturnsDescription
gpu.create_buffer(size, usage, mem)handleCreate buffer
gpu.create_uniform_buffer(size)handlePersistent-mapped UBO
gpu.update_uniform(handle, data)nilFast UBO write
gpu.buffer_upload(handle, floats)boolUpload float array
gpu.buffer_download(handle)arrayDownload as floats
gpu.upload_device_local(floats, usage)handleStaging upload
gpu.upload_bytes(bytes, usage)handleRaw byte upload
gpu.buffer_size(handle)numberBuffer size in bytes
gpu.destroy_buffer(handle)nilDestroy buffer

Images & Textures

FunctionReturnsDescription
gpu.create_image(w, h, d, fmt, usage)handleCreate 2D/3D image
gpu.create_image_3d(w, h, d, fmt, usage)handleCreate 3D volume
gpu.create_cubemap(size, fmt, usage)handleCreate cubemap (6 faces)
gpu.create_depth_buffer(w, h)handleAuto-format depth
gpu.load_texture(path)handleLoad PNG/JPG via stb_image
gpu.generate_mipmaps(img, w, h)nilGenerate mip chain
gpu.create_sampler(mag, min, addr)handleSimple sampler
gpu.create_sampler_advanced(mag, min, addr, aniso, mips)handleAnisotropic sampler
gpu.image_dims(handle)dict{width, height, depth}
gpu.destroy_image(handle)nilDestroy image

Shaders & Pipelines

FunctionReturnsDescription
gpu.load_shader(path, stage)handleLoad SPIR-V
gpu.reload_shader(handle, path)boolHot-reload shader
gpu.create_pipeline_layout(layouts, push_size?, stages?)handlePipeline layout
gpu.create_compute_pipeline(layout, shader)handleCompute pipeline
gpu.create_graphics_pipeline(config_dict)handleGraphics pipeline
gpu.destroy_pipeline(handle)nilDestroy pipeline

Render Passes

FunctionReturnsDescription
gpu.create_render_pass(attachments)handleStandard render pass
gpu.create_render_pass_mrt(formats, depth?)handleMulti-target pass
gpu.create_offscreen_target(w, h, fmt, depth?)dictOffscreen target
gpu.create_framebuffer(rp, images, w, h)handleCustom framebuffer
gpu.create_swapchain_framebuffers(rp)arraySwapchain FBs
gpu.create_swapchain_framebuffers_depth(rp, depth)arraySwapchain FBs + depth

Commands

FunctionDescription
gpu.begin_commands(cmd)Begin recording
gpu.begin_secondary(cmd, rp, fb, sub?)Begin secondary recording
gpu.end_commands(cmd)End recording
gpu.cmd_execute_commands(cmd, list)Execute secondary cmds
gpu.cmd_bind_compute_pipeline(cmd, pipe)Bind compute
gpu.cmd_bind_graphics_pipeline(cmd, pipe)Bind graphics
gpu.cmd_bind_descriptor_set(cmd, layout, idx, set)Bind descriptors
gpu.cmd_dispatch(cmd, x, y, z)Compute dispatch
gpu.cmd_dispatch_indirect(cmd, buf, offset)Indirect dispatch
gpu.cmd_push_constants(cmd, layout, stage, data)Push constants
gpu.cmd_begin_render_pass(cmd, rp, fb, clear)Begin render pass
gpu.cmd_end_render_pass(cmd)End render pass
gpu.cmd_draw(cmd, verts, inst, first_v, first_i)Draw
gpu.cmd_draw_indexed(cmd, idx, inst, first, off, fi)Indexed draw
gpu.cmd_draw_indirect(cmd, buf, off, count, stride)Indirect draw
gpu.cmd_bind_vertex_buffer(cmd, buf)Bind single VBO
gpu.cmd_bind_vertex_buffers(cmd, bufs_array)Bind multiple VBOs
gpu.cmd_bind_index_buffer(cmd, buf)Bind IBO
gpu.cmd_set_viewport(cmd, x, y, w, h, min, max)Set viewport
gpu.cmd_set_scissor(cmd, x, y, w, h)Set scissor
gpu.cmd_pipeline_barrier(cmd, src, dst, sa, da)Memory barrier
gpu.cmd_image_barrier(cmd, img, old, new, ss, ds, sa, da)Image barrier

Sync & Present

FunctionReturnsDescription
gpu.create_fence(signaled?)handleCreate fence
gpu.wait_fence(handle, timeout?)boolWait for fence
gpu.reset_fence(handle)nilReset fence
gpu.create_semaphore()handleCreate semaphore
gpu.acquire_next_image(semaphore)numberGet swapchain image
gpu.present(image_idx, wait_sem)boolPresent to screen
gpu.submit_with_sync(cmd, wait, signal, fence)nilFull sync submit
gpu.submit(cmd, wait?, signal?, fence?)nilGraphics submit
gpu.submit_compute(cmd, wait?, signal?, fence?)nilCompute submit
gpu.recreate_swapchain()boolRebuild swapchain

Input

FunctionReturnsDescription
gpu.key_pressed(key)boolKey held down
gpu.key_just_pressed(key)boolFirst frame pressed
gpu.key_just_released(key)boolFirst frame released
gpu.update_input()nilUpdate key states
gpu.mouse_pos()dict{x, y} in pixels
gpu.mouse_button(btn)boolButton held
gpu.mouse_delta()dict{dx, dy} since last frame
gpu.scroll_delta()dict{x, y} consumed
gpu.text_input_available()boolCharacter waiting?
gpu.text_input_read()intGet codepoint
gpu.set_cursor_mode(mode)nilCursor capture
gpu.get_time()numberSeconds since init
gpu.set_title(title)nilWindow title
gpu.save_screenshot(path)boolSave to PNG
gpu.screenshot()dictGet raw pixels {width, height, pixels}

OpenGL 4.5 Backend

SageLang supports OpenGL 4.5+ as an alternative to Vulkan via lib/opengl.sage.

Setup

import graphics.opengl

# Initialize with OpenGL instead of Vulkan
opengl.init_windowed("My App", 800, 600)
print opengl.device_name()

Differences from Vulkan

FeatureVulkan (import gpu)OpenGL (import opengl)
Shader formatSPIR-V (.spv files)GLSL via gpu.load_shader_glsl()
Initializationgpu.init_windowed()opengl.init_windowed()
API surfaceIdentical after initIdentical after init
Backend flagSAGE_HAS_VULKANSAGE_HAS_OPENGL

Build Detection

OpenGL is auto-detected via pkg-config:

make                 # Auto-detect both Vulkan and OpenGL
make OPENGL=1        # Force OpenGL
make OPENGL=0        # Disable OpenGL

When SAGE_HAS_OPENGL is set, the GPU API layer (gpu_api.c) initializes an OpenGL 4.5 core profile context via GLFW. All sgpu_* functions route to OpenGL calls instead of Vulkan.

LLVM Compiled Path

LLVM-compiled programs automatically link against both backends:

sage --compile-llvm game.sage -o game
# Links: -lvulkan -lglfw -lGL

Compile-time import note: in the C LLVM backend, from gpu import SOME_CONSTANT resolves directly from the built-in GPU constant table, eliminating runtime constant lookups.


UI Widget Library

lib/graphics/ui.sage provides an immediate-mode GUI system for GPU applications, similar in philosophy to Dear ImGui.

Quick Start

import gpu
import graphics.ui

gpu.init_windowed("UI Demo", 800, 600, "Sage UI", false)
let ctx = ui.ui_create()

while not gpu.window_should_close():
    gpu.poll_events()
    ui.ui_begin_frame(ctx)

    # Draw widgets
    ui.ui_panel(ctx, 10, 10, 300, 400, "My Panel")
    if ui.ui_button(ctx, 20, 50, 120, 30, "Click Me"):
        print "Button clicked!"

    ui.ui_label(ctx, 20, 90, "Hello from Sage UI")

    ui.ui_end_frame(ctx)
    # ui.ui_render(ctx, cmd_buf, font)  # Issue GPU draw commands

Widget Reference

WidgetFunctionReturns
Labelui_label(ctx, x, y, text)nil
Buttonui_button(ctx, x, y, w, h, label)bool (clicked)
Panelui_panel(ctx, x, y, w, h, title)nil
Windowui_window(ctx, x, y, w, h, title)dict (content area)
Checkboxui_checkbox(ctx, x, y, label, checked)bool (new state)
Sliderui_slider(ctx, x, y, w, label, value)number (0.0-1.0)
Scrollbarui_scrollbar_v(ctx, x, y, h, content_h, scroll)number (0.0-1.0)
Menuui_menu_button(ctx, x, y, w, h, label, items)int (item index or -1)
Text Inputui_text_input(ctx, x, y, w, label, text)string (current text)
Progressui_progress(ctx, x, y, w, h, value, label)nil
Separatorui_separator(ctx, x, y, w)nil
Tooltipui_tooltip(ctx, text)nil

Theming

let ctx = ui.ui_create()
let theme = ctx["theme"]

# Customize colors (RGBA arrays)
theme["accent"] = [0.9, 0.3, 0.1, 1.0]     # Orange accent
theme["bg"] = [0.05, 0.05, 0.08, 0.95]      # Darker background
theme["text"] = [1.0, 1.0, 1.0, 1.0]        # White text

# Customize sizes
theme["font_size"] = 14
theme["padding"] = 8
theme["title_height"] = 28

Architecture

The UI library uses an immediate-mode pattern:

  1. ui_begin_frame(ctx) — reads mouse input from gpu.mouse_pos() / gpu.mouse_button()
  2. Widget calls — each widget checks hit-testing, updates state, and appends draw commands to ctx["draw_list"]
  3. ui_end_frame(ctx) — resets active state when mouse released
  4. ui_render(ctx, cmd_buf, font) — issues GPU draw commands for all accumulated quads and text

Draw commands are dicts with type ("rect" or "text"), position, size, and color. Custom renderers can read ui_get_draw_list(ctx) directly.