A shading language in Common Lisp, compiled to SPIR-V by clsl itself, with no glslang and no foreign code.
  • Common Lisp 100%
Find a file
dxtr 74854b30dd
All checks were successful
CI / test (push) Successful in 2m54s
Keep the programs compile-program compiled
compile-program now gives back the program it compiled before for the
same program and target, while none of the definitions it was compiled
from has changed since and its target was not loaded again, so that
every engine and renderer that draws with a program compiles it once.
The programs are kept in the registry, beside the definitions they were
compiled from, so clsl still has one global variable: a cache keyed by
content, which the workspace's rules allow.  Keeping a program forgets
the programs that are no longer current, so the cache holds no more than
the live programs.  A stale program is never given back: compiling it
again says why it no longer compiles.  forget-compiled-programs, new,
has the next calls compile afresh, as after a change to clsl's own
compiler, which program-stale-p does not see.

For Pyssel's step 25, as Kim decided on 2026-10-19 (see clsl.org,
Decisions).
2026-10-19 21:31:57 +02:00
.forgejo/workflows Run on SBCL only 2026-10-17 04:43:42 +02:00
doc Correct seven statements of clsl's documents, and refuse unknown uniforms 2026-10-18 19:27:51 +02:00
generator Drop the % prefix from internal names 2026-10-18 18:09:11 +02:00
gmath Correct seven statements of clsl's documents, and refuse unknown uniforms 2026-10-18 19:27:51 +02:00
grammar Add clsl's foundation: SPIR-V tables, builder, reader and disassembler 2026-10-13 13:59:49 +02:00
spirv Drop the % prefix from internal names 2026-10-18 18:09:11 +02:00
src Keep the programs compile-program compiled 2026-10-19 21:31:57 +02:00
tests Keep the programs compile-program compiled 2026-10-19 21:31:57 +02:00
vulkan Drop the % prefix from internal names 2026-10-18 18:09:11 +02:00
.gitattributes Keep LF line endings in every checkout 2026-10-16 15:49:23 +02:00
.gitignore Pin the dependencies with stift in place of qlot 2026-10-16 11:32:07 +02:00
CLAUDE.md Point the CLAUDE.md at the wiki 2026-10-18 19:19:20 +02:00
clsl.asd Correct the README's status and clsl/vulkan's Vulkan version 2026-10-18 18:28:07 +02:00
LICENSE Add clsl's foundation: SPIR-V tables, builder, reader and disassembler 2026-10-13 13:59:49 +02:00
README.org Keep the programs compile-program compiled 2026-10-19 21:31:57 +02:00
stiftfile Move the pins on the workspace's libraries 2026-10-19 21:31:57 +02:00

clsl

A shading language in Common Lisp, compiled to SPIR-V by clsl itself: no glslang, no C or C++ toolchain and no foreign library, on Linux and Windows alike. It replaces cl-gsl as the shader compiler of the Pyssel engine.

(asdf:load-system :clsl/vulkan)

(defpackage #:example
  (:use #:cl #:clsl))
(in-package #:example)

(define-shader triangle-vertex :vertex ((position :vec2) (color :vec3))
  "Place each vertex at its POSITION, and pass its COLOR on."
  (values (vec4 position 0 1) color))

(define-shader triangle-fragment :fragment ((color :vec3) &uniform (tint :vec4))
  "Draw the COLOR the vertices pass, times TINT."
  (* (vec4 color 1) tint))

(define-program triangle
  "A triangle in its vertices' colours."
  :vertex triangle-vertex :fragment triangle-fragment)

(let ((program (compile-program 'triangle :target :vulkan)))
  (values
   ;; Each stage's SPIR-V, which cl-gfx's CREATE-SHADER-MODULE takes.
   (program-spirv program :vertex)
   ;; What the pipeline needs to know: the vertex inputs and the bindings.
   (program-vertex-inputs program)
   (program-bindings program)
   ;; The bytes of the :MATERIAL group's uniform block, for its buffer.
   (pack-uniforms program :material :values (list :tint #(1.0 0.5 0.5 1.0)))))

returns

#(119734787 67072 0 22 0 131089 1 196622 0 1 589839 0 1 1852399981 ...)
(#S(VERTEX-INPUT :NAME POSITION :LOCATION 0 :TYPE :VEC2 :FORMATS (:FLOAT32X2))
 #S(VERTEX-INPUT :NAME COLOR :LOCATION 1 :TYPE :VEC3 :FORMATS (:FLOAT32X3)))
(#S(PROGRAM-BINDING :GROUP :MATERIAL :NUMBER 0 :KIND :UNIFORM-BLOCK :STAGES (:FRAGMENT)))
#(0 0 128 63 0 0 0 63 0 0 0 63 0 0 128 63)

cl-gfx draws with it. Given a cl-gfx device and the surface it presents to, the program gives each stage's SPIR-V, the layout of a vertex buffer that feeds its inputs, and the entries of its bind group layouts, in cl-gfx's terms:

(let* ((program (compile-program 'triangle :target :vulkan))
       (vertex (gfx:create-shader-module device (program-spirv program :vertex)))
       (fragment (gfx:create-shader-module device (program-spirv program :fragment))))
  (prog1 (gfx:create-render-pipeline
          device :vertex vertex :fragment fragment
          :vertex-buffers (program-vertex-buffers program)
          :bind-group-layouts (mapcar (lambda (entries)
                                        (gfx:create-bind-group-layout device entries))
                                      (program-layout-entries program))
          :color-formats (list (gfx:surface-format surface)))
    (gfx:destroy-shader-module vertex)
    (gfx:destroy-shader-module fragment)))

The vertex buffer holds each vertex's position and color side by side, ((:stride 20 :attributes ((0 :float32x2 0) (1 :float32x3 8)) :step :vertex)), and the bytes pack-uniforms returned go in a uniform buffer, bound at binding 0 of the :material group's bind group, 1. program-features lists the optional device features a program needs, none for this one.

Shader code is Common Lisp. On numbers every operator means what it means in Lisp, and what a GPU cannot do as Lisp does is refused when the definition is evaluated, with a message that says what to write instead:

(define-function half ((n :int))
  (/ n 2))

signals

In HALF: / of integers is a ratio in Lisp, and clsl has no ratios: divide with
TRUNCATE, FLOOR, CEILING or ROUND, which give integer quotients, or make the
integers floats with FLOAT.
  in: (/ N 2)

Shader code

The language is Common Lisp's, where a GPU can give Lisp's meaning. doc/language.org is its reference, which describes every form and built-in. In short:

  • Types are written where values enter, in parameter lists, the and type declarations, and inferred everywhere else: :bool; :int and :uint, of 32 bits, which wrap; :float and :double; the vectors :vec2 to :vec4, :dvec2, :ivec2, :uvec3, :bvec4 and so on; the matrices of floats :mat2 to :mat4, and :mat2x3, of two columns of three, and so on, and of doubles, :dmat4 and so on; the complex numbers :complex and :dcomplex, Lisp's (complex single-float) and (complex double-float); the float pairs :dfloat and :dfvec2 to :dfvec4; the textures, of one, two and three dimensions, cubes, arrays, multisampled, buffers and shadow textures, such as :sampler2d, :isampler3d, :usampler-cube-array, :sampler2d-ms, :sampler-buffer and :sampler2d-shadow; the storage images of the same shapes, such as :image2d and :uimage3d; the structs of define-struct; (:array type length); and (:array type), whose length a storage buffer gives. define-type-alias names a type.
  • A number written in the code fits any type that holds it exactly: 2 and 1/2 fit a :float, and 16777217 does not; 1.5d0 and pi are :double values, which fit doubles, as in Lisp a double makes a double of a single-float, and float pairs, as the pair nearest them; #c(1.0 2.0) is a :complex. Nothing else converts silently: (+ x i) of a :float and an :int is refused, and (float i) converts; (float x 1d0) makes a :float a :double. A real meets a complex number of its kind as Lisp's contagion has it: (* 2.0 z).
  • Arithmetic is Lisp's on scalars, and extends component by component to vectors, a scalar going with each component, and to matrices as linear algebra does: (* matrix vector). / divides floats: in Lisp (/ 7 2) is 7/2, so truncate, floor, ceiling and round divide integers, and turn floats into integers, (floor 2.5) being the :int 2 and the remainder 0.5; ffloor and its kin give floats. mod and rem, min, max, abs, signum, expt, sqrt, isqrt, exp, log, the trigonometric and hyperbolic functions, decode-float, float-sign, scale-float, and 1+, 1-, zerop, plusp, minusp, evenp and oddp are Lisp's too. GPUs compute trigonometry, exponentials and logarithms of :float values only, so these refuse doubles. Doubles are slow on GPUs made for games, and many GPUs have none: the language reference says when to use them.
  • Complex numbers are made by complex and taken apart by realpart, imagpart, conjugate, phase and abs; cis, their arithmetic and their functions, from sqrt and exp to atanh, are Lisp's, with its branch cuts, written in clsl's own language and included once in each module that calls them. A :dcomplex has the arithmetic, abs, signum and sqrt.
  • Float pairs, :dfloat, hold a float and the float nearest what it misses, about 14 significant digits at the speed of floats on every GPU: a position 10,000 km from the origin, which a float holds to a metre, a pair holds to well under a micrometre. (dfloat x) makes one, (float p) gives the float nearest one, and (dfvec3 ...) a vector of them. They have the arithmetic, sqrt, comparisons, min, max, abs and the rounding functions, clsl's own shader functions, whose operations no driver may fuse or reorder, in modules that ask the GPU to round floats to the nearest; in Lisp, a pair is the double-float it holds.
  • The GPU's vocabulary names what Lisp has no name for: dot, cross, norm, distance, normalize, reflect, refract, face-forward, mix, clamp, smoothstep, fma, inverse-sqrt, transpose, determinant, inverse, outer-product and matrix-component-product; add-carry, subtract-borrow, multiply-extended, find-lsb and bitfield-reverse of integers; float-nan-p and float-infinity-p; pack-unorm-4x8 and the other packings of floats into integers, and their unpack- kin; pack-double-2x32 and unpack-double-2x32, a double's two words; and interpolate-at-centroid, interpolate-at-sample and interpolate-at-offset of a fragment shader's inputs.
  • Comparisons take numbers and give one boolean. Vectors compare as Lisp compares sequences: (every #'< a b), (some #'zerop v), (notany ...), (notevery ...), and (map :bvec3 #'< a b) component by component. length counts a vector's components, as in Lisp; norm is the Euclidean length.
  • Vectors and matrices are made by their constructors, (vec4 position 0 1) and (mat3 1), and read by x, y, z, w, (swizzle v :zyx) and aref, which are places.
  • Structs and arrays are made by a struct's constructor, (make-light :position p :color c :radius 2), by vector and by make-array, read by a struct's accessors, slot-value, with-slots and aref, which are places, and copied by a struct's copier and copy-seq. They are values, as vectors are: binding, assigning, passing and returning one copies it, where Lisp's would be one object, so a change through one variable is not seen through another.
  • Several values are made by values, and by truncate and its kin, and taken by multiple-value-bind, nth-value, multiple-value-setq, (setf (values ...)), multiple-value-prog1 and multiple-value-call; where one value is wanted, the first is.
  • Bits are Lisp's, on 32 bits: logand and its kin, logtest, logbitp, logcount, integer-length, ash, and ldb, dpb and their kin with (byte size position); float-bits and float-from-bits give a float's bits and make one of them.
  • Derivatives, in fragment shaders, are dfdx, dfdy and fwidth, and their -fine and -coarse kinds. The fragments of a quad compute them together, so they need uniform control flow, which clsl checks when a shader is defined, as WGSL does.
  • Textures are read by sample, through their samplers, with :level, :bias, :ddx and :ddy, :offset and, of a shadow texture, :compare; by fetch, a texel as it is, with :level, :sample and :offset; and by gather, a component of four texels; texture-size, texture-levels, texture-samples and texture-query-lod measure them. Storage images are read and written a texel at a time by image-load and image-store.
  • Compute shaders share variables across a workgroup, after &shared, which barrier and the memory barriers order, and change integers that invocations share with atomic-incf, atomic-max, compare-and-swap and their kin.
  • The special forms are let, let*, progn, locally, if, when, unless, cond, case, and, or, the, values, dotimes, loop and return, with declare of type, optimize, derivative-uniformity, ignore and ignorable; setq, setf, incf and decf, of variables, components, elements, slots, bytes and the places macros stand for, each place evaluated once; flet, labels, funcall and lambda, whose functions see and assign the variables around them; and macrolet and symbol-macrolet. define-macro defines a shader macro.
(define-shader tile-map :fragment ((uv :vec2) &uniform (tiles :usampler2d))
  "Draw TILES, a texture of one tile a texel, stretched over UV: each tile
in the colour its low three bytes give, with dark lines a pixel wide
between the tiles."
  (let* ((cell (* uv (float (texture-size tiles))))
         (line (fwidth cell))
         (tile (x (fetch tiles (truncate cell)))))
    (if (some #'< (mod cell 1.0) line)
        (vec4 0.1 0.1 0.1 1)
        (let ((red (ldb (byte 8 16) tile))
              (green (ldb (byte 8 8) tile))
              (blue (ldb (byte 8 0) tile)))
          (vec4 (/ (float (uvec3 red green blue)) 255) 1)))))

fwidth is computed before the if, where every fragment computes it: in a branch that some fragments of a quad take and others do not, it is refused.

Complex numbers are numbers, as in Lisp:

(define-shader mandelbrot :fragment ((uv :vec2))
  "Shade UV, from 0 to 1, by how soon the Mandelbrot set's iteration escapes
there, and black where it does not."
  (let ((c (complex (- (* 3 (x uv)) 2) (- (* 2 (y uv)) 1)))
        (z (complex 0.0)))
    (dotimes (i 100 (vec4 0 0 0 1))
      (setf z (+ (* z z) c))
      (when (> (abs z) 2)
        (return (vec4 (vec3 (/ (float i) 100)) 1))))))

At the default safety every operation has a defined result, where SPIR-V leaves some undefined:

Case Result
An integer divided by zero, or the most negative :int by -1, in truncate, floor, ceiling, round, mod and rem the quotient is the dividend, and the remainder 0
+, - and * of integers overflowing they wrap
An index out of range, into a vector, a matrix or an array a read gives zero, and a write does nothing
A float converted to an integer it does not fit the nearest integer that fits
isqrt of a negative :int 0
decode-float of an infinity or a NaN its magnitude, the exponent 0, and its sign
A NaN converted to an integer 0
ash by 32 bits or more, and a byte of ldb and its kin past bit 31 as in Lisp: an :int's sign bits, or zeros
A texel, a level, a layer or a sample outside a texture, in fetch zero
A texel or a sample outside a storage image image-load gives zero, and image-store writes nothing
An element out of range, in an atomic nothing changes, and it gives 0

A constant division by zero, or a constant index out of range, is refused when the definition is evaluated. (declare (optimize (safety 0))) leaves out the guards that give these results, where a measurement shows that they cost too much. A float function keeps its type, and gives what the GPU gives outside its domain, usually a NaN or an infinity: (sqrt -1.0) is a NaN, where Lisp's is a complex number.

Definitions

define-function, define-shader, define-struct, define-macro, define-type-alias, define-uniform and define-program make the parts of programs. Each is checked when it is evaluated: an error names the definition and the form at fault, and the definition before it stays.

(define-function brightness ((color :vec3))
  "The perceived brightness of the linear COLOR."
  (dot color (vec3 0.2126 0.7152 0.0722)))

A shader function's parameters are (name type), a type of a value or a texture, and its body is shader code, after a documentation string and declarations; its last form gives its value, whose type is inferred, or several with values. It may call a function not defined yet, which signals a forward-reference-warning and is checked when a program is compiled. No shader code recurses. remove-definition removes a definition of any kind.

define-struct defines a struct as defstruct does, with its constructor, copier and accessors; define-macro a shader macro, as defmacro defines a macro; and define-type-alias another name of a type:

(define-struct light
  "A point light."
  (position :vec3)
  (color :vec3)
  (radius :float))

(define-shader lit-surface :fragment ((point :vec3) (normal :vec3)
                                      &uniform (lights (:array light 4)))
  "Light the surface at POINT, facing NORMAL, by the four LIGHTS."
  (let ((total (vec3 0)))
    (flet ((shine ((light light))
             (with-slots (position color radius) light
               (let* ((to-light (- position point))
                      (falloff (max 0 (- 1 (/ (norm to-light) radius)))))
                 (incf total (* color falloff (max 0 (dot normal (normalize to-light)))))))))
      (dotimes (i 4)
        (shine (aref lights i))))
    (vec4 total 1)))

A uniform block holds the lights, laid out by std140, and pack-uniforms takes each as a plist of its slots, as the constructor takes them:

(pack-uniforms (compile-program '(:fragment lit-surface) :target :vulkan) :material
               :values (list :lights (loop for x in '(-4.0 -1.0 1.0 4.0)
                                           collect (list :position (vector x 2.0 0.0)
                                                         :color #(1.0 0.9 0.8)
                                                         :radius 6.0))))

Stages

define-shader defines a shader, a stage of a program:

  • :vertex: its inputs, (name type [:location n]), come from vertex buffers, at locations 0, 1 and so on unless given another, and it gives the position in clip space, a :vec4, which is Vulkan's on every target, y down and depth from 0 to 1, then the values the next stage takes, by position. Doubles come from a vertex buffer as their 32-bit words, which every GPU reads, three or four doubles taking two locations, and float pairs as two float attributes, the high parts then the low: vertex-input-formats says how each input is read.
  • (:tessellation-control :patch-size n :vertices n): it takes patches of :patch-size vertices and makes patches of :vertices, from 1 to 32 each, and runs once for each vertex it makes, which (invocation-id) numbers. Each input is (name (:array type n)), a value for each vertex of the patch, n its :patch-size, and it gives its own vertex's position and values, as a vertex shader does. (set-tessellation-levels outer inner) says how finely the tessellator divides the patch. The levels are the whole patch's, so every invocation of the patch sets the same ones: clsl refuses a call that some invocations of a patch may skip, and levels that may differ between them, as those computed from (invocation-id) do. Levels the shader leaves unset are 0, which discards the patch, and a shader that never sets them is refused.
  • (:tessellation-evaluation :patch-size n [:domain d] [:spacing s] [:winding w] [:point-mode b]): it takes those patches, its :patch-size the control shader's :vertices, its inputs arrays as the control shader's are, and places each vertex the tessellator makes, at (tessellation-coord) in its :domain, :triangles (the default), :quads or :isolines, giving a position and values. :spacing is :equal (the default), :fractional-even or :fractional-odd; :winding, :counter-clockwise (the default) or :clockwise, is the order the corners of its triangles run in the domain, whose origin is its top left, as Vulkan's is; and :point-mode t makes points instead.
  • (:geometry :input p :output p :max-vertices n [:invocations n]): it takes primitives of its :input, :points, :lines, :lines-adjacency, :triangles or :triangles-adjacency, each input an array of a value for each of their vertices, and makes strips of its :output, :points, :line-strip or :triangle-strip. (emit-vertex position value ...) emits a vertex, passing the values on, and (end-primitive) ends a strip; at the default safety, a vertex past :max-vertices is not emitted. It runs :invocations times for each primitive, once by default.
  • :fragment: its inputs, (name type [:interpolation i] [:sampling s]), take those values, interpolated :smooth, the default, :linear or :flat, at the pixel's :center, the default, the primitive's :centroid or each :sample; integers, doubles and float pairs are :flat. A struct passes whole, each slot interpolated as define-struct says. It gives a value for each render target, or none, and (discard) makes it draw nowhere.
  • (:compute :workgroup-size (x [y [z]])): it has no inputs, gives nothing, and writes storage buffers and storage images. The invocations of a workgroup share its variables after &shared.

The built-in inputs are functions of no arguments: vertex-index and instance-index in vertex shaders; invocation-id in tessellation control and geometry shaders; primitive-id in tessellation, geometry and fragment shaders, where it needs the device feature of geometry shaders; tessellation-coord in tessellation evaluation shaders; fragment-coord, front-facing-p, helper-invocation-p, point-coord, sample-index and sample-position in fragment shaders; and global-invocation-id, local-invocation-id, local-invocation-index, workgroup-id, workgroup-count and workgroup-size in compute shaders. (input-position index) reads the position the stage before gave a vertex of the patch or primitive, in tessellation and geometry shaders. One used in another stage, by the shader or a function it calls, is refused.

(define-shader scale-numbers (:compute :workgroup-size (64))
    (&uniform (numbers (:array :float) :storage :read-write)
              (factor :float))
  (let ((i (x (global-invocation-id))))
    (when (< i (length numbers))
      (setf (aref numbers i) (* factor (aref numbers i))))))

(define-shader outline (:geometry :input :triangles :output :line-strip :max-vertices 4)
    ((color (:array :vec3 3)))
  "Draw the edges of each triangle, back to its first corner."
  (dotimes (corner 4)
    (let ((i (mod corner 3)))
      (emit-vertex (input-position i) (aref color i)))))

Uniforms, textures, storage images and storage buffers

After &uniform, a shader's parameter list names what it reads from the program's bind groups, (name type [:group group] [:storage access] [:format format] [:access access]):

  • a scalar, vector or matrix of numbers, or a struct or an array of a length of them, is a member of its group's uniform block, laid out by std140;
  • a texture type, such as :sampler2d or :usampler-cube-array, is a texture with its sampler, read with sample, fetch and gather. A texture is no value: shader code passes it to these, or to a shader function, as it is;
  • a storage image type, such as :image2d, with the :format of its texels, such as :rgba8-unorm, is a storage image, read and written with image-load and image-store as its :access allows: :read, :write, or :read-write, the default;
  • with :storage :read or :storage :read-write, a scalar, vector, matrix, struct or array is a storage buffer, laid out by std430: an (:array type), or a struct whose last slot is one, has as many elements as the buffer has room for, which length counts as the shader runs.
(define-shader shaded-surface :fragment ((uv :vec2) (shadow-point :vec3)
                                         &uniform (albedo :sampler2d)
                                                  (shadow-map :sampler2d-shadow))
  "The ALBEDO texture at UV, darker where SHADOW-MAP's depths hide
SHADOW-POINT, the surface seen from the light, its depth third."
  (let ((lit (sample shadow-map (swizzle shadow-point :xy) :compare (z shadow-point))))
    (* (sample albedo uv) (+ 0.25 (* 0.75 lit)))))

(define-shader blur-rows (:compute :workgroup-size (64))
    (&uniform (source :image2d :format :rgba16-float :access :read)
              (target :image2d :format :rgba16-float :access :write))
  "Average each texel of SOURCE with its neighbours left and right into
TARGET; the texels past the edges read zero."
  (let ((texel (int (swizzle (global-invocation-id) :xy))))
    (image-store target texel (/ (+ (image-load source (- texel (ivec2 1 0)))
                                    (image-load source texel)
                                    (image-load source (+ texel (ivec2 1 0))))
                                 3))))

The groups are :frame, bind group 0, for what changes once a frame; :material, bind group 1 and the default; and :draw, bind group 2. A group's uniform block is its binding 0, and its textures, storage images and storage buffers follow. Each stage's come in turn, the vertex shader's first: the uniforms after its &uniform, in order, then those of define-uniform that it, or a function it calls, reads. Two stages that read a uniform of one name declare it alike.

A uniform that several shaders read is defined once with define-uniform, a special variable whose value pack-uniforms reads. Any shader or shader function may read it, and a shader has the ones it reads, or the functions it calls read, among its uniforms:

(define-uniform *sun* :vec3
  :value (vector 0.0 1.0 0.0) :group :frame
  :documentation "The direction the sun shines from.")

(define-function lit ((color :vec3) (normal :vec3))
  (* color (max (dot normal *sun*) 0)))

Shared variables, barriers and atomics

After &shared, a compute shader's parameter list names the variables its workgroup shares, (name type). They start at zero, in each workgroup. barrier waits until every invocation of the workgroup reaches it, so that each sees what the others wrote before it; it needs uniform control flow, and one that some invocations may not reach is refused. memory-barrier, memory-barrier-shared, memory-barrier-buffer, memory-barrier-image and group-memory-barrier order an invocation's reads and writes. The atomics change an :int or a :uint of a storage buffer or a shared variable in one step, and give what it held: atomic-incf, atomic-decf, atomic-min, atomic-max, atomic-logand, atomic-logior, atomic-logxor, atomic-exchange and compare-and-swap.

(define-shader histogram (:compute :workgroup-size (64))
    (&uniform (numbers (:array :uint) :storage :read)
              (bins (:array :uint 16) :storage :read-write)
     &shared (counts (:array :uint 16)))
  "Count NUMBERS into BINS by their low four bits, each workgroup into its
COUNTS first."
  (let ((i (x (global-invocation-id))))
    (when (< i (length numbers))
      (atomic-incf (aref counts (ldb (byte 4 0) (aref numbers i)))))
    (barrier)
    (let ((bin (local-invocation-index)))
      (when (< bin 16)
        (atomic-incf (aref bins bin) (aref counts bin))))))

Shared variables start at zero through Vulkan's shaderZeroInitializeWorkgroupMemory, which every Vulkan 1.4 device has and must enable: cl-gfx does.

Programs

define-program names the stages of a program, a vertex shader and a fragment shader, with tessellation control and evaluation shaders, both or neither, and a geometry shader between them; or a compute shader alone. It checks them against each other now: each takes what the one before gives, a tessellation evaluation shader the patches the control shader makes and a geometry shader after tessellation the primitives it makes, and they declare their uniforms alike. compile-program compiles a program, by its name or its stages, for a target:

(compile-program 'triangle :target :vulkan)
(compile-program '(:vertex triangle-vertex :fragment triangle-fragment) :target :vulkan)

The image keeps the programs it compiles: until a definition a program was compiled from changes, the same program and target give back the program compiled before, so that every engine and renderer that draws with a program compiles it once. forget-compiled-programs forgets them, so that the next calls compile afresh, as after a change to clsl's own compiler.

A compiled program never changes, and may be shared between threads. It tells what a pipeline needs:

  • program-target, the target's name, as compile-program took it; program-stages, program-shader-name, program-spirv, program-workgroup-size, and program-patch-size, how many vertices each patch of a program with tessellation has, as a pipeline draws them;
  • program-features: the optional features of a device the program needs, named as cl-gfx's device-features names them, so that it runs where (subsetp (program-features program) (gfx:device-features device)): :geometry-shaders, :float64 for doubles, :vertex-stores for storage a stage before the fragment shader writes, the float controls' :rounding-mode-rte-float32 and its kin, and the others its documentation lists;
  • program-vertex-buffers: the layout of one vertex buffer that feeds every input, side by side in the order of their locations, a vertex at a time or, with :step :instance, an instance at a time, as cl-gfx's create-render-pipeline takes :vertex-buffers;
  • program-vertex-inputs: each vertex-input's vertex-input-name, vertex-input-location, vertex-input-type, and vertex-input-formats, the formats of the vertex attributes it is read from, one for each location, named as cl-gfx's vertex formats are: (:float32x3) for a :vec3, (:uint32x4 :uint32x2) for a :dvec3, whose doubles are read as their words, and (:float32x3 :float32x3) for a :dfvec3, whose pairs are read as their high parts, then their low parts;
  • program-uniforms: each uniform's uniform-name, uniform-type, uniform-group, uniform-offset in its block, and its uniform-documentation and uniform-metadata from define-uniform; and program-uniform-block-size of each group;
  • program-resources: each texture, storage image and storage buffer, a resource, with its resource-name, resource-type, resource-group, resource-binding, resource-kind, :texture, :image or :storage-buffer, resource-access, and a storage image's resource-format;
  • program-layout-entries: for each bind group, by index from 0, the entries of its layout, as cl-gfx's create-bind-group-layout takes them, in its binding kinds, :uniform-buffer, :texture, :storage-texture and the others, a group the program reads nothing from having none;
  • program-bindings: every program-binding of every group, with its program-binding-group, program-binding-number, program-binding-kind, :uniform-block, :storage-buffer, :read-only-storage-buffer, or the type of a texture or storage image, such as :sampler2d or :image2d, and program-binding-stages, as a bind group layout lists them. texture-type-p and image-type-p tell a texture's type and a storage image's apart, and texture-type-shape gives the shape of the texture to bind, in cl-gfx's dimensions or :buffer for a texel buffer, and whether it is multisampled or holds depths:
(texture-type-shape :usampler2d-ms-array)  ; => :2D-ARRAY, T, NIL
(texture-type-shape :image-buffer)         ; => :BUFFER, NIL, NIL

pack-uniforms writes a group's uniform block: each uniform of define-uniform from its variable's value, as the thread that packs sees it, and the others from :values. Given a :buffer, it allocates nothing, so it may run every frame. pack-value writes one value of a type, a number, a vector or matrix as a sequence of its components, column by column, an array as a sequence of its elements, or a struct as a plist of its slots' values, by std140 or std430; layout-size, layout-alignment and array-stride say how much room a type takes, and struct-layout where each slot of a struct is. Both write a float pair as the pair nearest a real, and dfloat-parts gives its two floats, as a vertex buffer gives them:

(dfloat-parts 6371000.123456789d0)  ; => 6371000.0, 0.12345679

program-ir returns the IR a stage became, and disassemble-program prints its SPIR-V as assembly. shader-documentation returns the documentation of a definition, a struct's function, a built-in, a type or shader-type, which lists the types.

Redefinition

Every definition changes definitions-generation, which costs nothing to read. program-stale-p says whether a compiled program's definitions have changed since, and recompile-program compiles it again as they are now:

(when (program-stale-p program)
  (setf program (recompile-program program)))

A redefinition that breaks a program define-program defined is made, so that the other stages can follow, and signals a program-warning, which names the program and holds the error.

Errors

Every error clsl signals is a clsl-error, whose message, clsl-error-message, says what went wrong and what to do about it. A mistake in shader code, or in stages that do not fit together, is a shader-error: shader-error-definition names the definition at fault, and shader-error-form is the form. A call of a function no one defined is an undefined-shader-function, of undefined-shader-function-name. no-target-error names the system that defines its no-target-error-target, a target not loaded; unsupported-error says what a target cannot express, unsupported-error-feature, and unsupported-error-form is the form that needs it, or NIL when the module as a whole does; and internal-error is a bug in clsl, whose internal-error-ir is the IR's external form, for the report. The SPIR-V layer signals spirv-error, and the IR ir-error.

forward-reference-warning warns of a call of a function not defined yet: its forward-reference-warning-definition calls its forward-reference-warning-name. program-warning warns of a redefinition that breaks program-warning-program, and program-warning-error is the error compiling it would signal. uniformity-warning warns of a derivative declared a warning that may be computed where control flow is not uniform: its uniformity-warning-form in its uniformity-warning-definition, for the reason uniformity-warning-message gives.

Threads

Definitions may be made, removed and read in any thread: each is made under a lock, and a compilation reads them as they were when it started, so programs may be compiled in several threads at once. The definitions are the one global state clsl keeps, a registry of what define-function and its kin define, which exists once per image by nature. A compiled program never changes, and may be shared between threads; pack-uniforms writes into a buffer of the caller's, which belongs to one thread at a time.

The grammar's tables, the IR's operation table and targets never change, and may be shared between threads. A SPIR-V module, an IR module and everything in it, and a builder, may be used by one thread at a time. Modules may be translated in several threads at once, each its own.

Systems

System What
clsl The language, its definitions and programs, and clsl's conditions, in the package CLSL; and the IR, CLSL/IR
clsl/spirv SPIR-V, CLSL/SPIRV: the grammar's tables, the module builder, the reader and the disassembler; and the back end, CLSL/SPIRV-BACK-END, which translates the IR into SPIR-V
clsl/vulkan The :vulkan target: SPIR-V 1.6 under Vulkan 1.4's rules
clsl/gmath cl-gmath's types and functions in shader code, meaning what they mean in Lisp
clsl/generator Writes spirv/grammar.lisp from the grammar files in grammar/
clsl/test The test suite

clsl/glsl, which prints GLSL, and clsl/opengl, for OpenGL 4.5 and later, come later.

Loading clsl/gmath makes cl-gmath's types type aliases, as cl-gmath:vec3 of :vec3 and a quaternion a :vec4, and its functions shader functions and shader macros of their names, which compute what they compute in Lisp:

(asdf:load-system :clsl/gmath)

(define-function lambert ((normal cl-gmath:vec3) (light cl-gmath:vec3))
  (max (cl-gmath:vec3-dot (cl-gmath:vec3-normalize normal) light) 0))

A function takes the arguments it takes in Lisp, less result, as values are copied, and its scalars are :float values, or :double values for the doubles' functions; the accessors, as cl-gmath:vec3-x and cl-gmath:mref, are places. Where cl-gmath signals an error for arguments that have no answer, as the inverse of a singular matrix, shader code computes the same formulas, which give what the GPU gives. The doubles' functions that need trigonometry, such as cl-gmath:dquat-slerp, and the batch functions refuse to expand, saying why. shader-documentation of a function, as (shader-documentation 'cl-gmath:vec3-angle), says what it computes.

clsl is plain Common Lisp, for SBCL. Loading it needs no foreign library, and nothing in it needs setting up again when a saved image starts. It depends on cl-threads, for the lock its definitions are made under, and clsl/gmath on cl-gmath too.

The IR

CLSL/IR is the intermediate representation between clsl's language and the code it compiles to. Programs never need it; back ends, targets and tests do, and program-ir shows what a program's shaders became. The plan, clsl.org in the workspace, says why it is shaped as it is.

A module is one stage of a program, :vertex, :tessellation-control, :tessellation-evaluation, :geometry, :fragment or :compute: its structs, its global variables, its functions and its entry point, with the options of its stage, as a compute shader's :workgroup-size, a tessellation control shader's :output-vertices, a tessellation evaluation shader's :domain, :spacing, :winding and :point-mode, and a geometry shader's :input, :output, :max-vertices and :invocations; and every stage's float controls, :rounding-mode-rte, the kinds of floats the GPU rounds to the nearest, and :signed-zero-inf-nan-preserve, those whose signed zeros, infinities and NaNs it keeps: set-entry-point says what each is, and module-option returns one. Values are constants, globals, parameters and the results of instructions, each defined once and each of a type. Control flow is structured: an :if holds a then block and an else block, a :loop a body and a continuing block, and every block ends in exactly one terminator. Memory is explicit: a variable is a pointer, which :load, :store and :access reach through, and pointer-root returns the variable a pointer points into.

Types

Types are written by their designators, and interned in their module, so that two types of a module are the same exactly when they are eq.

Designator Type
:bool, :int, :uint, :float, :double a boolean; 32-bit integers, signed and unsigned, that wrap; IEEE floats of 32 and 64 bits
:vec2 to :vec4, :ivec2, :uvec3, :bvec4, :dvec2 and so on vectors of 2 to 4 scalars
:mat2 to :mat4, :mat2x3 (two columns of three), :dmat4x2 and so on matrices of floats or doubles, stored by column
(:array element length), (:array element) arrays, the second's length known only as the shader runs, as a storage buffer's last member may be
a symbol a struct of the module, which add-struct or (:struct ...) declares
(:pointer space type) a pointer into an address space
:sampler2d, :usampler-cube-array, :sampler2d-shadow and the others the language has a texture, with its sampler: handle-keyword-description says of what shape and kind, and texture-keywords lists them
(:image2d format) and the others of image-keywords a storage image, its texels stored in format, one of image-formats
:void what a function returns when it returns nothing

intern-type returns a module's type for a designator, type-designator a type's designator, and pointer-to a pointer type. The address spaces are :function, for a function's variables; :input and :output, the stage's interface; :uniform, a uniform block; :storage, a storage buffer; :workgroup, memory a compute shader's workgroup shares; :private, a variable of the invocation that every function sees; and :handle, where textures and storage images are. A texture or storage image is no value: :load reads it from its global for the operations that take it, and a function takes a pointer to it, never the texture itself.

Layout is not part of a type: type-size, type-alignment, array-stride, matrix-stride and member-offsets lay a type out by :std140, as uniform blocks are, or :std430, as storage buffers are.

Operations

An instruction is an operation and its operands; operations are uniform where SPIR-V's are, so a scalar factor is copied into a vector with :splat before it scales one component by component. Unless the table says otherwise, an operation takes scalars or vectors of one type and gives that type, component by component. operations lists them all, and operation-kind, operation-arguments, operation-effect, operation-stages and operation-uniformity describe each.

Operations Operands Result
:add, :subtract, :multiply, :divide, :mod, :rem two numbers of one type the same; integers wrap, :divide truncates them, :mod takes the divisor's sign and :rem the dividend's, as mod and rem do
:negate, :abs, :min, :max, :clamp numbers; :abs of no :uint, :clamp of three the same
:vector-times-scalar, :matrix-times-scalar a vector or matrix of floats, and a scalar the vector or matrix
:matrix-times-vector, :vector-times-matrix, :matrix-times-matrix, :outer-product, :transpose matrices and vectors of floats that fit as linear algebra gives
:dot two vectors of floats a scalar
:determinant, :inverse a matrix with as many rows as columns a scalar, or a matrix
:floor, :ceiling, :truncate, :round, :sqrt, :inverse-sqrt floats the same; :round takes halves to the even neighbour
:exp, :exp2, :log, :log2, :sin, :cos, :tan, :asin, :acos, :atan, :sinh, :cosh, :tanh, :asinh, :acosh, :atanh :float values, never doubles the same
:power, :atan2 two :float values of one type, never doubles the same; (:atan2 y x)
:fma, :mix, :smoothstep three floats of one type the same: (:mix x y a) is x * (1 - a) + y * a
:sign :int values or floats -1, 0 or 1 of their type
:norm, :distance one or two floats of one type a scalar: the Euclidean length, or that of the difference
:normalize, :reflect, :face-forward one, two or three floats of one type the same
:cross two vectors of three floats the same
:refract two floats of one type, then a scalar of their component the same
(:sample texture coordinates [offset]), :sample-bias, :sample-level, :sample-grad a texture of colours, neither multisampled nor a buffer; a float for each of its dimensions, and an array's layer; then a bias or a level of detail, a :float, or two derivatives, a float for each dimension; and an optional constant offset, an :int for each dimension its texel, a vector of four of the texture's components, at the level of detail the coordinates' derivatives choose, in fragment shaders only, or the bias, level or derivatives give
:sample-compare, :sample-compare-bias, :sample-compare-level, :sample-compare-grad a shadow texture, its coordinates, a depth, a :float, and the rest as the four above the share of its texels that pass the comparison, a :float
(:gather texture coordinates component [offset]), (:gather-compare texture coordinates depth [offset]) a two-dimensional or cube texture, an array or not; its coordinates; a constant :int from 0 to 3, or a depth the component of four texels, or each compared, a vector of four
(:fetch texture texel level [offset]), (:fetch-sample texture texel sample), (:fetch-buffer texture index) a texture of colours neither a cube, multisampled nor a buffer, an :int for each of its dimensions and an array's layer, and an :int; or a multisampled one and a sample; or a buffer texture and an index its texel there, unfiltered, a vector of four of the texture's components
(:texture-size texture [level]), :texture-levels, :texture-samples, (:texture-query-lod texture coordinates) a texture, and a level of one with mip levels; or a texture with mip levels; or a multisampled one; or one with mip levels and a float for each dimension its size, an :int for each dimension and an array's layers; how many levels; how many samples; or the level of detail a read would pick, a :vec2, in fragment shaders only
(:image-load image texel [sample]), (:image-store image texel texel-value [sample]), :image-size, :image-samples a storage image, an :int for each dimension and an array's layer, a cube's face the third, a sample of a multisampled one, and a vector of four of its components to store its texel, or nothing, or its size, or how many samples it has; a load needs its global's :access to read, and a store to write
:atomic-add, :atomic-subtract, :atomic-min, :atomic-max, :atomic-and, :atomic-or, :atomic-xor, :atomic-exchange a pointer to an :int or a :uint of a :storage buffer that is written, or of a :workgroup variable, and a value of its type what it held, after it changed it in one step
(:atomic-compare-exchange pointer expected new) the same pointer, and two values what it held, which it replaced with new if it was expected
:barrier nothing; it waits for every invocation of the workgroup, in compute shaders only
:memory-barrier, :memory-barrier-buffer, :memory-barrier-image, :memory-barrier-shared, :group-memory-barrier nothing; they order the invocation's reads and writes of memory, the last two in compute shaders only
:dfdx, :dfdy, :fwidth, and their -fine and -coarse kinds :float values the same: derivatives across the quad, in fragment shaders only
:equal, :not-equal two numbers or booleans of one type booleans of their shape
:less, :less-equal, :greater, :greater-equal two numbers of one type booleans of their shape
:all, :any a vector of booleans a :bool
:and, :or, :not booleans the same
:logand, :logior, :logxor, :lognot, :shift-left, :shift-right integers; a shift's count of the same type the same; :shift-right keeps the sign of an :int
:bit-count, :find-msb, :find-lsb, :bit-reverse integers the same: how many bits are 1; the index of the highest bit that is 1, or of a negative :int's highest 0, -1 if there is none; the index of the lowest 1, or -1; and the bits in reverse order
(:add-carry type a b), (:subtract-borrow type a b) two :uint values of one type a struct of type of two members of theirs: the sum, or difference, wrapped, and the carry, or borrow, 1 or 0
(:multiply-extended type a b) two integers of one type a struct of type of two members of theirs: the low and the high 32 bits of their 64-bit product
(:frexp type x) floats a struct of type of the significand, of x's type, and the exponent, :int values of its shape, as GLSL.std.450's FrexpStruct; undefined for an infinity or a NaN
:is-nan, :is-inf floats booleans of their shape
:pack-unorm-4x8, :pack-snorm-4x8, :pack-unorm-2x16, :pack-snorm-2x16, :pack-half-2x16 a :vec4, or a :vec2 a :uint
:unpack-unorm-4x8, :unpack-snorm-4x8, :unpack-unorm-2x16, :unpack-snorm-2x16, :unpack-half-2x16 a :uint a :vec4, or a :vec2
:interpolate-at-centroid, (:interpolate-at-sample input sample), (:interpolate-at-offset input offset) a pointer to a fragment shader's input of floats, at a location, and an :int sample or a :vec2 offset the input interpolated there; in fragment shaders only
:select a condition and two scalars or vectors of one type the one the condition chooses: a :bool chooses whole, a vector of booleans each component
(:construct type part ...) a vector's components, alone or in vectors; a matrix's columns; an array's elements; a struct's members type
(:splat type scalar) a scalar a vector of copies of it
(:extract composite index ...), (:insert composite value index ...) a composite, and literal positions the member, or a copy with it replaced
(:swizzle vector index ...) a vector, and two to four literal components a vector of them
:extract-dynamic, :insert-dynamic a vector, a component for :insert-dynamic, and an :int or :uint index the component, or a copy
(:convert type value) a number of another kind of the same shape it converted: floats to integers truncate, integers to floats round
(:bitcast type value) a number of another type with as many bits the same bits
(:var type) a pointer to a new variable of the function
:load, :store a pointer, and for :store a value of what it points to what it points to, or nothing
:access a pointer, and indexes: constants into a struct a pointer to the member they reach
:array-length a storage buffer whose last member has no length its length, a :uint
(:call function argument ...) the function's arguments what it returns
:discard nothing; in fragment shaders only, it makes the invocation a helper, whose writes are dropped
:emit-vertex, :end-primitive nothing; in geometry shaders only, :emit-vertex emits a vertex of the outputs as they are, and :end-primitive ends the strip

Integer operations wrap. Where SPIR-V leaves a result undefined, such as an integer divided by zero, a shift by 32 bits or more, an index out of range or a texel outside a texture, so does the IR: the language guards each case, so that every operation in it has a defined result. The functions of floats give what the GPU gives outside their domains, usually a NaN or an infinity, and Vulkan grants each its precision; the builder folds none of them.

Control flow

(:if condition [type] (:then ...) (:else ...)) runs one of its blocks, and (:loop [type] (:body ...) (:continuing ...)) runs its body, then its continuing block, until an exit leaves it. With a type, either gives a value, which its exits give. The terminators:

Terminator Ends Does
(:exit-if [value]) a block of an :if leaves the :if, giving its value
(:exit-loop [value]) a block of a :loop's body, or of an :if in it leaves the :loop, giving its value
(:continue) the same goes to the continuing block
(:next-iteration) a :loop's continuing block runs the body again
(:break-if condition [value]) a :loop's continuing block leaves the :loop if the condition is true, and runs the body again if not
(:return [value]) any block returns from the function
(:unreachable) any block is never reached

A value is defined in its block, after its statement, and in the blocks inside it. A construct no exit leaves never completes, and only (:unreachable) may follow it. construct-completes-p says whether one does, and terminator-target which construct an exit leaves. instruction-function returns the function an instruction is in.

Uniform control flow

A derivative, and :sample and its kin whose level of detail comes from derivatives, are computed by the invocations of a quad together, and :barrier waits for the invocations of a workgroup together: operation-uniformity says :collective of them. Their results are undefined where some invocations of the quad, or the workgroup, run them and others do not. uniformity-failures runs WGSL's uniformity analysis over a module, and returns a uniformity-failure for each collective operation that may run where control flow is not uniform. In a tessellation control module it runs over the invocations of one patch, and finds as well each :store to a :patch output, such as the tessellation levels, that the invocations of a patch may not all make, or make with values that differ: GLSL defines such a store only where every invocation makes it alike. A failure has:

  • uniformity-failure-origin, the instructions at fault, from the call in the entry point to the collective operation or the store;
  • uniformity-failure-need, what that operation needs: :control-flow, or :stored, the same value stored by every invocation of the patch;
  • uniformity-failure-kind, :control-flow; :argument or :contents for an argument, or what it points to, that a callee needs uniform; or :stored for the value a store stores;
  • uniformity-failure-parameter, that argument's position;
  • uniformity-failure-path, the steps by which it may not be uniform, from the operation to what differs between invocations;
  • uniformity-failure-severity, :error or :warning, as the :severity function given says of each collective instruction.

The language reports each failure in the terms of the shader code: an error refuses the shader, and a warning is a uniformity-warning.

Globals

(:global name space type option ...) declares a global variable. An :input or :output has a :location, or is the :built-in it names, such as :position, :fragment-coord or :global-invocation-id: built-ins lists them. Between the stages, a value is interpolated perspective-correct unless its :interpolation is :linear or :flat, and at the pixel's centre unless its :sampling is :centroid or :sample; a fragment shader's input of integers or doubles is :flat. An input of a tessellation or geometry shader, and an output of a tessellation control shader, is an array of a value for each vertex, of the patch or primitive, as per-vertex-global-p says, unless it is :patch t, one value of the whole patch, as a tessellation control shader's output and a tessellation evaluation shader's input may be. A :uniform block or :storage buffer holds a struct of its members, at a :binding of a bind :group, and a storage buffer's :access is :read or :read-write. A :handle holds a texture or a storage image, at its :binding, a storage image's :access :read, :write or :read-write. A :workgroup variable starts at zero.

The external form

module-form writes a module as an s-expression, and read-module-form reads one, written by hand or by module-form, back into a module:

(:module :compute
 (:struct numbers (values (:array :uint)))
 (:global id :input :uvec3 :built-in :global-invocation-id)
 (:global data :storage numbers :group 0 :binding 0)
 (:function main () :void
  (%1 (:load id))
  (%2 (:extract %1 0))
  (%3 (:access data (:int 0) %2))
  (%4 (:load %3))
  (doubled (:multiply %4 (:uint 2)))
  (:store %3 doubled)
  (:return))
 (:entry-point main :workgroup-size (64 1 1)))

A statement is an instruction, or a label and an instruction. A label is the value's name, which becomes its OpName, unless it is % and digits, which name nothing; :name after the instruction gives a name a label cannot. An operand is a label, a global's name, or a constant: its type and its data, as (:float 1.0), (:vec2 0.5 1.0) or (:mat2 (1.0 0.0) (0.0 1.0)). module-form labels a value by its name where no other value of the function and no global it uses has that label, and %1, %2 and so on otherwise, so that the same module always gives an equal form.

Building

make-module, add-struct, add-global, add-function and set-entry-point make a module. A builder, make-builder, adds instructions to the end of a function's body: build adds one and returns its value, build-if and build-loop add a construct, whose blocks if-then-block, if-else-block, loop-body-block and loop-continuing-block return, and with-block builds in one of them. After a terminator, the code that follows, which never runs, as Lisp code after a return, is built in an unreachable-block, which no construct holds.

(let ((if (ir:build-if builder negativep :float)))
  (ir:with-block (builder (ir:if-then-block if))
    (ir:build builder :exit-if (ir:constant module :float -1.0)))
  (ir:with-block (builder (ir:if-else-block if))
    (ir:build builder :exit-if (ir:constant module :float 1.0)))
  if)

When every operand is constant, build folds: composites always, and integer, boolean and conversion operations where the result is exactly defined. It does not fold floating-point arithmetic, whose results differ from GPU to GPU.

The verifier

verify-module checks a module against the IR's rules, and signals an ir-error naming the function and the statement at fault, as the external form writes them, and what would fit. Every block ends in a terminator, and only there; each operand is defined before its use, in its block or one around it, and of the types its operation takes; each exit leaves a construct it is in, with a value of the construct's type; stores go only to memory that may be written; calls match their callees, and no function calls itself; operations appear only in the stages they belong to; pointers are passed only to whole variables; and the globals, structs and entry point are well formed.

Targets and the SPIR-V back end

A target is one API's rules for the code a program becomes. find-target returns one by its name, and signals no-target-error, naming the system to load, for one no system loaded has defined. target-compile makes a target's code of a module, and target-disassemble prints that code for people to read. clsl/vulkan defines :vulkan: SPIR-V 1.6 and the memory model Logical GLSL450, as glslang makes them for Vulkan 1.4; fragment shaders whose origin is the upper left; bind groups as descriptor sets; and :discard as OpDemoteToHelperInvocation, which cl-gfx enables.

translate-module verifies a module and translates it into SPIR-V for a target, a spirv-target:

  • each instruction is chosen by its operands' types: :add is OpFAdd or OpIAdd, :less is OpFOrdLessThan, OpSLessThan or OpULessThan, and :min is GLSL.std.450's FMin, SMin or UMin;
  • an :if becomes a selection with its merge block, a :loop a loop header, body, continue target and merge block, and the value either gives an OpPhi at its merge;
  • the structs and arrays of uniform blocks and storage buffers are declared laid out, with their offsets and strides, and those of values plain, as Vulkan requires; values convert between the two with OpCopyLogical;
  • the capabilities a module declares are worked out from what it uses, by the grammar's tables, those of GLSL.std.450 included, and refused with unsupported-error where the target does not allow them;
  • a fragment shader's built-in input of integers, SampleId, is decorated Flat, as its other integer inputs are; and from SPIR-V 1.6, where a fragment may be demoted, HelperInvocation is Volatile, so that it reads what it is after the demotion;
  • the entry point lists every global its call tree uses, and names become OpName and OpMemberName.

The same module always gives the same words. A module the back end cannot translate validly is a bug in clsl, signalled as internal-error with the IR's external form.

SPIR-V

clsl/spirv's package is CLSL/SPIRV. It knows nothing of clsl's IR or language, and may be used on its own; the back end, in the same system, writes its modules with it.

Instructions

An instruction is a keyword and its operands, in the order of SPIR-V's grammar: (:type-int 3 32 1) is %3 = OpTypeInt 32 1. The keyword is the grammar's name, without Op for an instruction, in lower case, with a hyphen between words: OpFOrdLessThan is :f-ord-less-than, the execution model GLCompute is :gl-compute, and the capability Float64 is :float64. Names that start with a digit, as the dimensions 1D to 3D do, are written :2d, which Lisp reads as :|2D|.

Operand In Lisp
an id an integer that new-id made
a literal number an integer, or a float of the type's width: a single-float for 32 bits, a double-float for 64, and a single-float a half can hold for 16
a string a string
an enumerant a keyword, followed by its parameters: :location 0
a set of bits a keyword, :none for none, or a list of keywords in increasing order, followed by the parameters of each: '(:volatile :aligned) 4
an instruction of an imported set its keyword: :inverse-sqrt of GLSL.std.450
OpSpecConstantOp's operation the instruction's keyword: :i-add

Building a module

make-module makes a module, of SPIR-V 1.6 unless :version says otherwise; new-id makes its ids; emit adds an instruction and returns the id it defines; module-words returns its words.

(spirv:emit module :decorate variable :location 0)
(spirv:emit module :f-add float sum a b)
(spirv:emit module :loop-merge merge continue :none)

The module puts each instruction where SPIR-V's layout puts it, whatever order they are emitted in, but for three that are written by others: an OpConstant needs its type emitted first, an OpSwitch its selector's type, and an OpExtInst its import. A function begins with :function and ends with :function-end; the instructions of its blocks go into it in the order they are emitted, and its variables of :function storage go to the start of its first block. A capability or extension emitted twice is declared once.

Types and constants are declared once, and the same id comes back each time one is asked for: void-type, bool-type, int-type, float-type, vector-type, matrix-type, pointer-type, function-type, image-type, sampler-type, sampled-image-type, constant, constant-composite, null-constant and import-instruction-set. SPIR-V allows a type other than an aggregate or a pointer to be declared once only, and emit refuses a second. Aggregates are another matter: one array or struct type may be laid out, with offsets and strides, and another of the same members not, so new-array-type, new-runtime-array-type and new-struct-type declare a new type each time.

emit refuses operands its instruction's grammar does not take, with an error that says which operand is wrong and what would fit, and an instruction where the layout does not allow it, such as an OpReturn outside a function. module-words refuses a module that is not whole: a function not ended, no memory model, or an id that an instruction uses and none defines.

Reading and printing

read-instructions reads a module's words, or a .spv file, back into instructions in the form emit takes, so that what is read can be emitted again. read-capabilities reads only the capabilities a module declares, decoding no other instruction. read-header returns the header's version, generator, bound and schema, and read-words reads a file's words in either byte order.

(spirv:read-instructions words)
;; => ((:CAPABILITY :SHADER) (:MEMORY-MODEL :LOGICAL :GLSL450)
;;     (:ENTRY-POINT :FRAGMENT 1 "main" 2) ...)

disassemble-spirv prints a module as SPIR-V assembly, laid out and named as spirv-dis lays out and names it: ids by OpName, built-in variables by GLSL's names, and types and constants by what they are. With :raw-ids t it prints ids as numbers, and spirv-as --preserve-numeric-ids reads the text back into the same words. Floats are printed as the shortest decimals that read back as them; infinities, NaNs and 16-bit floats in hexadecimal, as spirv-dis prints them. :stream nil returns the text as a string.

The grammar's tables

The tables are generated from the SPIR-V grammar that Khronos publishes in SPIRV-Headers: SPIR-V 1.6, revision 7, and GLSL.std.450. They answer what an instruction or enumerant needs: instruction-opcode, opcode-instruction, instruction-spirv-name, instruction-capabilities, instruction-extensions, instruction-version and instruction-last-version; enumerant-names, enumerant-value, enumerant-spirv-name, enumerant-capabilities, enumerant-extensions, enumerant-version and enumerant-last-version; extended-instruction-number and extended-instruction-capabilities; and grammar-version.

(spirv:instruction-version :copy-logical)          ; => (1 4)
(spirv:enumerant-capabilities :capability :shader) ; => (:MATRIX)

A name the grammar lacks is an error, which suggests the name meant where it can: :fadd is :f-add.

A module notes what each instruction and enumerant it holds requires, by these tables, and by the width of its integer and float types, which the grammar does not list: module-requirements returns them, each a requirement whose requirement-capabilities and requirement-extensions it needs one of, and whose requirement-what names it. emit refuses an instruction or enumerant that the module's version of SPIR-V lacks and no extension provides, such as OpCopyLogical in a SPIR-V 1.0 module.

The grammar and the generator

The grammar files are vendored in grammar/, with Khronos's licence, and spirv/grammar.lisp, which clsl/generator writes from them, is committed, so loading clsl needs no JSON library. After changing a grammar file or the generator, run

(asdf:load-system :clsl/generator)
(clsl/generator:regenerate-grammar)

and commit both. A test checks that the committed file is what the generator writes. The generator reads JSON with jzon, in generator/json.lisp alone, which turns it into plain Lisp data: changing the JSON library is rewriting that file.

Status

clsl is being built. The language's core is done: vertex, tessellation, geometry, fragment and compute shaders of scalars, vectors, matrices, structs and arrays, with Lisp's numbers, several values, control flow, loops and assignment, local functions and macros, the bits of integers, uniforms, every kind of texture and storage image, storage buffers, shared variables, barriers and atomics, derivatives, with WGSL's uniformity analysis, doubles, complex numbers, and float pairs, for positions far from the origin. Programs tell the layouts and the device features they need in cl-gfx's terms, and clsl/gmath brings cl-gmath's names into shader code. Pyssel and cl-slug moved from cl-gsl to clsl on 2026-10-15, and cl-gsl is archived; GLSL output and the OpenGL target come next. clsl.org, in the workspace, is the plan.

Tests

stift test

The suite holds clsl to Common Lisp, to SPIRV-Tools and to a GPU:

  • spirv-val validates the modules the tests build, and spirv-as and spirv-dis check the builder, the reader and the disassembler. Install spirv-tools on Debian or Ubuntu, or the Vulkan SDK on Windows.
  • Every operation of the IR, in every type the verifier lets it take, validates, and computes on the GPU what the tests' evaluator, which runs the IR in Lisp, computes for the same IR.
  • Every built-in of the language, in every signature it takes, compiles into a shader that validates. On the GPU every built-in computes what Lisp computes: Lisp's function of the same name, or the GPU's vocabulary, such as mix and refract, by its definition computed in doubles; integers exactly, and floats within the precision Vulkan grants each. Control flow, every and its kin, loops, several values, structs and arrays, local functions, places and assignments compute what the same forms compute in Lisp; the bit operations compute what Lisp's do, ash by 32 bits or more and bytes past bit 31 included; the guards give their defined results; uniforms packed by pack-uniforms reach the shader; structs and arrays packed by std140 and std430 come back from storage buffers as pack-value lays them out; every kind of texture is read every way, at places, levels, layers and samples, with offsets, through filters and comparing samplers, as the evaluator reads it, in compute shaders and, at the level of detail derivatives pick, in fragment shaders; storage images of every kind and format are read, written and read back; textures and images are read and written in and out of range; atomics count right across workgroups, and shared memory, which starts at zero, sums a workgroup's share between barriers; derivatives, fine and coarse, are what the evaluator computes across each quad; and a triangle, interpolation, the interpolation functions, several render targets, discard and Pyssel's quad shaders draw as expected, the quad as well with a struct between its stages as with values by position. Doubles compute on the evaluator exactly what Lisp computes, and on the GPU within the precision Vulkan grants them, that of single-floats; they reach shaders unchanged from vertex buffers, as words, between stages, and through uniform blocks and storage buffers. Float pairs compute, on the evaluator and on the GPU, within 2^-45 of what Lisp computes exactly of the reals they hold, comparisons, rounding and conversions exactly, and keep a position 10,000 km out to well under a micrometre; their operations are decorated NoContraction; and they reach shaders unchanged from vertex buffers, between stages, and through uniform blocks and storage buffers. Every complex function is held to Lisp's, computed in doubles, on the evaluator within a few ulps and on the GPU within the precision Vulkan grants the functions it is made of. A program of all five graphics stages draws a triangle as a vertex and a fragment shader do, tessellated or not, and draws nothing of a patch whose levels its shader leaves at 0; a tessellation evaluation shader's :winding decides which of its triangles culling drops; a geometry shader emits nothing past its :max-vertices, and passes the number of its primitive on to a fragment shader that reads it; and a struct, a matrix and doubles pass through every stage. cl-slug's pixel shader, rewritten in clsl, compiles and validates.
  • Each function of cl-gmath, through clsl/gmath, computes on the evaluator what it computes in Lisp, and each function cl-gmath exports has a shader form or a refusal that says why it has none.
  • Every rule of the uniformity analysis is held to shader code it accepts and shader code it refuses, with the message, the tessellation levels' rule included.
  • Every form the language refuses is refused with its message.
  • Shaders run on a Vulkan 1.4 device through cl-gfx, under the Khronos validation layer, which must report nothing. Mesa's lavapipe needs no GPU: install mesa-vulkan-drivers and vulkan-validationlayers.

A missing tool, driver or layer fails the suite with a message saying what to install: the tests never skip. CI runs the suite on SBCL, on lavapipe.

Licence

MIT. The grammar files in grammar/ are Khronos's, under the MIT licence in grammar/LICENSE. tests/slug-test.lisp ports Eric Lengyel's reference pixel shader for the Slug algorithm, under the MIT licence its header gives.