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-devicereturns 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:
- Destroying is deferred.
destroy-bufferand the otherdestroy-functions mark the object as destroyed at once, so using it signalsdestroyed-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. - Don't overwrite data still being read. An
:uploadbuffer 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-slotruns from 0 belowdevice-frames-in-flight.:devicebuffers 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-textureand fromread-textureis 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
ccwandcwmean 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.