1 Definitions and Stages
dxtr edited this page 2026-10-18 19:16:40 +02:00

Definitions and Stages

define-function, define-shader, define-struct, define-macro, define-type-alias, define-uniform and define-program make the parts of programs. They are macros, evaluated as Lisp evaluates them, at the REPL or when a file loads. Each is checked when it is evaluated: an error names the definition and the form at fault, and the definition before it stays.

define-uniform is described on Uniforms and Resources, and define-program on Programs.

define-function

(define-function name ((parameter type) ...)
  [documentation] [(declare ...)]
  form ...)

Defines the shader function name, which shader code calls as (name argument ...). A parameter's type is a type of a value or a texture. The last form gives the value, whose type is inferred, or several with values, or none. A struct or an array argument is copied: the function changes its own copy. A shader function reads the uniforms of define-uniform, and the shader that calls it passes it its own textures.

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

A shader function may call a function not defined yet. The definition is made, and signals a forward-reference-warning, a style warning, when it is evaluated, at the REPL or as a file loads; it is checked whole when a program that uses it is compiled. At the REPL, SBCL prints:

(define-function tone ((color :vec3))
  (gamma-correct (* color 1.2)))
WARNING:
   TONE calls GAMMA-CORRECT, which is not a shader function yet: define it with
   DEFINE-FUNCTION before a program that uses TONE is compiled.

A program that calls it compiles once it is defined, and until then signals an undefined-shader-function; a shader that calls tone warns of it again as it is defined:

(define-shader toned :fragment ((color :vec3))
  (vec4 (tone color) 1))

(compile-program '(:fragment toned) :target :vulkan)
In TONE: GAMMA-CORRECT is not a shader function: neither built into clsl's
language nor defined with DEFINE-FUNCTION.
  in: (GAMMA-CORRECT (* COLOR 1.2))
(define-function gamma-correct ((color :vec3))
  (expt color (vec3 (/ 1 2.2))))

(program-stages (compile-program '(:fragment toned) :target :vulkan))
;; => (:FRAGMENT)

No shader code calls itself, through other functions or not. The name of a built-in names no definition.

define-shader

(define-shader name stage (input ... [&uniform uniform ...] [&shared variable ...])
  [documentation] [(declare ...)]
  form ...)

Defines the shader name: the stage of a program, a function from its inputs, the parameters before &uniform, to its outputs, its values. A shader cannot be called. The uniforms after &uniform are on Uniforms and Resources, and the shared variables after &shared on Shared Memory and Atomics. The stages:

Stage Takes Gives
:vertex inputs (name type [:location n]), from vertex buffers the position in clip space, a :vec4, then the values the next stage takes
(:tessellation-control :patch-size n :vertices n) patches of :patch-size vertices, each input (name (:array type n)) each vertex of the patches it makes, its position and values; and the tessellation levels
(:tessellation-evaluation :patch-size n [:domain d] [:spacing s] [:winding w] [:point-mode b]) the patches the control shader makes, inputs as the control shader's each vertex the tessellator makes, placed: its position and values
(:geometry :input p :output p :max-vertices n [:invocations n]) primitives of :input, each input an array of a value for each vertex strips of :output, with emit-vertex
:fragment inputs (name type [:interpolation i] [:sampling s]), the values of the stage before a value for each render target, or none
(:compute :workgroup-size (x [y [z]])) nothing nothing; it writes storage buffers and storage images

Vertex shaders

A vertex shader's inputs are scalars and vectors of numbers, which a vertex buffer holds, at locations 0, 1 and so on: an input without a :location takes the one after the input before it. It gives the position in clip space, a :vec4, then the values the next stage takes, with values: scalars, vectors and matrices of numbers, and structs of them. Clip space is Vulkan's on every target, as cl-gfx's is: y points down, so the top of the target is at y -1, and depth runs from 0 to 1.

A vertex shader may take no inputs, and place its vertices by (vertex-index) alone; its program then needs no vertex buffer:

(define-shader fullscreen :vertex ()
  "A triangle that covers the target, placed by the vertex index alone:
draw three vertices, with no vertex buffer."
  (let ((uv (vec2 (float (logand (ash (vertex-index) 1) 2))
                  (float (logand (vertex-index) 2)))))
    (values (vec4 (- (* uv 2) 1) 0 1) uv)))

(define-shader show-uv :fragment ((uv :vec2))
  "UV as red and green."
  (vec4 uv 0 1))

(define-program fullscreen-uv :vertex fullscreen :fragment show-uv)

(program-vertex-buffers (compile-program 'fullscreen-uv :target :vulkan))
;; => NIL

Tessellation shaders

A tessellation control shader takes patches of :patch-size vertices, as a pipeline draws them, and makes patches of :vertices, from 1 to 32 each. It runs once for each vertex it makes, which (invocation-id) numbers from 0, and gives that vertex's position and values, as a vertex shader does. Its inputs are (name (:array type n)), a value for each vertex of the patch, n its :patch-size; (input-position index) reads their positions. (set-tessellation-levels outer inner) says how finely the tessellator divides the patch: every invocation of the patch sets the same levels, and a shader that never sets them is refused (see Uniform Control Flow).

A tessellation evaluation shader takes the patches the control shader makes, its :patch-size the control shader's :vertices, and runs once for each vertex the tessellator makes, which it places. (tessellation-coord) is where the vertex is in the :domain: :triangles, the default, the weights of the patch's three corners; :quads, x and y from 0 to 1 across it; or :isolines, lines across a quad. :spacing is how the levels divide an edge: :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. :point-mode t makes a point of each vertex instead.

(define-shader detailed-vertex :vertex ((position :vec2) (detail :float))
  (values (vec4 position 0 1) detail))

(define-shader refine (:tessellation-control :patch-size 3 :vertices 3)
    ((detail (:array :float 3)))
  "Divide each triangle as finely as its most detailed corner asks."
  (let ((level (max (aref detail 0) (aref detail 1) (aref detail 2))))
    (set-tessellation-levels (vec4 level) (vec2 level)))
  (input-position (invocation-id)))

(define-shader place (:tessellation-evaluation :patch-size 3)
    ()
  "Place each vertex the tessellator makes, by its weights of the corners."
  (let ((weights (tessellation-coord)))
    (+ (* (x weights) (input-position 0))
       (* (y weights) (input-position 1))
       (* (z weights) (input-position 2)))))

(define-shader white :fragment ()
  (vec4 1))

(define-program tessellated
  "Triangles, divided as finely as their corners ask."
  :vertex detailed-vertex
  :tessellation-control refine
  :tessellation-evaluation place
  :fragment white)

(let ((program (compile-program 'tessellated :target :vulkan)))
  (values (program-stages program) (program-patch-size program) (program-features program)))
;; => (:VERTEX :TESSELLATION-CONTROL :TESSELLATION-EVALUATION :FRAGMENT)
;;    3
;;    (:TESSELLATION-SHADERS)

A pipeline of the program draws lists of patches of program-patch-size vertices, and needs the device's :tessellation-shaders feature.

Geometry shaders

A geometry shader takes primitives of its :input, :points, :lines, :lines-adjacency, :triangles or :triangles-adjacency, of 1, 2, 4, 3 and 6 vertices, its inputs arrays of a value for each, 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. It emits at most :max-vertices vertices: past them, at the default safety, emit-vertex emits nothing. It runs :invocations times for each primitive, from 1, the default, to 32, which (invocation-id) tells apart.

(define-shader colored-vertex :vertex ((position :vec2) (color :vec3))
  (values (vec4 position 0 1) color))

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

(define-shader flat-color :fragment ((color :vec3))
  (vec4 color 1))

(define-program wireframe
  "Triangles drawn as their edges."
  :vertex colored-vertex :geometry outline :fragment flat-color)

(program-features (compile-program 'wireframe :target :vulkan))
;; => (:GEOMETRY-SHADERS)

Every emit-vertex of a geometry shader, and of the functions it calls, passes values of the same types, which the next stage takes. A geometry shader after tessellation takes the primitives the tessellator makes: :points in :point-mode, :lines of :isolines, and :triangles otherwise.

Fragment shaders

A fragment shader's inputs take the values of the stage before, by position. A value is interpolated :smooth, perspective-correct, the default; :linear, without perspective; or :flat, not interpolated, from one vertex, as integers, doubles and float pairs always are. It is sampled at the pixel's :center, the default, the primitive's :centroid, or each :sample. It gives a value for each render target, a scalar or vector of numbers, or none, and (discard) makes the fragment draw nowhere.

Compute shaders

A compute shader has no inputs and gives nothing. It runs in workgroups of x by y by z invocations, which tell themselves apart by (global-invocation-id), and writes storage buffers and storage images. The invocations of a workgroup share its variables after &shared. Getting Started runs one.

Built-in inputs

The built-in inputs are functions of no arguments. One used in another stage, by the shader or a function it calls, is refused.

Function Stage Gives
(vertex-index) vertex the index of the vertex being placed, an :int
(instance-index) vertex the index of the instance being drawn, an :int
(invocation-id) tessellation control, geometry which invocation this is, an :int from 0: the vertex it places of the patch it makes, or which of a geometry shader's :invocations
(primitive-id) tessellation, geometry, fragment the index of the primitive in the draw, an :int: of the patch, of the primitive taken, or of the primitive the fragment is of. A fragment shader that reads it needs the device feature of geometry shaders
(tessellation-coord) tessellation evaluation where the vertex is in the patch, a :vec3
(input-position index) tessellation, geometry the position the stage before gave the vertex at index of the patch or primitive, a :vec4
(fragment-coord) fragment where the fragment is, a :vec4: x and y in pixels from the target's top left, at the pixel's centre, z its depth, w 1 over the clip-space w
(front-facing-p) fragment true if the fragment's primitive faces the viewer
(helper-invocation-p) fragment true if the fragment is a helper of its quad's derivatives, which draws nowhere
(point-coord) fragment where the fragment is in its point, a :vec2 from 0 to 1 from the top left
(sample-index) fragment which sample of its pixel the fragment is, an :int; the shader runs once for each sample
(sample-position) fragment where the fragment's sample is in its pixel, a :vec2 from 0 to 1
(global-invocation-id) compute which invocation of the whole dispatch this is, a :uvec3
(local-invocation-id) compute which invocation of its workgroup this is, a :uvec3
(local-invocation-index) compute the same, counted across the workgroup's dimensions, a :uint
(workgroup-id) compute which workgroup of the dispatch this is, a :uvec3
(workgroup-count) compute how many workgroups the dispatch has across each dimension, a :uvec3
(workgroup-size) compute the size of the shader's workgroups, a :uvec3

A function that uses one of these may be defined anywhere, and is refused in a shader of another stage, which calls it, when that shader is defined:

(define-function pixel-x ()
  (x (fragment-coord)))

(define-shader wrong-stage :vertex ((position :vec2))
  (vec4 position (pixel-x) 1))
In PIXEL-X: FRAGMENT-COORD belongs to fragment shaders, and PIXEL-X, which uses
it, is called by the vertex shader WRONG-STAGE.
  in: (FRAGMENT-COORD)

Stage functions

Function Stage Does
(discard) fragment makes the fragment draw nowhere, and gives no value, so (if test (discard) color) is a colour. Nothing it writes after counts, to a render target or a storage buffer; it runs on, as a helper of its neighbours' derivatives
(set-tessellation-levels outer inner) tessellation control sets the levels of the patch's edges, a :vec4, and inside it, a :vec2: :triangles use the first three of outer and the first of inner, :quads all of them, :isolines the first two of outer
(emit-vertex position value ...) geometry emits a vertex at position, a :vec4 in clip space, passing each value on
(end-primitive) geometry ends the strip of the vertices emitted since the last one
(interpolate-at-centroid input) fragment input, an input of the fragment shader or a slot or component of one, of floats, interpolated at the centroid of the part of the pixel the primitive covers
(interpolate-at-sample input sample) fragment input interpolated at the position of the :int sample of the pixel
(interpolate-at-offset input offset) fragment input interpolated offset, a :vec2 in pixels from -0.5 to 0.5, from the pixel's centre

The derivatives, dfdx, dfdy and fwidth and their -fine and -coarse kinds, are on Uniform Control Flow. interpolate-at-centroid and its kin interpolate an input as the vertex shader gave it, so they take the input itself, and an input that shader code assigns is refused there; a shader function's parameter is not an input.

Stage interfaces

A vertex shader's inputs are at their locations, each taking one, but an input of three or four doubles, or of float pairs, two. Its values after the position go to the next stage at locations 0, 1 and so on, a matrix taking one location for each column, three or four doubles two, and a vector of float pairs one for each pair; so do the values of the other stages before the fragment shader. A tessellation or geometry shader takes them as arrays of a value for each vertex of its patch or primitive. A fragment shader's values go to render targets 0, 1 and so on; a :complex to two channels, and doubles to none, as no render target holds them.

define-program checks that each stage takes what the one before gives, in number, order and type. With colored-vertex from above, which gives a :vec3:

(define-shader misfit-fragment :fragment ((color :vec4))
  color)

(define-program misfit :vertex colored-vertex :fragment misfit-fragment)
In MISFIT-FRAGMENT: The vertex shader COLORED-VERTEX passes :VEC3 to the
fragment shader MISFIT-FRAGMENT, which takes :VEC4: the values after the
position are the fragment shader's parameters, in number, order and type.

A struct passes whole, as a variable of the interface for each of its slots, a nested struct's slots in turn, at consecutive locations, each interpolated as its slot says, or by its type: integers, doubles and float pairs :flat, floats :smooth, at the :center. A struct that holds a boolean or an array does not pass between stages.

(define-struct surface
  (normal :vec3)
  (uv :vec2)
  (material :uint))                     ; :flat, as integers are

(define-shader surface-vertex :vertex ((position :vec3) (normal :vec3) (uv :vec2)
                                       (material :uint))
  (values (vec4 position 1) (make-surface :normal normal :uv uv :material material)))

(define-shader surface-fragment :fragment ((point surface))
  (vec4 (* 0.5 (+ 1 (surface-normal point))) 1))

(define-program surfaces :vertex surface-vertex :fragment surface-fragment)

The fragment shader's input point is at locations 0 to 2: normal at 0, uv at 1 and material at 2.

define-struct

(define-struct name [documentation]
  (slot type [:interpolation i] [:sampling s])
  ...)

Defines the struct name, as defstruct defines one: (make-name :slot value ...) makes one, each slot's value given after a keyword of its name, in any order, and evaluated in the order written; (copy-name struct) copies one; and (name-slot struct) reads a slot, a place that setf assigns, as (slot-value struct 'slot) and with-slots do. The constructor takes every slot: a slot has no default, as shader code has no nil to leave in it.

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

(shader-documentation 'make-light)
;; => "(make-light :position value :color value :radius value): a LIGHT of the slots'
;;    values, each given after a keyword of the slot's name, every slot given."

A slot's type is a scalar, vector or matrix, a struct, or an array of a length. The last slot of a struct that only a storage buffer holds may be an array whose length is known only as the shader runs. A struct does not hold itself, through other structs or not. Its functions are named by the struct's and the slots' names, in the package define-struct is evaluated in; a name that is already a shader function, macro or another struct's function is refused. :interpolation and :sampling say how a slot passes between stages. Redefining a struct makes the programs that use it stale, as redefining a function does.

define-macro

(define-macro name lambda-list
  [documentation] [(declare ...)]
  form ...)

Defines name as a shader macro, as defmacro defines a macro: shader code that uses it, (name . arguments), stands for what the body, Lisp code, returns, with the lambda list bound as defmacro binds it, &whole, &optional, &rest, &body, &key and destructuring included. There is no &environment: shader code is expanded as it is checked, which no Lisp environment describes.

(define-macro square (x)
  "X times itself, X evaluated once."
  (let ((value (gensym "VALUE")))
    `(let ((,value ,x)) (* ,value ,value))))

A macro is expanded each time the shader code that uses it is checked, so the definitions that use it follow its redefinitions, which make their programs stale. The names a macro makes with gensym never reach the SPIR-V: a program compiles to the same words whatever they are.

define-type-alias

(define-type-alias name type [documentation])

Defines name, a symbol that is not a keyword, as another name of type, wherever a type is written: in parameter lists, struct slots, uniforms, the, type declarations and make-array's :element-type. A name is a struct or a type alias, not both. Programs follow an alias's redefinitions.

(define-type-alias color :vec4 "A linear RGBA colour.")

(define-function grey ((c color))
  "C's brightness, squared, as a grey of C's alpha."
  (let ((b (square (brightness (swizzle c :xyz)))))
    (vec4 b b b (w c))))

remove-definition

(remove-definition name) removes the shader, shader function, shader macro, struct, type alias, uniform or program name, and returns true if there was one. Removing a struct removes its functions; a uniform's special variable stays. Programs that use what was removed fail to compile until it is defined again. A struct's function is removed with its struct:

(remove-definition 'square)       ; => T
(remove-definition 'square)       ; => NIL
(remove-definition 'make-light)
MAKE-LIGHT is a function of the struct LIGHT, which goes with it: remove the
struct, LIGHT.

Documentation

shader-documentation returns the documentation of a definition, a struct's function, an operator of the language, a type, shader-type, &uniform or &shared:

(shader-documentation 'color)
;; => "A linear RGBA colour."