1 Shader Code
dxtr edited this page 2026-10-18 19:16:40 +02:00

Shader Code

Shader code is Common Lisp, where a GPU can give Lisp's meaning. It is written in the package of the program that defines it, which uses clsl for the language's own names, such as vec3, dot and &uniform; Common Lisp's names, such as let, + and floor, mean what they mean in Common Lisp. This page describes the language. Language Functions lists its functions, and doc/language.org in clsl's repository is the full reference, which describes every form and built-in.

Principles

  1. On scalars, every operator means what it means in Common Lisp. Arithmetic and numeric functions extend component by component to vectors, and to matrices where linear algebra says so. Predicates stay scalar: a comparison gives one boolean, and vectors compare through every, some and map, as Lisp compares sequences.
  2. Where a GPU cannot give Lisp's meaning, the form is refused when its definition is evaluated, with a message that says what to write instead. The exceptions, which static types force, are listed below under Where clsl differs from Common Lisp.
  3. Every operation has a defined result for every input, at the default safety: see Numbers and Precision.
  4. Types are written where values enter, in parameter lists, and inferred everywhere else.
  5. There is one way to do each thing. Names come from Common Lisp where the meaning matches, and from the GPU's vocabulary, as dot, mix and smoothstep, where Lisp has none.
  6. Mistakes are found when a definition is evaluated. The error names the definition and the form, and says what would fit.

Types

Type Values
:bool t and nil
:int, :uint integers of 32 bits, signed and unsigned, which wrap
:float, :double IEEE floats of 32 and 64 bits, Lisp's single-float and double-float
:vec2 to :vec4, :dvec2 to :dvec4, :ivec2 to :ivec4, :uvec2 to :uvec4, :bvec2 to :bvec4 vectors of 2 to 4 floats, doubles, integers, unsigned integers or booleans
:mat2, :mat3, :mat4, :mat2x3 and the others, :dmat2 to :dmat4x3 matrices of floats, or doubles, stored by column: :mat2x3 has two columns of three
:complex, :dcomplex complex numbers of floats or doubles, Lisp's (complex single-float) and (complex double-float)
:dfloat, :dfvec2 to :dfvec4 float pairs, a float and the float nearest what it misses, about 14 significant digits at the speed of floats; vectors of them
:sampler2d, :isampler3d, :usampler-cube-array, :sampler2d-ms, :sampler-buffer, :sampler2d-shadow and so on textures of one, two or three dimensions, cubes, arrays, multisampled, buffers and shadow textures, of floats or integers, with their samplers
:image2d, :uimage3d and so on storage images of the same shapes, which shader code reads and writes a texel at a time
the name of a struct a struct that define-struct defines
(:array type length) an array of length values of type, a positive integer
(:array type) an array whose length is known only as the shader runs, which a storage buffer holds

define-type-alias names a type, and the alias may be written wherever the type is. (shader-documentation 'shader-type) lists every type, and (shader-documentation :vec3) describes one:

(shader-documentation :vec3)
;; => "A vector of 3 :FLOATs, which (vec3 ...) makes."

A struct and an array of a length are values, as a vector is. A texture is no value: shader code passes it to sample, fetch, gather and the queries, or to a shader function, as it is. Nor is a storage image, nor an array whose length is known only as the shader runs: shader code reads and writes them a part at a time.

Numbers written in the code

An integer written in the code is an :int, or a :uint above an :int's range; a single-float is a :float, a double-float a :double, as pi is, and t and nil are :bool values. A constant, such as single-float-epsilon or one of defconstant, is its value.

A number written in the code fits any type that holds it exactly, where it meets one, as Lisp's contagion would make it: (* x 2) of a :float x multiplies by 2.0, (* x 1/2) by 0.5, and (+ u 1) of a :uint adds a :uint. 16777217 fits no :float, which cannot hold it, and 1/3 fits nothing:

(define-function third ((x :float))
  (* x 1/3))
In THIRD: 1/3 is a ratio, and clsl has no ratios, whose integers would overflow
where Lisp's never do.  A ratio fits a :FLOAT, a :DOUBLE or a :DFLOAT where it
is exactly one, and division of floats gives floats: write 0.33333334, or
(/ 1.0 3.0), or for doubles and float pairs 0.3333333333333333d0.
  in: 1/3

Arithmetic of numbers written in the code is done exactly, as Lisp does it, before the result fits: (/ 1 2) is 1/2, which fits a :float, and (+ 1 4294967296) is refused, as 4294967297 does not fit in 32 bits. A double-float fits doubles, and float pairs as the pair nearest it, but not a :float: (* x 1d0) of a :float is refused, and (float pi 1.0) is the :float nearest pi.

Numbers written in the code that meet where one value is wanted, as the branches of an if, take the type Lisp's contagion gives them: (if c 1 2.5) is a :float, and (if c 1 2.5d0) a :double.

Conversions

Nothing else converts silently. (+ x i) of a :float and an :int is refused:

(define-function mixed ((x :float) (i :int))
  (+ x i))
In MIXED: + takes numbers of one type, :INT, :UINT, :FLOAT, :DOUBLE or :DFLOAT,
scalars or vectors, or a vector and a scalar of its components; matrices of one
type; or complex numbers, or a complex number and a real of its parts' kind,
not :FLOAT, :INT.
  in: (+ X I)

and (+ x (float i)) adds them. The conversions:

Form Gives
(float x) a float: an integer as a :float, a float as it is, a float pair as the :float nearest it
(float x 1.0), (float x 1d0) the :float, or :double, nearest x: of a vector, a matrix or a complex number too
(truncate x), (floor x), (ceiling x), (round x) a float as an integer, rounding as each says; floats become integers only so
(int i), (uint i) the :int, or :uint, of an integer's bits: one that fits stays, one that does not wraps
(dfloat x) a float pair of a float, an integer or a double

A real meets a complex number of its kind as Lisp's contagion has it, as the complex number of it and 0: (* 2.0 z).

Forms

Form Meaning
(progn form ...) the forms in order; the last gives the value, or values
(let ((variable value) ...) (declare ...) form ...), (let* ...) binds variables, each of its value's type, or of the type a type declaration gives it
(locally (declare ...) form ...) the forms with the declarations in effect
(if test then [else]) then if the :bool test is true; a value if both branches give one of one type
(when test form ...), (unless test form ...) the forms if test is true, or false; no value
(cond (test form ...) ... [(t form ...)]) the forms of the first true clause; a value if it ends with a t clause
(case key (keys form ...) ... [(otherwise form ...)]) the clause whose keys, integers written in the code, hold the :int or :uint key
(and boolean ...), (or boolean ...) :bool values, evaluated until one is false, or true
(the type value) value, of type; a number written in the code becomes type if type holds it exactly
(setq variable value ...), (setf place value ...) stores each value in its variable or place, and gives the last
(incf place [delta]), (decf place [delta]) adds or subtracts delta, 1 by default; the place's parts are evaluated once
(dotimes (variable count [result]) ...) the body count times, then result
(loop form ...) the simple loop, until a return leaves
(return [value]) leaves the innermost dotimes or loop
(values value ...) and the forms of several values see Several values
(flet ...), (labels ...), (funcall ...), (lambda ...) see Local functions
(macrolet ...), (symbol-macrolet ...) see Macros
(slot-value struct 'slot), (with-slots (slot ...) struct ...) a struct's slots, as places
(make-array length :initial-element value [:element-type type]) an array of a length written in the code
(every predicate vector ...), (some ...), (notany ...), (notevery ...) as Common Lisp's, of the vectors' components at each position
(map type function vector ...) the vector of type of function of the components at each position
(swizzle vector :components) the components the keyword names: (swizzle color :zyx)

#'name names, in every, some, notany, notevery, map, funcall and multiple-value-call, the function they apply: a local function, a shader function, a struct's function or a function of the language. every and its kin take a lambda form too: (every (lambda (c) (< 0 c 1)) color). A comparison of vectors, (every #'< a b), compares them component by component at once.

Places

A place is a variable; a component of a vector, as x, y, z, w and swizzle read it, where swizzle names no component twice; an element, as aref reads it, of a vector, a matrix or an array; a slot of a struct, as its accessor and slot-value read it; a byte of an integer, as ldb and mask-field read it; (the type place); a symbol macro, or a macro form, whose expansion is a place; and a part of any of these, as (x (light-color (aref lights i))). Parameters and inputs are variables too. Uniforms and textures are not places, and a part of a storage buffer is one only with :storage :read-write. setf, incf, decf and (setf (values ...)) assign places, each place's parts evaluated once, from left to right.

Loops

(define-function first-set-bit ((bits :uint))
  "The index of the lowest 1 bit of BITS, or -1 if there is none."
  (dotimes (i 32 -1)
    (when (logbitp i bits)
      (return i))))

A loop gives values of one type: those its return forms give, and a dotimes's result, which it gives when its count runs out. A number written in the code takes the type of the other values, as -1 above is an :int. dotimes counts with an :int, or a :uint, its count's type; a type declaration at the start of its body declares its variable, (dotimes (i 10) (declare (type :uint i)) ...). Shader code does not assign the variable of dotimes. loop is Common Lisp's simple loop, and a loop that no return leaves never ends. return leaves the loop around it in the same shader function: a return with no loop around it is refused.

Declarations

declare is written at the start of the body of let, let*, locally, dotimes and the definitions:

  • (optimize (safety 0)) leaves out the guards that give every operation a defined result, in its body. The default safety, 1, keeps them. The other qualities are accepted and change nothing.
  • (type type variable ...) gives variables of let and let* their type, which their values fit: (let ((x 0)) (declare (type :float x)) ...).
  • (derivative-uniformity severity), :error, :warning or :off, says what becomes of a derivative where control flow may not be uniform: see Uniform Control Flow.
  • (ignore variable ...) and (ignorable variable ...) are accepted.

Several values

A form may give several values, as in Lisp: values makes them, and so do truncate and its kin, decode-float, add-carry and its kin, and the functions whose last form gives several. Where one value is wanted, the first is used, and the others are evaluated and dropped.

(define-function split ((v :vec2))
  "The whole parts of V, and what is left of each."
  (multiple-value-bind (whole fraction) (floor v)
    (values whole fraction)))

(define-function cell-colour ((v :vec2))
  "V's whole parts and what is left of each, in one :VEC4."
  (multiple-value-call #'vec4 (ffloor v)))

multiple-value-bind, nth-value, multiple-value-setq, (setf (values place ...) form), multiple-value-prog1 and multiple-value-call take them as in Lisp, and (return (values ...)) gives several from a loop. A static type has no nil for a value a form does not give, so a variable, a position or a place beyond the values is refused, where Lisp would give nil. Only the values that are used are computed: (nth-value 1 (floor x)) computes no quotient.

Structs and arrays

A struct, which define-struct defines, is made by its constructor, copied by its copier, and read and written a slot at a time; an array of a length is made by (vector element ...) or (make-array length :initial-element value), read and written by aref, counted by length and copied by copy-seq. An array has one dimension, and an array of arrays makes more: (aref (aref grid row) column).

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

(define-function brightest ((lights (:array light 4)) (point :vec3))
  "The index of the light of LIGHTS that shines brightest at POINT."
  (let ((best 0)
        (most 0.0))
    (dotimes (i 4)
      (with-slots (position color radius) (aref lights i)
        (let ((shine (/ (norm color) (+ 1 (/ (distance position point) radius)))))
          (when (> shine most)
            (setf best i
                  most shine)))))
    best))

Structs and arrays are values, as numbers and vectors are: binding one, assigning it, passing it and returning it copies it, so that a change to one copy is not seen through another. In Lisp they would be one object:

(define-function copies ()
  "1.0: B is a copy of A."
  (let* ((a (make-light :position (vec3 0) :color (vec3 1) :radius 1))
         (b a))
    (setf (light-radius b) 2.0)
    (light-radius a)))

Code that copies before it changes, with copy-seq or a struct's copier, means the same in both. An index computed as the shader runs reads zero out of range, and a write there does nothing; an index written in the code is checked when the definition is evaluated:

(define-function past-end ((v :vec3))
  (aref v 3))
In PAST-END: :VEC3 has 3 components, from 0 to 2, and none at 3.
  in: (AREF V 3)

Local functions

flet and labels define functions in shader code, as in Lisp. A local function's parameters are typed, as a shader function's are, ((parameter type) ...), and its last form gives its value, or several. The functions of flet do not see each other; those of labels do. A local function sees the variables around it, and assigns them as Lisp does:

(define-function blend ((a :float) (b :float))
  (let ((total 0.0))
    (flet ((add ((weight :float) (value :float))
             (incf total (* weight value))))
      (add 0.25 a)
      (add 0.75 b))
    total))

A lambda form applied to arguments, ((lambda (x) (* x x)) a), binds its parameters as let does, and one in funcall, map or every takes the types of what it is given. A function is no value: funcall and its kin take #'name or a lambda form written in the form. No shader code calls itself, through other functions or not:

(define-function no-recursion ((n :int))
  (if (> n 0) (no-recursion (- n 1)) 0))
In NO-RECURSION: NO-RECURSION calls itself, through other functions or not, and
shader code cannot recurse.

Macros

A shader macro, which define-macro defines, and a local macro of macrolet, expand as Lisp's macros do, before the code is checked; their bodies are Lisp code, run as the shader code is checked. symbol-macrolet makes a symbol stand for a form, a place where it is assigned:

(define-function brighter ((color :vec3))
  (macrolet ((twice (form) `(* 2 ,form)))
    (symbol-macrolet ((red (x color)))
      (setf red (twice red))))
  color)

A local function or macro shadows a shader function or macro of its name, and a variable shadows a symbol macro. A name built into the language names no macro or local function.

Two shaders

A fragment shader that draws a tile map from a texture of integers, one tile a texel:

(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 (see Uniform Control Flow).

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))))))

Where clsl differs from Common Lisp

What static types and a GPU force:

  • Integers are 32 bits, and wrap; a ratio is no value. / divides floats only, and truncate and its kin divide integers.
  • A float function keeps its type: (sqrt -1.0) is a NaN, where Lisp's is a complex number, and (asin 2.0) too. (sqrt (complex -1.0)) is the complex number.
  • Floats, doubles and integers do not convert into one another in arithmetic, as Lisp's contagion would convert them: float converts. A real does meet a complex number of its parts' kind as Lisp's contagion has it.
  • Complex numbers are of floats: a complex number of rationals is no value, though one written in the code fits a complex number exactly.
  • GPUs compute trigonometry, exponentials and logarithms of :float values only: doubles are refused there, and a :dcomplex has no exp, log, phase or trigonometry.
  • float converts vectors, matrices and complex numbers too, which cl:float does not take.
  • A variable keeps the type of its first value.
  • A struct and an array are values: binding, assigning, passing and returning one copies it.
  • A form has as many values as it gives: a variable, a position or a place beyond them is refused, where Lisp would give nil. A struct's constructor takes every slot, and make-array an :initial-element, for the same reason. A struct's slots have no defaults, and define-struct takes no options.
  • An array has one dimension, and a length written in the code, or known only as a storage buffer's.
  • case takes integers written in the code as its keys.
  • What Lisp signals an error for, such as a division by zero, or a negative byte size computed as the shader runs, has a defined result instead: a shader cannot signal.
  • loop is the simple loop only, and return leaves the innermost dotimes or loop, never a function. Shader code does not assign the variable of dotimes, and has no go to the tags of its body.
  • A function, local or not, is no value. No function calls itself.
  • A shader macro has no &environment: shader code is expanded as it is checked, which no Lisp environment describes.
  • A byte, (byte size position), is written in the forms of ldb and its kin, and is no value of its own.

Asking clsl

shader-documentation describes any name in shader code: a definition, a struct's function, an operator of the language, a type, shader-type, &uniform or &shared.

(shader-documentation 'dot)
;; => "(dot a b): the dot product of the vectors A and B, a scalar of their
;;    components."

(shader-documentation 'light-radius)
;; => "(light-radius light): the RADIUS slot of the LIGHT, of the type :FLOAT.  A place, which SETF assigns."