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

Uniforms and Resources

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

  • a scalar, vector or matrix of numbers, or a struct or an array of a length of them, is a member of its group's uniform block, laid out by std140;
  • a texture type, such as :sampler2d or :usampler-cube-array, is a texture with its sampler, read with sample, fetch and gather;
  • a storage image type, such as :image2d, with the :format of its texels, such as :rgba8-unorm, is a storage image, read and written with image-load and image-store as its :access allows: :read, :write, or :read-write, the default;
  • with :storage :read or :storage :read-write, a scalar, vector, matrix, struct or array is a storage buffer, laid out by std430.

Shader code reads uniforms and never assigns them, but for the parts of a storage buffer of :storage :read-write and the texels of a storage image it may write.

Groups and bindings

Group Bind group For
:frame 0 what changes once a frame, such as a camera
:material 1 what a material sets; the default
:draw 2 what changes with each draw

uniform-group-index gives a group's bind group. A group's uniform block is its binding 0, and its textures, storage images and storage buffers follow at bindings 1, 2 and so on. 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. In program-layout-entries, a group the program reads nothing from, below one that it does, has no entries, and the list ends with the last group it reads from.

(define-shader texel-reader :fragment ((uv :vec2)
                                       &uniform (heights :sampler-buffer)
                                                (palette :sampler1d :group :frame)
                                                (tint :vec4 :group :draw))
  (* tint (sample palette (x (fetch heights (truncate (* 100 (x uv))))))))

(let ((program (compile-program '(:fragment texel-reader) :target :vulkan)))
  (values (program-bindings program)
          (program-layout-entries program)))
;; => (#S(PROGRAM-BINDING :GROUP :FRAME :NUMBER 1 :KIND :SAMPLER1D :STAGES (:FRAGMENT))
;;     #S(PROGRAM-BINDING :GROUP :MATERIAL :NUMBER 1 :KIND :SAMPLER-BUFFER :STAGES (:FRAGMENT))
;;     #S(PROGRAM-BINDING :GROUP :DRAW :NUMBER 0 :KIND :UNIFORM-BLOCK :STAGES (:FRAGMENT)))
;;    (((1 :TEXTURE :VISIBILITY (:FRAGMENT)))
;;     ((1 :UNIFORM-TEXEL-BUFFER :VISIBILITY (:FRAGMENT)))
;;     ((0 :UNIFORM-BUFFER :VISIBILITY (:FRAGMENT))))

program-bindings names each binding's kind in clsl's terms, and program-layout-entries gives each bind group's layout, by index from 0, as cl-gfx's create-bind-group-layout takes it. A :sampler-buffer is a texel buffer, :uniform-texel-buffer in cl-gfx's terms.

Uniform blocks

A group's scalars, vectors and matrices, structs and arrays of them, from &uniform and define-uniform, are the members of one uniform block, laid out by std140. program-uniforms lists them with their offsets, :frame's first, program-uniform-block-size gives a block's size, and pack-uniforms writes its bytes:

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

(define-shader sunny :fragment ((normal :vec3) &uniform (albedo :vec3) (sky :vec3 :group :frame))
  "ALBEDO lit by the sun, and SKY all round."
  (vec4 (+ (lit albedo normal) (* 0.1 sky)) 1))

(let ((program (compile-program '(:fragment sunny) :target :vulkan)))
  (values (mapcar (lambda (uniform)
                    (list (uniform-name uniform) (uniform-group uniform) (uniform-offset uniform)))
                  (program-uniforms program))
          (program-uniform-block-size program :frame)
          (pack-uniforms program :frame :values '(:sky #(0.5 0.7 1.0)))))
;; => ((SKY :FRAME 0) (*SUN* :FRAME 16) (ALBEDO :MATERIAL 0))
;;    32
;;    #(0 0 0 63 51 51 51 63 0 0 128 63 0 0 0 0 0 0 0 0 0 0 128 63 0 0 0 0 0 0 0 0)

pack-uniforms writes each uniform of define-uniform from its variable's value, as the thread that packs sees it, and the others from :values, a plist of their names, as keywords or as themselves, and their values, as pack-value takes them. A uniform it has no value for is an error. Given a :buffer, a vector of bytes at least the block's size, it writes there and allocates nothing, so it may run every frame; without one it makes a new vector. The bytes go in a uniform buffer bound at binding 0 of the group's bind group.

define-uniform

(define-uniform name type &key value (group :material) documentation metadata)

Defines name as defvar does, with value if it is unbound, and makes it a uniform of type, a scalar, vector or matrix of numbers, or a struct or an array of a length of them, in group. Shader code that reads name reads the uniform, and a shader that reads it, or calls a function that does, has it among its uniforms, a member of its group's block, as *sun* above is. documentation is the variable's, and metadata, a plist, is for tools such as an inspector, which uniform-metadata gives them; uniform-documentation gives the documentation. No shader code assigns it.

Textures

A texture uniform is a texture with its sampler, as cl-gfx binds them: a :texture binding of an image and a sampler.

Types Holds
:sampler1d, :sampler2d, :sampler3d a texture of floats of one, two or three dimensions
:sampler-cube six square faces, read by direction
:sampler1d-array, :sampler2d-array, :sampler-cube-array arrays of layers of those, or of cubes
:sampler2d-ms, :sampler2d-ms-array a multisampled texture, several samples a texel, or an array of them
:sampler-buffer a texel buffer: a row of texels that a buffer holds, read by index
:isampler2d, :usampler-cube-array and so on each of the above of signed or unsigned integers, whose sampler takes the nearest texel: Vulkan filters no texture of integers
:sampler1d-shadow, :sampler2d-shadow, :sampler-cube-shadow and their arrays a shadow texture: depths, with a comparing sampler

A texture's coordinates depend on its shape:

Texture sample and gather take, floats fetch takes, integers texture-size gives
one-dimensional u, from 0 to 1 across it x, its texel's index an :int
two-dimensional (vec2 u v), from the top left (ivec2 x y), from the top left an :ivec2
three-dimensional (vec3 u v w) (ivec3 x y z) an :ivec3
cube a direction from its centre, a :vec3 none: a cube is read by direction a face's size, an :ivec2
an array of these the same, then the layer, a float that is rounded the same, then the layer the same, then how many layers
multisampled none (ivec2 x y), then an array's layer the same as fetch takes
buffer none the texel's index an :int, how many texels it has
Function Takes and gives
(sample texture coordinates &key level bias ddx ddy offset compare) the texel at coordinates, read through the sampler: a :vec4 filtered as the sampler says, or an :ivec4 or :uvec4 of the nearest texel of integers. In a fragment shader its level of detail follows from how fast coordinates change between neighbouring fragments, so it needs uniform control flow; elsewhere it is level 0. :level, a :float, gives the level; :bias adds to the one it picks; :ddx and :ddy give the derivatives it picks it from. :offset, :int values from -8 to 7 written in the code, moves the texels it reads. Of a shadow texture, :compare, a depth, gives the share of the texels that pass, a :float
(fetch texture texel &key level sample offset) the texel at texel as it is, unfiltered. :level is the mip level, 0 by default, and of a multisampled texture :sample, which it needs, the sample. A texel, level, layer or sample the texture does not have reads zero
(gather texture coordinates &key component offset compare) the component, 0 to 3 written in the code, 0 by default, of each of the four texels a sample of the first level at coordinates blends: the bottom left texel's, the bottom right's, the top right's and the top left's. Of a shadow texture, each compared with :compare, 1.0 or 0.0. Of a two-dimensional texture or a cube, an array or not
(texture-size texture [level]) the size of the mip level, 0 by default, in texels; a level it does not have is of size 0
(texture-levels texture) how many mip levels it has; not of a multisampled or buffer texture
(texture-samples texture) how many samples each texel of a multisampled texture has
(texture-query-lod texture coordinates) the level of detail a sample there picks, a :vec2: the level it reads, and the one it computed. In fragment shaders only, in uniform control flow

A texture is no value: shader code passes it to these, or to a shader function, as it is.

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

(program-layout-entries (compile-program '(:fragment shaded-surface) :target :vulkan))
;; => (NIL ((1 :TEXTURE :VISIBILITY (:FRAGMENT)) (2 :TEXTURE :VISIBILITY (:FRAGMENT))))

texture-type-shape gives the shape of the texture a GPU layer binds to a texture or storage image type, in cl-gfx's dimensions or :buffer for a texel buffer, then whether it is multisampled, and whether it holds depths; texture-type-p and image-type-p tell the two kinds of type apart:

(texture-type-shape :usampler2d-ms-array)  ; => :2D-ARRAY, T, NIL
(texture-type-shape :image-buffer)         ; => :BUFFER, NIL, NIL
(texture-type-shape :sampler-cube-shadow)  ; => :CUBE, NIL, T
(image-type-p :uimage3d)                   ; => T

Storage images

A storage image is read and written a texel at a time, through no sampler. Its types are :image1d, :image2d, :image3d, :image-cube, their arrays, :image2d-ms, :image2d-ms-array and :image-buffer, of floats, and each of integers too, such as :iimage2d and :uimage-buffer. Its :format is how it stores its texels, in cl-gfx's names, which are WebGPU's:

Kind Formats
floats :rgba32-float, :rgba16-float, :rg32-float, :rg16-float, :r32-float, :r16-float, :rg11b10-ufloat; normalised, :rgba16-unorm, :rgb10a2-unorm, :rgba8-unorm, :rg16-unorm, :rg8-unorm, :r16-unorm, :r8-unorm, :rgba16-snorm, :rgba8-snorm, :rg16-snorm, :rg8-snorm, :r16-snorm, :r8-snorm
signed integers :rgba32-sint, :rgba16-sint, :rgba8-sint, :rg32-sint, :rg16-sint, :rg8-sint, :r32-sint, :r16-sint, :r8-sint
unsigned integers :rgba32-uint, :rgba16-uint, :rgb10a2-uint, :rgba8-uint, :rg32-uint, :rg16-uint, :rg8-uint, :r32-uint, :r16-uint, :r8-uint

A texel's coordinates are those fetch takes, but a cube's, which are (ivec3 x y face), the face from 0 to 5 for +X, -X, +Y, -Y, +Z and -Z, and of a cube array six times the cube plus the face.

Function Takes and gives
(image-load image texel &key sample) the texel at texel, a vector of four of the image's components: the channels its format lacks 0, but alpha 1. A texel or sample outside the image reads zero. The image's :access is :read or :read-write
(image-store image texel value &key sample) stores value, four components, at texel, as the format stores it, and gives no value. A texel outside the image is not written. The image's :access is :write or :read-write
(image-size image) its size, as texture-size gives a texture's
(image-samples image) how many samples each texel of a multisampled image has
(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))))

(let ((program (compile-program '(:compute blur-rows) :target :vulkan)))
  (mapcar (lambda (resource)
            (list (resource-name resource) (resource-binding resource) (resource-kind resource)
                  (resource-access resource) (resource-format resource)))
          (program-resources program)))
;; => ((SOURCE 1 :IMAGE :READ :RGBA16-FLOAT) (TARGET 2 :IMAGE :WRITE :RGBA16-FLOAT))

They are :storage-texture bindings in cl-gfx's terms, or :storage-texel-buffer for an :image-buffer. A multisampled storage image needs the device's :multisample-storage-textures feature, and a storage image or buffer that a stage before the fragment shader writes, :vertex-stores: program-features lists them.

Storage buffers

With :storage :read or :storage :read-write, a uniform is a storage buffer, which shader code reads, and with :read-write writes, whole or a part at a time. It is laid out by std430. An (:array type), or a struct whose last slot is one, has as many elements as the buffer has room for, which length counts as the shader runs; it is only ever a storage buffer's. A storage buffer of a struct holds the struct itself; one of another type holds it alone.

(define-struct histogram
  (total :uint)
  (counts (:array :uint)))

(define-shader count-up (:compute :workgroup-size (64))
    (&uniform (data histogram :storage :read-write))
  "Count each invocation into DATA."
  (let ((i (x (global-invocation-id))))
    (when (< i (length (histogram-counts data)))
      (atomic-incf (histogram-total data))
      (setf (aref (histogram-counts data) i) i))))

(layout-size 'histogram :layout :std430)   ; => 4

The struct takes 4 bytes, and each element of its last slot (array-stride '(:array :uint) :layout :std430), 4 more. A storage buffer no stage writes is a :read-only-storage-buffer binding, and one that some stage writes a :storage-buffer.

Layout

Layout is not part of a type: the same type is laid out by std140 in a uniform block, and by std430 in a storage buffer.

  • std140 aligns a :vec2 to 8 bytes, and a :vec3 and a :vec4 to 16, and a scalar may follow a :vec3 in its last 4 bytes. A :double is 8 bytes, a :dvec2 is aligned to 16, and a :dvec3 and a :dvec4 to 32. A complex number is laid out as its parts, a :vec2 or a :dvec2, and so is a float pair, a :vec2 of its high and low parts; a vector of pairs is an array of them. A matrix is an array of its columns, each aligned to 16 bytes, and an array's elements are aligned to 16 bytes. A struct is aligned to its most aligned slot, rounded up to 16 bytes, and its size is rounded up to its alignment.
  • std430 is the same, but nothing is rounded up to 16: arrays and matrices of scalars and :vec2 values are packed, and a struct is aligned to its most aligned slot.

layout-size, layout-alignment, array-stride and struct-layout give these, std140 unless :layout :std430 is given. With the struct light of Definitions and Stages:

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

(list (layout-size :vec3) (layout-alignment :vec3)
      (layout-size '(:array :float 4)) (layout-size '(:array :float 4) :layout :std430)
      (array-stride :float) (array-stride :float :layout :std430)
      (layout-size :mat3) (layout-size '(:array light 4))
      (layout-size :dfvec3) (layout-size :dfvec3 :layout :std430))
;; => (12 16 64 16 16 4 48 128 48 24)

(struct-layout 'light)
;; => (#S(SLOT-LAYOUT :NAME POSITION :TYPE :VEC3 :OFFSET 0)
;;     #S(SLOT-LAYOUT :NAME COLOR :TYPE :VEC3 :OFFSET 16)
;;     #S(SLOT-LAYOUT :NAME RADIUS :TYPE :FLOAT :OFFSET 28))
;;    32

radius fits in the last 4 bytes of color, so the struct takes 32 bytes by either layout.

Packing values

pack-value writes one value of a type into a vector of bytes, at an :offset, by std140 or std430, and returns the vector. A scalar is a Lisp real, made a float of its kind; a complex number a Lisp number; a float pair a Lisp real, as dfloat-parts splits it; a vector a sequence of its components; a matrix a sequence of its components, column by column, as cl-gmath keeps them; an array a sequence of its elements; and a struct a plist of its slots' values, as its constructor takes them. It writes the bytes the value covers, and leaves the padding between them as it was.

(pack-value :vec2 #(1.0 -2.0) (make-array 8 :element-type '(unsigned-byte 8)))
;; => #(0 0 128 63 0 0 0 192)

(let ((buffer (make-array 32 :element-type '(unsigned-byte 8) :initial-element 0)))
  (pack-value 'light (list :position #(0.0 4.0 0.0) :color #(1.0 1.0 1.0) :radius 8.0)
              buffer :layout :std430))
;; => #(0 0 0 0 0 0 128 64 0 0 0 0 0 0 0 0 0 0 128 63 0 0 128 63 0 0 128 63 0 0 0 65)

pack-uniforms takes the same values, an array of structs among them:

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

(length (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)))))
;; => 128

pack-value allocates nothing given doubles and float pairs as double-floats, vectors and matrices of them as vectors of double-floats, and complex numbers of doubles as (complex double-float) values.