- Common Lisp 99%
- GLSL 1%
|
All checks were successful
CI / test (push) Successful in 1m47s
To the latest commits of cl-ffi, whose allocate boxes no pointer, and of the libraries that pin it, of Pyssel's step 25. |
||
|---|---|---|
| .forgejo/workflows | ||
| src | ||
| tests | ||
| vulkan | ||
| windowing | ||
| .gitattributes | ||
| .gitignore | ||
| cl-gfx.asd | ||
| CLAUDE.md | ||
| LICENSE | ||
| README.org | ||
| stiftfile | ||
cl-gfx
- Status
- Devices and frames
- Drawing
- Computing
- Resources
- Bind groups
- Surfaces
- Threads
- Errors
- Tests
- Licence
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'discarddoes, 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.