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
:sampler2dor:usampler-cube-array, is a texture with its sampler, read withsample,fetchandgather; - 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.
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
:vec2to 8 bytes, and a:vec3and a:vec4to 16, and a scalar may follow a:vec3in its last 4 bytes. A:doubleis 8 bytes, a:dvec2is aligned to 16, and a:dvec3and a:dvec4to 32. A complex number is laid out as its parts, a:vec2or a:dvec2, and so is a float pair, a:vec2of 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
:vec2values 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.