3 Concepts
dxtr edited this page 2026-10-14 19:19:56 +02:00

Concepts

This page explains the model behind cl-gfx. Read it before the API pages.

The object graph

device ─┬─ surface ── (target: a window)
        ├─ buffer
        ├─ texture
        ├─ sampler
        ├─ shader-module ──┐
        ├─ bind-group-layout ──┬─ render-pipeline
        │                      ├─ compute-pipeline
        │                      └─ bind-group ── (buffers, textures, samplers)
        └─ render-bundle
  • A device is an opened GPU. Everything else is made from a device, and object-device returns that device.
  • Buffers and textures hold data. Samplers say how shaders read textures.
  • Shader modules hold compiled SPIR-V. A pipeline combines shader modules with the fixed-function state. It is immutable.
  • A bind group layout describes the bindings shaders read: numbers, kinds and stages. Pipelines are made with layouts, and bind groups fill a layout with real buffers, textures and samplers.
  • A surface presents images to a surface target, such as a window.
  • A render bundle is a list of draw commands recorded once and replayed in later frames.

This is WebGPU's model. Anyone who knows WebGPU or wgpu will recognise it.

Recording, not immediate drawing

Drawing is recorded. Within a frame, passes record commands into the frame's command list, a plain Lisp vector. When the frame ends, the backend translates the list into graphics API calls and submits it.

begin-frame ─ pass ─ pass ─ … ─ end-frame
              │
              └─ set-pipeline, set-bind-group, set-vertex-buffer, draw …
  • Passes run in the order they are recorded. Each pass sees what the passes before it wrote.
  • The backend tracks how every buffer and texture was last written and read, and inserts the barriers each pass needs. You never write a barrier.
  • Recording is cheap and, once the command list has grown big enough, allocates nothing.

Frames in flight

Several frames are in flight at once: two by default, up to eight (:frames-in-flight on open-device). While the GPU draws frame n, the CPU records frame n+1. begin-frame waits only for the frame that last used the same slot.

Two consequences follow:

  1. Destroying is deferred. destroy-buffer and the other destroy- functions mark the object as destroyed at once, so using it signals destroyed-error. The backend releases the object only once no frame in flight uses it, so it is safe to destroy something drawn in the previous frame.
  2. Don't overwrite data still being read. An :upload buffer is written directly by the CPU. If a frame in flight still reads the region you overwrite, the GPU may see the new data. Give each frame in flight a region of its own, at (* (gfx:frame-slot frame) region-size). frame-slot runs from 0 below device-frames-in-flight. :device buffers and textures are written through a staging copy, so this doesn't apply to them.

Uploads

Writes to :device buffers and to textures go through staging memory, and when they happen depends on whether a frame is being recorded:

When What happens
During a frame (between begin-frame and end-frame) The data is copied into the frame's staging ring. The GPU copies it into place before the frame's passes, after earlier frames are done with it. Nothing waits, and in steady state nothing allocates.
Between frames The write waits for the GPU. This suits loading.

Either way, the data you passed may be reused as soon as the call returns.

read-buffer and read-texture always wait for the GPU, and signal an error during a frame. They return bytes, or a vector of the :element-type you ask for, such as single-float. They are for tests, tools and screenshots, not for every frame.

Coordinates

Coordinates follow Vulkan, on every backend:

  • Clip space: x points right, y points down, and depth runs from 0 to 1.
  • Textures, viewports and scissors: pixel (0, 0) is the top left.
  • Texel data for write-texture and from read-texture is laid out in rows from top to bottom, without padding.
  • Tessellation: the domain's origin is the top left, as in Vulkan, so a tessellation evaluation shader's ccw and cw mean what they mean there.

The Vulkan backend runs as Vulkan does. The OpenGL backend, when it comes, will convert, so that a program draws the same on both.

Lifetimes

Object Lives until
A device close-device, or the end of with-device. Closing the device destroys every object still alive.
Objects made from a device, render bundles included Their destroy- function, or the device closing.
A frame end-frame, or the end of with-frame. Frame objects are reused, one per slot.
A render or compute pass end-render-pass or end-compute-pass, or the end of with-render-pass or with-compute-pass. The pass object is reused, so don't keep it.
A surface texture The end of the frame that acquired it.

A bind group, a render bundle and a pipeline refer to other objects. Those objects must outlive them. Destroying a shader module or a bind group layout does not affect the pipelines and bind groups already made from it.

Threads

A device, and everything made from it, belongs to one thread: the render thread. Make every cl-gfx call from that thread. The window system may process its events on another thread.

Allocation

Code that runs every frame allocates nothing in steady state: begin-frame, end-frame, acquire-surface-texture, with-render-pass, with-compute-pass, every draw and dispatch command, write-buffer to an :upload buffer, and writes during a frame. Use with-render-pass rather than begin-render-pass: the macro writes its attachment lists out at compile time, while the function takes lists built at run time.

No graphics API types

No Vulkan type, handle or enum appears in the cl-gfx package, and neither does the backend protocol, which lives in cl-gfx/backend. Formats, usages and modes are keywords; data is specialised Lisp vectors. A program written against cl-gfx will run unchanged on the OpenGL backend.