- Common Lisp 100%
|
All checks were successful
CI / test (push) Successful in 2m54s
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). |
||
|---|---|---|
| .forgejo/workflows | ||
| doc | ||
| generator | ||
| gmath | ||
| grammar | ||
| spirv | ||
| src | ||
| tests | ||
| vulkan | ||
| .gitattributes | ||
| .gitignore | ||
| CLAUDE.md | ||
| clsl.asd | ||
| LICENSE | ||
| README.org | ||
| stiftfile | ||
clsl
- Shader code
- Definitions
- Programs
- Errors
- Threads
- Systems
- The IR
- Targets and the SPIR-V back end
- SPIR-V
- The grammar and the generator
- Status
- Tests
- Licence
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,
theandtypedeclarations, and inferred everywhere else::bool;:intand:uint, of 32 bits, which wrap;:floatand:double; the vectors:vec2to:vec4,:dvec2,:ivec2,:uvec3,:bvec4and so on; the matrices of floats:mat2to:mat4, and:mat2x3, of two columns of three, and so on, and of doubles,:dmat4and so on; the complex numbers:complexand:dcomplex, Lisp's(complex single-float)and(complex double-float); the float pairs:dfloatand:dfvec2to: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-bufferand:sampler2d-shadow; the storage images of the same shapes, such as:image2dand:uimage3d; the structs ofdefine-struct;(:array type length); and(:array type), whose length a storage buffer gives.define-type-aliasnames a type. - A number written in the code fits any type that holds it exactly:
2and1/2fit a:float, and16777217does not;1.5d0andpiare:doublevalues, 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:floatand an:intis refused, and(float i)converts;(float x 1d0)makes a:floata: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, sotruncate,floor,ceilingandrounddivide integers, and turn floats into integers,(floor 2.5)being the:int2 and the remainder 0.5;ffloorand its kin give floats.modandrem,min,max,abs,signum,expt,sqrt,isqrt,exp,log, the trigonometric and hyperbolic functions,decode-float,float-sign,scale-float, and1+,1-,zerop,plusp,minusp,evenpandoddpare Lisp's too. GPUs compute trigonometry, exponentials and logarithms of:floatvalues 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
complexand taken apart byrealpart,imagpart,conjugate,phaseandabs;cis, their arithmetic and their functions, fromsqrtandexptoatanh, are Lisp's, with its branch cuts, written in clsl's own language and included once in each module that calls them. A:dcomplexhas the arithmetic,abs,signumandsqrt. - 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,absand 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-productandmatrix-component-product;add-carry,subtract-borrow,multiply-extended,find-lsbandbitfield-reverseof integers;float-nan-pandfloat-infinity-p;pack-unorm-4x8and the other packings of floats into integers, and theirunpack-kin;pack-double-2x32andunpack-double-2x32, a double's two words; andinterpolate-at-centroid,interpolate-at-sampleandinterpolate-at-offsetof 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.lengthcounts a vector's components, as in Lisp;normis the Euclidean length. - Vectors and matrices are made by their constructors,
(vec4 position 0 1)and(mat3 1), and read byx,y,z,w,(swizzle v :zyx)andaref, which are places. - Structs and arrays are made by a struct's constructor,
(make-light :position p :color c :radius 2), byvectorand bymake-array, read by a struct's accessors,slot-value,with-slotsandaref, which are places, and copied by a struct's copier andcopy-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 bytruncateand its kin, and taken bymultiple-value-bind,nth-value,multiple-value-setq,(setf (values ...)),multiple-value-prog1andmultiple-value-call; where one value is wanted, the first is. - Bits are Lisp's, on 32 bits:
logandand its kin,logtest,logbitp,logcount,integer-length,ash, andldb,dpband their kin with(byte size position);float-bitsandfloat-from-bitsgive a float's bits and make one of them. - Derivatives, in fragment shaders, are
dfdx,dfdyandfwidth, and their-fineand-coarsekinds. 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,:ddxand:ddy,:offsetand, of a shadow texture,:compare; byfetch, a texel as it is, with:level,:sampleand:offset; and bygather, a component of four texels;texture-size,texture-levels,texture-samplesandtexture-query-lodmeasure them. Storage images are read and written a texel at a time byimage-loadandimage-store. - Compute shaders share variables across a workgroup, after
&shared, whichbarrierand the memory barriers order, and change integers that invocations share withatomic-incf,atomic-max,compare-and-swapand their kin. - The special forms are
let,let*,progn,locally,if,when,unless,cond,case,and,or,the,values,dotimes,loopandreturn, withdeclareoftype,optimize,derivative-uniformity,ignoreandignorable;setq,setf,incfanddecf, of variables, components, elements, slots, bytes and the places macros stand for, each place evaluated once;flet,labels,funcallandlambda, whose functions see and assign the variables around them; andmacroletandsymbol-macrolet.define-macrodefines 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-formatssays how each input is read.(:tessellation-control :patch-size n :vertices n): it takes patches of:patch-sizevertices 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,nits: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-sizethe 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),:quadsor:isolines, giving a position and values.:spacingis:equal(the default),:fractional-evenor: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 tmakes points instead.(:geometry :input p :output p :max-vertices n [:invocations n]): it takes primitives of its:input,:points,:lines,:lines-adjacency,:trianglesor:triangles-adjacency, each input an array of a value for each of their vertices, and makes strips of its:output,:points,:line-stripor: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-verticesis not emitted. It runs:invocationstimes for each primitive, once by default.:fragment: its inputs,(name type [:interpolation i] [:sampling s]), take those values, interpolated:smooth, the default,:linearor:flat, at the pixel's:center, the default, the primitive's:centroidor each:sample; integers, doubles and float pairs are:flat. A struct passes whole, each slot interpolated asdefine-structsays. 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
:sampler2dor:usampler-cube-array, is a texture with its sampler, read withsample,fetchandgather. 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:formatof its texels, such as:rgba8-unorm, is a storage image, read and written withimage-loadandimage-storeas its:accessallows::read,:write, or:read-write, the default; - with
:storage :reador: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, whichlengthcounts 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, ascompile-programtook it;program-stages,program-shader-name,program-spirv,program-workgroup-size, andprogram-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'sdevice-featuresnames them, so that it runs where(subsetp (program-features program) (gfx:device-features device))::geometry-shaders,:float64for doubles,:vertex-storesfor storage a stage before the fragment shader writes, the float controls':rounding-mode-rte-float32and 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'screate-render-pipelinetakes:vertex-buffers;program-vertex-inputs: eachvertex-input'svertex-input-name,vertex-input-location,vertex-input-type, andvertex-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: eachuniform'suniform-name,uniform-type,uniform-group,uniform-offsetin its block, and itsuniform-documentationanduniform-metadatafromdefine-uniform; andprogram-uniform-block-sizeof each group;program-resources: each texture, storage image and storage buffer, aresource, with itsresource-name,resource-type,resource-group,resource-binding,resource-kind,:texture,:imageor:storage-buffer,resource-access, and a storage image'sresource-format;program-layout-entries: for each bind group, by index from 0, the entries of its layout, as cl-gfx'screate-bind-group-layouttakes them, in its binding kinds,:uniform-buffer,:texture,:storage-textureand the others, a group the program reads nothing from having none;program-bindings: everyprogram-bindingof every group, with itsprogram-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:sampler2dor:image2d, andprogram-binding-stages, as a bind group layout lists them.texture-type-pandimage-type-ptell a texture's type and a storage image's apart, andtexture-type-shapegives the shape of the texture to bind, in cl-gfx's dimensions or:bufferfor 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;:argumentor:contentsfor an argument, or what it points to, that a callee needs uniform; or:storedfor 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,:erroror:warning, as the:severityfunction 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:
:addisOpFAddorOpIAdd,:lessisOpFOrdLessThan,OpSLessThanorOpULessThan, and:minisGLSL.std.450'sFMin,SMinorUMin; - an
:ifbecomes a selection with its merge block, a:loopa loop header, body, continue target and merge block, and the value either gives anOpPhiat 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.450included, and refused withunsupported-errorwhere the target does not allow them; - a fragment shader's built-in input of integers,
SampleId, is decoratedFlat, as its other integer inputs are; and from SPIR-V 1.6, where a fragment may be demoted,HelperInvocationisVolatile, so that it reads what it is after the demotion; - the entry point lists every global its call tree uses, and names become
OpNameandOpMemberName.
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-valvalidates the modules the tests build, andspirv-asandspirv-discheck the builder, the reader and the disassembler. Installspirv-toolson 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
mixandrefract, by its definition computed in doubles; integers exactly, and floats within the precision Vulkan grants each. Control flow,everyand 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,ashby 32 bits or more and bytes past bit 31 included; the guards give their defined results; uniforms packed bypack-uniformsreach the shader; structs and arrays packed by std140 and std430 come back from storage buffers aspack-valuelays 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,discardand 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:windingdecides 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-driversandvulkan-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.