A portable GPU API for Common Lisp at roughly WebGPU's level: devices, buffers, textures, shaders, pipelines and render passes. Vulkan today, OpenGL 4.5 next.
  • Common Lisp 99%
  • GLSL 1%
Find a file
dxtr 25e5a98614
All checks were successful
CI / test (push) Successful in 1m47s
Move the pins on the workspace's libraries
To the latest commits of cl-ffi, whose allocate boxes no pointer, and
of the libraries that pin it, of Pyssel's step 25.
2026-10-19 21:31:46 +02:00
.forgejo/workflows Run on SBCL only 2026-10-17 04:40:30 +02:00
src Declare the arguments' full domain types 2026-10-18 16:56:34 +02:00
tests Declare the Vulkan enum types where the backend translates 2026-10-18 17:16:00 +02:00
vulkan Declare the Vulkan enum types where the backend translates 2026-10-18 17:16:00 +02:00
windowing Size window surfaces in pixels 2026-10-11 02:23:12 +02:00
.gitattributes Keep LF line endings in every checkout 2026-10-16 15:49:19 +02:00
.gitignore Pin the dependencies with stift in place of qlot 2026-10-16 11:20:50 +02:00
cl-gfx.asd Load the test system on Windows 2026-10-16 21:41:12 +02:00
CLAUDE.md Declare the arguments' full domain types 2026-10-18 16:56:34 +02:00
LICENSE Add cl-gfx: a portable GPU API with a Vulkan backend 2026-10-10 11:13:15 +02:00
README.org Declare the arguments' full domain types 2026-10-18 16:56:34 +02:00
stiftfile Move the pins on the workspace's libraries 2026-10-19 21:31:46 +02:00

cl-gfx

A portable GPU API for Common Lisp, at roughly WebGPU's level: devices, buffers, textures, shaders, pipelines and render passes. Vulkan today, OpenGL 4.5 next; no type of either appears in the API.

(asdf:load-system :cl-gfx/windowing)
(asdf:load-system :cl-windowing/wayland)   ; :cl-windowing/win32 on Windows

(defpackage #:example
  (:use #:cl)
  (:local-nicknames (#:gfx #:cl-gfx) (#:win #:cl-windowing)))
(in-package #:example)

(defun shader (name)
  (asdf:system-relative-pathname "cl-gfx" (format nil "tests/shaders/~A.spv" name)))

(win:with-window-system (window-system)
  (win:with-window (window window-system :title "Triangle" :width 640 :height 480)
    (gfx:with-device (device :present-to window-system)
      (let* ((surface (gfx:create-surface device window))
             (pipeline (gfx:create-render-pipeline
                        device
                        :vertex (gfx:create-shader-module device (shader "triangle.vert"))
                        :fragment (gfx:create-shader-module device (shader "triangle.frag"))
                        ;; Per vertex: a position and a color, three floats each.
                        :vertex-buffers '((:stride 24 :attributes ((0 :float32x3 0)
                                                                   (1 :float32x3 12))))
                        :color-formats (list (gfx:surface-format surface))))
             (vertices (gfx:create-buffer device :size 72 :usage '(:vertex))))
        (gfx:write-buffer vertices (make-array 18 :element-type 'single-float
                                                  :initial-contents '( 0.0 -0.5 0.0  1.0 0.0 0.0
                                                                      -0.5  0.5 0.0  0.0 1.0 0.0
                                                                       0.5  0.5 0.0  0.0 0.0 1.0)))
        (loop repeat 60
              do (win:process-events window-system)
                 (gfx:with-frame (frame device)
                   (let ((texture (gfx:acquire-surface-texture surface frame)))
                     (when texture
                       (gfx:with-render-pass (pass frame :color ((texture :clear '(0 0 0 1))))
                         (gfx:set-pipeline pass pipeline)
                         (gfx:set-vertex-buffer pass 0 vertices)
                         (gfx:draw pass 3))))))
        (gfx:surface-size surface)))))
;; => 640, 480

A real program loops until cl-windowing reports a :close event; see its README.

cl-gfx is a few systems:

  • cl-gfx: the API, which knows nothing about windows or graphics APIs.
  • cl-gfx/vulkan: the Vulkan backend, through cl-vk. It needs Vulkan 1.4, whose drivers have every feature it enables on all devices, such as demoting fragments to helper invocations, which shaders' discard does, and zero-initialized workgroup memory, which compute shaders' shared variables start from: any current GPU driver, or Mesa's lavapipe for a software renderer.
  • cl-gfx/windowing: draw into cl-windowing's windows.

Status

Early work: buffers; textures of every shape, with mipmaps and multisampling; samplers, comparing ones among them; bind groups of uniform, storage and texel buffers, textures and storage textures; render pipelines with tessellation and geometry shaders, and compute pipelines; render and compute passes with barriers per resource; render bundles; presenting; and writing and reading buffers and textures. The OpenGL backend comes next.

Devices and frames

open-device (or with-device) opens a GPU. Give :present-to what its surfaces will present to, such as a cl-windowing window system, or leave it out to render offscreen. :validation t checks every use of the graphics API, through the Vulkan validation layer, and take-validation-messages returns what it found of the program's use of the API, leaving out the loader's notes on the machine, such as the layers other software installed there; :minimum-severity :warning keeps its warnings and errors. :validation-checks '(:synchronization) checks the barriers between commands too.

A GPU may lack some features: device-features lists those the device has, such as :geometry-shaders, :tessellation-shaders, :float64 and :multisample-storage-textures, and what needs one the device lacks signals unsupported-error. That includes a shader module: create-shader-module reads the SPIR-V capabilities and execution modes it declares, and refuses one of doubles, which declares Float64, on a device without :float64, and one whose floats must round to the nearest, as RoundingModeRTE 32 says, on a device without :rounding-mode-rte-float32. Every device has the rest, which Vulkan 1.4 requires, such as cube map arrays and anisotropic filtering.

A frame is recorded between begin-frame and end-frame, or in with-frame, which ends the frame however its body is left: after an error and a restart, what was recorded is submitted and the next frame can begin. with-render-pass likewise always ends its pass. Several frames are in flight at once, two by default and up to eight with :frames-in-flight: recording a frame waits only for the frame that last used its slot, frame-slot, from 0 below device-frames-in-flight. Objects destroyed while a frame may still use them, with destroy-buffer and the like, live on until the GPU is done with them.

Coordinates are Vulkan's on every backend: in clip space x points right, y down and depth runs from 0 to 1; in textures and viewports, pixel (0, 0) is the top left; and a tessellation shader's domain has its origin at the top left, as in Vulkan. The Vulkan backend runs as Vulkan does, and the OpenGL backend will convert.

Drawing

A render pass draws into its attachments:

(gfx:with-render-pass (pass frame :color ((texture :clear '(0 0 0 1)))
                                  :depth (depth-texture :clear 1.0))
  (gfx:set-pipeline pass pipeline)
  (gfx:set-vertex-buffer pass 0 vertices)
  (gfx:set-index-buffer pass indices :uint16)
  (gfx:draw-indexed pass 36))

The draw commands take what most draws leave alone as keywords: (gfx:draw pass 6 :instance-count 100), (gfx:draw-indexed pass 36 :base-vertex 24 :first-instance 3), and (gfx:set-viewport pass 0 0 320 240 :min-depth 0.5).

An attachment without :clear keeps its contents. :level and :layer draw into one mip level and layer of a texture, such as a face of a cube; a multisampled texture names a :resolve texture of one sample, which it is resolved into when the pass ends:

(gfx:with-render-pass (pass frame :color ((samples :clear '(0 0 0 1) :resolve image)))
  ...)

The pipeline then draws as many :samples as the attachments have. A multisampled texture is :2d or :2d-array, of one mip level: a render target, drawn into a layer at a time, and a texture that shaders read a sample at a time, or a storage texture they write so, where the device has :multisample-storage-textures. Recording is cheap: the pass records commands into the frame's command list, and the backend translates the list when the frame ends. Frames allocate nothing in steady state.

A render pipeline may run tessellation and geometry shaders between its vertex and fragment shaders. With tessellation it draws patches:

(gfx:create-render-pipeline device :vertex vertex
                                   :tessellation-control control
                                   :tessellation-evaluation evaluation
                                   :geometry geometry
                                   :fragment fragment
                                   :topology :patch-list :patch-size 3
                                   :color-formats '(:rgba8-unorm))

A pipeline's :blend is :replace, the default; :alpha, which blends colours of straight alpha, as most images store them; or :premultiplied-alpha, which blends colours whose red, green and blue are already multiplied by their alpha, and filters without dark fringes.

A render bundle is recorded once and executed in later passes, for what does not change from frame to frame:

(let ((bundle (gfx:create-render-bundle device)))
  (gfx:record-render-bundle (encoder bundle)
    (gfx:set-pipeline encoder pipeline)
    (gfx:set-vertex-buffer encoder 0 vertices)
    (gfx:draw encoder 3))
  ;; Then, in each frame's pass:
  (gfx:execute-render-bundle pass bundle))

Computing

A compute pass runs a compute pipeline on workgroups:

(let* ((layout (gfx:create-bind-group-layout device '((0 :storage-buffer))))
       (values (gfx:create-buffer device :size (* 4 count) :usage '(:storage :copy-src)))
       (pipeline (gfx:create-compute-pipeline device :module (gfx:create-shader-module device spirv)
                                                     :bind-group-layouts (list layout)))
       (group (gfx:create-bind-group device layout (list (list 0 values)))))
  (gfx:with-frame (frame device)
    (gfx:with-compute-pass (pass frame)
      (gfx:set-pipeline pass pipeline)
      (gfx:set-bind-group pass 0 group)
      (gfx:dispatch pass (ceiling count 64))))
  (gfx:read-buffer values :element-type 'single-float))

Passes run in the order they are recorded, and each sees what the passes before it wrote: a compute pass can fill a buffer that the next render pass draws from, or a storage texture it samples. read-buffer returns a buffer's contents, for tests and tools, as bytes or as a vector of another :element-type: single-float, double-float, or (unsigned-byte 16) or 32; the buffer needs :copy-src.

Resources

Function Makes
create-buffer a buffer of :size bytes, for :usage such as (:vertex) or (:index)
create-texture a texture of :width by :height texels of :format, such as :rgba8-unorm, for :usage such as (:sampled :copy-dst), of a :dimension, with :array-layers, :mip-levels and :samples
create-sampler how shaders read a texture: :filter, :address-mode, :mipmap-filter, :compare and :max-anisotropy
create-shader-module shader code, from a .spv file or a vector of SPIR-V
create-bind-group-layout the shape of a bind group: its bindings and what each holds
create-bind-group buffer ranges and textures for shaders to read and write
create-render-pipeline how to draw: shaders, vertex layout, bind group layouts, formats and fixed-function state
create-compute-pipeline how to compute: a compute shader and its bind group layouts

A buffer's :memory is :device (the default), fast for the GPU and written through a staging copy; or :upload, which write-buffer writes directly. Don't overwrite an :upload buffer's data that a frame in flight still reads: give each frame in flight a region of its own, as (gfx:write-buffer uniforms data :offset (* 256 (gfx:frame-slot frame))) does.

A texture's :dimension is :2d by default; :1d, :3d (with a :depth), :cube, and the arrays :1d-array, :2d-array and :cube-array (with :array-layers) are the others. :mip-levels :all gives it every mip level down to one texel:

(let ((sky (gfx:create-texture device :width 512 :dimension :cube :format :rgba8-srgb
                                      :mip-levels :all :usage '(:sampled :copy-dst))))
  (dotimes (face 6)
    (gfx:write-texture sky (face-texels face) :z face :depth 1))
  (gfx:generate-mipmaps sky))

write-texture writes texels into a texture, or a box of it, from a vector of bytes, or of floats or integers as write-buffer takes them; the texture needs :copy-dst in its :usage. :z and :depth count slices of a 3D texture and layers of the others, six faces to a cube, and :level picks the mip level. generate-mipmaps fills the levels after the first from it. Besides the colour formats such as :rgba8-unorm and the float ones such as :rgba16-float, textures hold unsigned and signed integers, such as :rg16-uint and :r32-sint, which shaders read unfiltered; integer-format-p tells them apart. Depth textures are sampled too, for shadows through a sampler with :compare.

Vertex attributes are floats, :float32 to :float32x4; doubles, :float64 to :float64x4, where the GPU reads them, which not every GPU does; integers, :uint32 to :uint32x4 and :sint32 to :sint32x4; or :unorm8x4. Doubles can be read on any GPU as pairs of :uint32 values and put together in the shader, as clsl's vertex shaders do.

Writes to :device buffers and to textures go through staging memory. During a frame, between begin-frame and end-frame, the data is copied into the frame's staging ring, and the GPU copies it into place before the frame draws, after the frames before it are done with it: nothing waits, and in steady state nothing allocates. Between frames, a write waits for the GPU, which suits loading. read-texture returns a texture's texels, a mip level at a time, as bytes or of an :element-type as read-buffer takes it, for tests and screenshots; the texture needs :copy-src.

Bind groups

Shaders read uniforms and textures through bind groups, as in WebGPU. A bind group layout says what each binding holds; a pipeline is made with the layouts of the groups it reads, by index; a bind group fills a layout's bindings; and set-bind-group makes a pass's draws read it:

(let* ((layout (gfx:create-bind-group-layout device '((0 :uniform-buffer)
                                                      (1 :texture :visibility (:fragment)))))
       (pipeline (gfx:create-render-pipeline device ... :bind-group-layouts (list layout)))
       (uniforms (gfx:create-buffer device :size 64 :usage '(:uniform)))
       (image (gfx:create-texture device :width 2 :height 2 :format :rgba8-srgb
                                         :usage '(:sampled :copy-dst)))
       (group (gfx:create-bind-group device layout
                                     (list (list 0 uniforms)
                                           (list 1 image (gfx:create-sampler device))))))
  (gfx:write-texture image texels)
  ;; In a pass:
  (gfx:set-pipeline pass pipeline)
  (gfx:set-bind-group pass 0 group)
  (gfx:draw pass 6))

A :uniform-buffer binding takes a buffer with :uniform in its usage, and optionally :offset, a multiple of 256, and :size; a :texture binding takes a texture with :sampled in its usage and a sampler. A :storage-buffer binding, which shaders read and write, or a :read-only-storage-buffer, takes a buffer with :storage in its usage, as a uniform buffer is taken; a :storage-texture binding, which shaders read and write texel by texel, takes a texture with :storage in its usage, and no sampler. A :uniform-texel-buffer or :storage-texel-buffer binding takes a buffer with :uniform-texel or :storage-texel in its usage and the :format of its texels, such as (1 table :format :rgba32-float). :visibility names the stages that use a binding, by default every stage the device runs: :vertex, :tessellation-control, :tessellation-evaluation, :geometry, :fragment and :compute. A bind group's layout and resources come from its device. A pipeline reads up to four bind groups. A bind group fits every pipeline made with a layout of the same entries. Setting a pipeline keeps the bind groups set; a render bundle starts with none set, and leaves none set after it.

Each render pass, dispatch and write waits for exactly what it uses: the backend remembers how every buffer and texture was last written and read, and records the barriers a pass needs before it begins. So a texture drawn into in one pass can be sampled in the next, and a dispatch sees what the one before it wrote. A pass may not read a texture it draws into, nor sample and store to one texture; that is refused when it is recorded.

Shaders are SPIR-V, as glslangValidator -V or glslc compile them from GLSL.

Surfaces

create-surface makes a surface for a window (with cl-gfx/windowing loaded). It prefers an sRGB format, which encodes the linear colours shaders write and blends in linear light; with :srgb nil it prefers a plain one, which stores what shaders write and blends that, as image editors and browsers do. surface-format says which it got, and srgb-format-p which kind a format is. Each frame, acquire-surface-texture returns the texture to draw into, which the frame presents when it ends; or nil until the window system has given the window a size, and while the window is minimised. The surface follows the window's size: when the window system proposes one, the next texture has it, and the size is adopted just before that frame presents. A frame may present to several surfaces, such as a window's and its popups'.

destroy-surface waits for the GPU to finish the frames in flight and destroys the surface at once, so the window may be destroyed right after: call it between frames. The wait is for the frames in flight at most, as when a swapchain is made anew for a new size.

Anything can be a surface target: implement surface-target-size, surface-target-pending-size and surface-target-adopt-size for it, and for the Vulkan backend cl-gfx/vulkan:target-vulkan-instance-extensions and cl-gfx/vulkan:target-vulkan-surface.

A backend implements the generic functions of the package cl-gfx/backend, which also holds what it needs from the core, such as the command lists frames record; cl-gfx/vulkan is the example. Programs need only cl-gfx.

Threads

A device, and everything made from it, belongs to one thread, the render thread. The window system may process events on another.

Errors

Errors are conditions under gfx-error, whose message says what went wrong and what to do. unsupported-error means the system lacks something: no GPU with Vulkan 1.4, a device feature a pipeline or shader module needs, or a format or present mode the GPU cannot use. destroyed-error means a destroyed object was used. no-backend-error means the backend asked for, by default :vulkan, is not loaded: load cl-gfx/vulkan. An argument of the wrong type, a usage that is no buffer-usage or texture-usage among them, is a type-error. Every function declares its arguments' types, so SBCL refuses a wrong one on entry, and warns when it compiles a call with a constant of the wrong type.

Tests

stift test

The tests render offscreen on a Vulkan driver, such as Mesa's lavapipe, and, where Wayland is, present to headless Weston. On Debian or Ubuntu: apt install mesa-vulkan-drivers weston vulkan-validationlayers. With the validation layer installed, every device runs under it, its synchronization checks included, and the tests check it reported no error.

Licence

MIT.