- Common Lisp 100%
|
All checks were successful
CI / test (push) Successful in 3m26s
Pyssel and cl-slug compile their shaders with clsl, which writes SPIR-V itself; nothing uses cl-gsl any more, and the repository is kept, read-only, as a record. |
||
|---|---|---|
| .forgejo/workflows | ||
| ffi | ||
| gmath | ||
| src | ||
| tests | ||
| .gitignore | ||
| cl-gsl-build.asd | ||
| cl-gsl.asd | ||
| LICENSE | ||
| qlfile | ||
| qlfile.lock | ||
| README.org | ||
cl-gsl
- Building
- Shader code
- Compute shaders
- Uniforms from Lisp variables
- Geometry and tessellation
- Macros and other names
- Programs
- Redefinition
- Errors
- Threads
- Status
- Tests
Archived. cl-gsl was Pyssel's shader compiler until 2026-10-15, when clsl replaced it. clsl compiles shader code in Common Lisp to SPIR-V by itself, with no glslang and no C++ toolchain. This repository is kept, read-only, as a record.
Shaders written in Common Lisp. Define shader functions and stages as Lisp, with typed parameters and inferred types everywhere else; cl-gsl checks them, translates them to GLSL and compiles that to SPIR-V for Vulkan.
(asdf:load-system :cl-gsl)
(defpackage #:example
(:use #:cl #:cl-gsl))
(in-package #:example)
(define-shader triangle-vertex :vertex ((position :vec3) (color :vec3))
"Place each vertex where its POSITION says, and pass its COLOR on."
(values (vec4 position 1) color))
(define-function brightness ((color :vec3))
"Return the perceived brightness of the linear COLOR."
(dot color (vec3 0.2126 0.7152 0.0722)))
(define-shader triangle-fragment :fragment ((color :vec3))
"Draw COLOR, dimmed where it is bright."
(vec4 (mix color (* color 0.5) (brightness color)) 1))
(defvar *program*
(compile-program :vertex 'triangle-vertex :fragment 'triangle-fragment))
(program-spirv *program* :vertex) ; => #(119734787 67072 ...), 32-bit words
(program-vertex-inputs *program*)
;; => (#S(VERTEX-INPUT :NAME POSITION :LOCATION 0 :TYPE :VEC3)
;; #S(VERTEX-INPUT :NAME COLOR :LOCATION 1 :TYPE :VEC3))
(write-string (program-glsl *program* :fragment))
prints the GLSL the fragment shader was compiled from:
#version 450
layout(location = 0) in vec3 color;
layout(location = 0) out vec4 fragment_output_0;
float brightness(vec3 color_1) {
return dot(color_1, vec3(0.2126, 0.7152, 0.0722));
}
void main() {
fragment_output_0 = vec4(mix(color, (color * 0.5), brightness(color)), 1.0);
}
cl-gfx takes the SPIR-V as it is:
(gfx:create-render-pipeline
device
:vertex (gfx:create-shader-module device (program-spirv *program* :vertex))
:fragment (gfx:create-shader-module device (program-spirv *program* :fragment))
:vertex-buffers '((:stride 24 :attributes ((0 :float32x3 0) (1 :float32x3 12))))
:color-formats (list (gfx:surface-format surface)))
Building
cl-gsl compiles GLSL to SPIR-V in-process with glslang, through its C interface. Distributions ship glslang as static archives only, so building cl-gsl links them into a shared library of its own, which ASDF keeps with the compiled files and rebuilds when glslang is upgraded. That needs:
- a C++ compiler:
c++, or$CXX; - glslang's development files:
glslang-devon Debian and Ubuntu. If its archives are somewhere the compiler does not look, setGLSLANG_LIBRARY_DIRto their directory.
This is a stopgap. It works where the archives are position-independent code, as Debian's are, and not yet on Windows.
Shader code
A shader stage is defined with define-shader, a shader function with
define-function. Both look like defun with typed parameters. Every other
type is inferred. A definition is checked as soon as it is evaluated. If the
check fails, the error names the definition and the form, and the previous
definition stays.
(define-function lighten ((color :vec3) (amount :float))
(let ((white (vec3 1)))
(mix color white (clamp amount 0 1))))
A function may call one defined later. That gives a forward-reference-warning
warning, and the call is checked again when a program is compiled.
Stages are functions from their inputs to their outputs.
- A
:vertexshader's parameters are its vertex inputs, at locations 0, 1, … in order unless given:location. It returns the clip-space position, a:vec4, then any values for the fragment shader, withvalues. - A
:fragmentshader's parameters receive those values, in the same order and with the same types. Integer inputs are flat. Others take:interpolation :flator:noperspective. It returns one colour per render target, withvaluesif there are several, or calls(discard). - A compute shader,
(:compute :workgroup-size (x y z)), has no inputs and returns nothing; see Compute shaders.
Clip space is Vulkan's, as in cl-gfx: y points down, so the top of the target is at y -1, and depth runs from 0 to 1.
Uniforms come after &uniform in a stage's parameter list. It is
cl-gsl's symbol, written gsl:&uniform through a nickname, as cl-gsl's
other names are.
Uniforms are the same for every vertex or fragment of a draw, and shader
code reads them but cannot assign them:
(define-shader sprite-vertex :vertex ((position :vec2) (uv :vec2)
&uniform (view :mat4 :group :frame))
(values (* view (vec4 position 0 1)) uv))
(define-shader sprite-fragment :fragment ((uv :vec2)
&uniform (tint :vec4)
(image :sampler2d :group :draw))
(* (sample image uv) tint))
Uniforms fall into groups by how often they change, as Godot's uniform sets do, and each group is one bind group, or descriptor set:
| Group | Bind group | For |
|---|---|---|
:frame |
0 | what the engine sets for a frame or pass, such as the camera or the target's size |
:material |
1 | what a material sets; the default |
:draw |
2 | what changes from draw to draw, such as a sprite's texture |
In a group, the uniforms of value types are the members of a uniform
block, laid out by the rules of std140, at binding 0; the resources,
textures, storage buffers and storage images, follow from binding 1. Both
are in the order the stages first declare them, vertex before fragment.
(uniform-group-index group) gives a group's bind group. Two stages that
declare the same uniform share it, and must declare it alike. A shader
function takes textures as parameters; it cannot store one in a variable or
return one.
Types
| Kind | Types |
|---|---|
| Scalars | :bool :int :uint :float :double |
| Vectors | :vec2 to :vec4, :dvec2 to :dvec4, :ivec2 to :ivec4, :uvec2 to :uvec4, :bvec2 to :bvec4 |
| Matrices | :mat2 :mat3 :mat4, and :matCxR with C columns and R rows, such as :mat2x3; :dmat2 and the rest of doubles |
| Numbers | :complex and :dcomplex, complex numbers of floats and doubles; :ratio, a ratio of 32-bit integers |
| Arrays | (:array type length), such as (:array :vec4 8); (:array type) in storage buffers |
| Structs | a name defined with define-struct |
| Textures | :sampler1d :sampler2d :sampler3d :sampler-cube, the -array kinds, :sampler2d-ms, :sampler-buffer, each also i- and u-prefixed for ints and uints, such as :usampler2d; and the -shadow kinds, such as :sampler2d-shadow |
| Storage images | :image2d, :uimage3d, and so on, as the textures, without shadows |
(shader-documentation 'shader-type) lists them all. An integer literal
also fits where a :float or :uint is expected, so (* 2 v) and
(max x 0) need no decimal point. A double literal, 1d0, is a
:double, and fits where a :float is expected only through float.
Numbers
Complex numbers and ratios work as Lisp's do, with its contagion:
(define-function spin ((z :complex) (turns :float))
"Turn Z by TURNS whole turns about the origin."
(* z (cis (* turns 6.2831855))))
(define-function share ((part :int) (whole :int))
"PART of WHOLE as a ratio, exact until it is made a float."
(/ (rational part) whole))
#c(1 2) and 2/3 are literals; complex, realpart, imagpart,
conjugate, phase, cis, numerator, denominator and rational
make and take them apart; +, -, *, /, =, abs, exp, sqrt,
log, expt, sin and their kin take them, and < and floor take
ratios. A :complex is held as a vec2 and a :ratio as an ivec2, so
they pass between stages and live in uniform blocks. Integer / stays
GLSL's truncating division, since a shader's types are fixed: make a ratio
with rational, or write one. Ratios are reduced, and their parts are 32
bits, so they overflow sooner than Lisp's.
Operators
Operators are Common Lisp's where the meaning carries over, and this package's where it is particular to shaders:
| What | Operators |
|---|---|
| Binding and control | let let* progn if when unless cond case and or not the |
| Loops | dotimes, the simple loop, return |
| Values | values multiple-value-bind nth-value multiple-value-setq multiple-value-prog1 multiple-value-call |
| Local definitions | flet labels macrolet symbol-macrolet funcall |
| Assignment | setf setq incf decf; places are variables, (x v), (swizzle v :xy), (aref v i), struct slots |
| Arithmetic | + - * / per component, with linear algebra * for matrices; 1+ 1- abs signum min max expt sqrt isqrt exp log fma |
| Division | floor ceiling truncate round and their f kinds, giving the quotient and remainder; mod rem |
| Comparison | < > <= >= = /= with any number of scalars; two vectors compare per component, which any all not reduce |
| Predicates | zerop plusp minusp evenp oddp float-nan-p float-infinity-p |
| Trigonometry | sin cos tan asin acos atan sinh cosh tanh asinh acosh atanh radians degrees |
| Construction | vec3 ivec2 mat4 ..., float int uint bool, vector make-array, a struct's make- |
| Components | x y z w, (swizzle v :zyx), (aref v i), length, a struct's accessors, slot-value with-slots |
| Geometry | dot cross length distance normalize reflect refract face-forward |
| Interpolation | mix clamp step smoothstep fract inverse-sqrt |
| Matrices | transpose determinant inverse outer-product matrix-component-product |
| Bits | logand logior logxor lognot logtest logbitp logeqv lognand lognor logandc1 logandc2 logorc1 logorc2 ash ldb dpb byte logcount integer-length find-lsb find-msb bitfield-reverse add-carry subtract-borrow multiply-extended |
| Floats | float-bits float-from-bits float-sign decode-float scale-float; pack-unorm-4x8 unpack-half-2x16 and the rest of GLSL's packing |
| Textures | sample fetch gather texture-size texture-levels texture-samples texture-query-lod |
| Storage images | image-load image-store image-size image-samples |
| Derivatives | dfdx dfdy fwidth and their -fine and -coarse kinds; interpolate-at-centroid interpolate-at-sample interpolate-at-offset |
| Stage inputs | (vertex-index) (instance-index) in vertex shaders; (fragment-coord) (front-facing-p) (point-coord) (sample-index) (sample-position) (helper-invocation-p) in fragment shaders |
| Compute | (global-invocation-id) and kin, barrier memory-barrier, atomic-incf atomic-decf atomic-min atomic-max atomic-logand atomic-logior atomic-logxor atomic-exchange compare-and-swap |
| Escape | (glsl type "control" argument ...): GLSL written by hand |
(shader-documentation 'mix) describes any of them, and any function you
define.
Loops and values
Loops are dotimes and the simple loop, and return leaves the
innermost one, giving the loop its value, as in Lisp:
(define-function lowest-set-bit ((bits :uint))
"The index of the lowest bit set in BITS, or -1 if none is."
(dotimes (i 32 -1)
(when (logtest bits (ash (uint 1) i))
(return i))))
(define-function first-square-above ((limit :int))
(let ((n 0))
(loop (when (> (* n n) limit) (return n))
(incf n))))
dotimes evaluates its count once, an :int or :uint, and its variable has
the count's type. A loop gives no value if no return gives one; one that
does gives a value of one type from every return, and a dotimes from its
result form too. loop with keywords, such as (loop for i below n ...), is
not shader code: count with dotimes.
Several values work as in Lisp. A function returns them with values,
multiple-value-bind receives them, and where one value is wanted the first
is used. floor and its kin give the quotient and the remainder:
(define-function polar ((v :vec2))
(values (length v) (atan (y v) (x v))))
(define-function spiral ((v :vec2))
(multiple-value-bind (radius angle) (polar v)
(multiple-value-bind (turns rest) (floor angle 6.2831855)
(+ radius turns rest))))
In GLSL the values after the first are out parameters.
Structs, arrays and local functions
define-struct defines a struct, as defstruct does, with a constructor
taking every slot by keyword and an accessor per slot, which setf assigns:
(define-struct light
"A point light."
(position :vec3)
(color :vec3))
(define-function lit ((l light) (p :vec3))
(/ (light-color l) (+ 1 (distance (light-position l) p))))
(define-shader lights-fragment :fragment ((p :vec3)
&uniform (lights (:array light 4)))
(let ((sum (vec3 0)))
(dotimes (i (length lights))
(incf sum (lit (aref lights i) p)))
(vec4 sum 1)))
Structs and arrays are values, as vectors are: binding or passing one
copies it. vector and (make-array n :initial-element x) make arrays,
aref reads and writes their elements, and length counts them.
struct-layout, layout-size and array-stride say how a uniform block,
by :std140, or a storage buffer, by :std430, lays them out.
A struct of numbers, scalars, vectors and structs of them, also passes from one stage to the next:
(define-struct shade
(color :vec3)
(material :int))
(define-shader shade-vertex :vertex ((position :vec3))
(values (vec4 position 1) (make-shade :color (vec3 1 0 0) :material 2)))
(define-shader shade-fragment :fragment ((shade shade))
(vec4 (* (shade-color shade) (if (= (shade-material shade) 2) 1.0 0.5)) 1))
Each slot is a stage output and input of its own, so each is interpolated
by its type: color smoothly, material flat, as integers must be. A
vertex shader's inputs, which vertex buffers hold, and a fragment shader's
outputs, which render targets take, are not structs.
flet and labels define local functions, which see the variables around
them; one that assigns such a variable changes it outside too, as in Lisp.
macrolet and symbol-macrolet define local macros. case tests a key
against values written in the code.
Textures
sample reads a texture filtered, fetch one texel unfiltered, and
gather the four texels filtering would blend. Keywords choose how:
(sample image uv) ; by the derivatives, in a fragment shader
(sample image uv :level 2) ; from mipmap level 2
(sample image uv :bias 1 :offset (ivec2 1 0))
(sample image uv :ddx (dfdx uv) :ddy (dfdy uv))
(sample shadow-map uv :compare depth) ; a :sampler2d-shadow, giving a :float
(fetch atlas (ivec2 x y) :level 0) ; integer coordinates
(fetch samples (ivec2 x y) :sample 3) ; a multisampled texture
(gather image uv :component 3)
A texel of a :sampler... is a :vec4, of an :isampler... an :ivec4 and
of a :usampler... a :uvec4. cl-gsl knows which texture kinds GLSL reads
each way, and says so when one does not fit, rather than leaving glslang to
reject the GLSL.
Shader code is not Lisp everywhere
Shader code differs from Lisp where GPUs do. Recursion is not allowed, a
variable keeps the type of its initial value, and floor and its kin give
float quotients for floats. Integers are 32 bits and wrap around, and ash
by 32 bits or more is undefined. ash by a constant shifts one way; by a
variable it chooses the way by the sign as the shader runs. Derivatives
are undefined where neighbouring fragments take different branches.
Compute shaders
A compute shader runs in workgroups of the size its stage gives. It has no
inputs: (global-invocation-id) tells its invocations apart. It reads and
writes storage buffers and images, and shares &shared variables across a
workgroup:
(define-struct particle
(position :vec4)
(velocity :vec4))
(define-shader advance (:compute :workgroup-size (64))
(&uniform (particles (:array particle) :storage :read-write)
(counter (:array :uint 1) :storage :read-write)
(picture :image2d :format :rgba8-unorm :access :write)
(dt :float :group :frame))
(let ((i (x (global-invocation-id))))
(when (< i (uint (length particles)))
(let ((p (aref particles i)))
(incf (particle-position p) (* dt (particle-velocity p)))
(setf (aref particles i) p)
(atomic-incf (aref counter 0))
(image-store picture (ivec2 (int i) 0) (particle-position p))))))
(compile-program :compute 'advance)
- A storage buffer is a uniform with
:storage :reador:storage :read-write. It holds any type a block holds, laid out by std430, and may end in an array without a length,(:array type), whoselengthis known as the shader runs. A struct may end in one too. - A storage image is a uniform of an image type, with its
:format, cl-gfx's name such as:rgba8-unormor:r32-uint, and its:access,:read,:writeor:read-write.image-loadandimage-storeread and write it. - Atomics change a place in a storage buffer or shared variable at
once for every invocation, and give the value it held before:
atomic-incf,atomic-max,compare-and-swapand the rest. barrierwaits for the workgroup;memory-barrierand its kin order memory writes.
Vertex and fragment shaders may use storage buffers and images too.
Uniforms from Lisp variables
define-variable defines a special variable that shader code reads
as a uniform of its group. Every stage and function that reads it shares
it, with no parameter for it:
(define-variable *sun* :vec3
:value (vector 0.0 1.0 0.0) :group :frame
:documentation "The direction light comes from.")
(define-function lambert ((normal :vec3))
(max (dot normal *sun*) 0))
(define-shader lit :fragment ((normal :vec3) &uniform (tint :vec4))
(* tint (lambert normal)))
pack-uniforms writes a program's uniform block of one group, laid out by
std140, ready for a uniform buffer: each shader variable from its Lisp
variable's value, and the other uniforms from :values. Given the vector
to write into, it allocates nothing:
(let ((program (compile-program :fragment 'lit)))
(pack-uniforms program :frame) ; *sun*
(pack-uniforms program :material :values (list :tint #(1 0.5 0.5 1))))
pack-value writes one value of any shader type by std140 or std430: a
vector or sequence for a vector, a matrix's components column by column, a
plist of slots for a struct.
Geometry and tessellation
A tessellation control, tessellation evaluation or geometry shader receives every vertex of its patch or primitive: each parameter is an array of the values the stage before passed, one per vertex. The tessellation stages return their vertex's position and values, as a vertex shader does; a geometry shader emits vertices instead:
(define-shader split (:tessellation-control :patch-size 3 :vertices 3) ((color :vec3))
(set-tessellation-levels (vec4 4 4 4 1) (vec2 4 0))
(values (input-position (invocation-id)) (aref color (invocation-id))))
(define-shader place (:tessellation-evaluation :patch-size 3 :domain :triangles)
((color :vec3))
(let ((w (tessellation-coord)))
(values (+ (* (x w) (input-position 0)) (* (y w) (input-position 1))
(* (z w) (input-position 2)))
(+ (* (x w) (aref color 0)) (* (y w) (aref color 1)) (* (z w) (aref color 2))))))
(define-shader outline (:geometry :input :triangles :output :line-strip :max-vertices 4)
((color :vec3))
(dotimes (corner 4)
(emit-vertex (input-position (mod corner 3)) (aref color (mod corner 3))))
(end-primitive))
(compile-program :vertex 'triangle-vertex :tessellation-control 'split
:tessellation-evaluation 'place :geometry 'outline
:fragment 'triangle-fragment)
compile-program checks that each stage takes what the one before passes,
that the patches agree in size, and that a geometry shader after
tessellation takes the primitives tessellation makes.
(program-patch-size program) is the size of the patches to draw.
Macros and other names
define-macro defines a shader macro, as defmacro defines a Lisp
macro. A form naming it is replaced by what its body returns, and the
replacement is checked in its place:
(define-macro squared (x)
"X times itself, evaluated once."
(let ((value (gensym)))
`(let ((,value ,x)) (* ,value ,value))))
define-type-alias makes a symbol another name for a shader type, in
parameter lists and the.
Together they let shader code use a Lisp library's names. Loading
cl-gsl/gmath does this for
cl-gmath:
- Its types become aliases:
gm:vec3for:vec3,gm:quatfor:vec4. - Its vector, matrix and quaternion functions become shader macros, with
the same arguments, less the
resultLisp callers may pass.
Code written with cl-gmath's names is then shader code too:
(define-function lambert ((normal gm:vec3) (light gm:vec3))
(max (gm:vec3-dot (gm:vec3-normalize normal) light) 0))
Where GLSL differs, shader-documentation says so: GLSL's normalize of
a zero vector, and its inverse of a singular matrix, are undefined.
Programs
(compile-program :vertex v :fragment f) checks the stages and every function
they call. It checks that each stage's outputs match the next one's inputs,
generates GLSL for each stage, and compiles it to SPIR-V for Vulkan 1.3.
Either stage may be left out; :tessellation-control,
:tessellation-evaluation and :geometry stages go between them;
(compile-program :compute c) compiles a compute shader alone. The result
is a program:
(program-spirv program stage): the SPIR-V, as(simple-array (unsigned-byte 32) (*)).(program-glsl program stage): the GLSL, for reading.(program-vertex-inputs program): a list ofvertex-inputobjects, each with avertex-input-name,vertex-input-locationandvertex-input-type, to build a vertex buffer layout from. An input of doubles is read as 32-bit unsigned words, which every GPU reads from vertex buffers, as not every GPU reads doubles: auvec4per location and auvec2for an odd double, low word first, from the same bytes the doubles fill. A:dvec3is fed by a:uint32x4at its location and a:uint32x2at the next.(program-workgroup-size program): a compute shader's(x y z).(program-patch-size program): the vertices of each patch tessellation takes, ornil.(program-uniforms program): the members of the groups' uniform blocks, asuniformobjects, with auniform-name,uniform-type,uniform-groupanduniform-offset, in bytes into the group's block;(program-uniform-block-size program group), the size of a buffer that holds one.(program-resources program &key kind): the textures, storage buffers and storage images, or those ofkind(:texture,:storage-bufferor:image), asresourceobjects, with aresource-name,resource-type,resource-group,resource-binding,resource-kind,resource-accessandresource-format.(program-bindings program): the bindings the uniforms need, asprogram-bindingobjects, with aprogram-binding-group,program-binding-set,program-binding-number,program-binding-kindandprogram-binding-stages. A kind is:uniform-block,:storage-buffer,:read-only-storage-buffer, or a texture's or image's type, whichtexture-type-pandimage-type-ptell apart andtexture-type-shapegives the shape of, such as:2d-arrayor:buffer: what a GPU layer such as cl-gfx builds its bind group layouts from.(program-stages program):(:vertex :fragment), or the stages it has.(program-shader-name program stage): the name of the shader it was compiled from forstage.
(dolist (binding (program-bindings *program*))
(format t "~&set ~D, binding ~D: ~S~%" (program-binding-set binding)
(program-binding-number binding) (program-binding-kind binding)))
Redefinition
A program keeps the definitions it was compiled from. Code that holds programs and draws with them, such as a frame loop, can follow redefinitions made at the REPL:
(definitions-generation)grows whenever a shader, shader function, struct or shader variable is defined, redefined or removed. Compare it with the number seen last, and look further only when it has changed. It allocates nothing.(program-stale-p program)is true once something the program was compiled from has been redefined or removed.(recompile-program program)compiles the same stages again from their current definitions and returns a new program. It signalsshader-errorif they no longer fit together, for example when the vertex shader now passes values the fragment shader does not take.
A definition that fails its check is never stored, so a mistake at the REPL leaves programs, and what is drawn with them, as they were.
GLSL that was compiled before is not compiled again. Recompiling an unchanged program costs only the check and the translation.
Errors
shader-error: a mistake in shader code. The message names the definition and the form, and says what would fit, such as the signatures a built-in accepts.undefined-shader-function: a call of something neither built in nor defined. This is ashader-error.glslang-error: glslang rejected the generated GLSL. That is a bug in cl-gsl. The condition carries glslang's log and the GLSL.
All of them are gsl-error conditions.
Threads
Definitions may be made, and programs compiled, from any thread. Compiling
serialises its use of glslang. A program never changes, so it may be shared
between threads.
Status
Every stage: vertex, tessellation, geometry, fragment and compute; GLSL
output, SPIR-V through glslang; programs that follow redefinitions; macros,
local functions, several values, structs and arrays; doubles, complex
numbers and ratios; every GLSL texture kind and way of reading one; storage
buffers and images, atomics and barriers; uniforms from Lisp variables and
a std140 packer; cl-gmath's names through cl-gsl/gmath. Next come uniform
metadata for the inspector, and a plain-OpenGL dialect of the GLSL output.
Tests
qlot install
qlot exec sbcl --non-interactive --eval '(asdf:test-system :cl-gsl)'