machdocs
Home GitHub

GPU shaders

A spirv target compiles ordinary mach into a finished SPIR-V module. A shader is written in the same language as the rest of the project - the same records, the same vectors, the same comptime - and a small set of decorators say which functions are pipeline stages and which module-scope variables the pipeline binds.

Note

Every directive on this page is accepted on every target, because which target a module is built for is not a property of its source. Only a target that forms pipeline stages acts on them: on a machine target a staged function is compiled normally and the interface variables are ordinary globals.

The target

A spirv target's object output is a complete, self-contained module rather than a link input, so the build delivers a module tree and runs no link phase.

[target.gpu]
isa = "spirv"
os  = "freestanding"
abi = "spirv"
# no `of`: the finished-module format resolves on its own
mach build . --target gpu     # writes out/gpu/<profile>/obj/<module>.spv

A static or shared artifact kind, and mach test, are refused by name: there is no archive, shared object, or executable form for a module. See Manifest.

stage(str): a pipeline stage

Marks a function as the entry point of a graphics or compute pipeline stage. The value set is closed - "vertex", "fragment", "compute" - and an unrecognized value is a compile error, not a module that quietly forms no stage.

#[stage("vertex")]
fun vertex_main() { }

#[stage("fragment")]
fun fragment_main() { }

A staged function takes no parameters and returns nothing. A pipeline stage has no caller: its inputs arrive through input interface variables and its results leave through output ones, so there is no argument list or return value to carry them. A staged function with either is rejected.

A module that declares any stage is a shader module, and that changes the whole artifact rather than just the one function. A shader module carries entry points and no external linkage at all; a module with no stage is a library module, which publishes each function as a linkage export so a consumer can find it. The two are exclusive - a Vulkan consumer refuses a module carrying linkage - so adding the first #[stage(...)] to a module stops it exporting its functions.

The entry point's name, as a pipeline-creation call looks it up, is the function's bare source name. A shader module has no linker symbols to mangle.

workgroup(x, y, z)

Sizes the workgroup of a #[stage("compute")] function. It requires a stage on the same function - without one it would silently mean nothing - and applies only to the compute stage.

#[stage("compute")] #[workgroup(64, 1, 1)]
fun compute_main() { }

Omitted, a compute stage takes the single-invocation default (1, 1, 1). The dimensions are always declared in the emitted module, since a compute stage that does not state its workgroup size is not one a consumer can dispatch.

The interface

A stage reads and writes module-scope variables that the pipeline binds. These directives say which kind each variable is. They apply only to module-level val / var bindings, and a variable carries exactly one of them - they are mutually exclusive.

#[input(0)]            var in_position: f32x4;
#[output(0)]           var out_colour:  f32x4;
#[builtin("position")] var position:    f32x4;

rec Camera { view: f32x4; proj: f32x4; }
#[uniform(0, 0)] var camera: Camera;

rec Particles { pos: [64]f32x4; }
#[storage(0, 1)] var particles: Particles;

#[sampler(1, 0)] var albedo: Sampler2D;

input and output number a varying with a location, which is how one stage's outputs line up with the next stage's inputs: the producer's #[output(0)] feeds the consumer's #[input(0)].

builtin(str)

Names a value the pipeline supplies or consumes instead of one a location carries. The set is closed.

ValueMeaningTypeDirection
"position"clip-space vertex positionf32x4written
"point_size"rasterized point sizef32written
"vertex_index"index of the current vertexu32read
"instance_index"index of the current instanceu32read
"frag_coord"fragment window coordinatef32x4read
"global_invocation"compute global invocation idu32x3read
"local_invocation"compute local invocation idu32x3read
"workgroup_id"compute workgroup idu32x3read

The direction is a property of the built-in, not something you restate - a stage writes its position and reads what the pipeline hands it - so there is no input/output marker to pair with builtin, and none that could disagree with it.

The type is a property of the built-in too, and it is a requirement rather than a suggestion: the pipeline binds the variable itself, so a wider or narrower one is an invalid module rather than a wasteful one. Declaring a built-in at any other type is a compile error naming both the declared type and the required one. The two integer rows accept i32 as well as u32, because the compiler carries an integer's width and not its sign and the emitted type is sign-less either way.

uniform and storage blocks

uniform binds a read-only block by descriptor set and binding; storage binds a read-write buffer the same way. Both types must be a rec: a block has a host-visible layout, and a bare scalar or vector has no block layout for a pipeline to bind. Wrap a single value in a one-field record. Each is emitted with its Block decoration and an explicit byte offset on every member, taken from the same layout the rest of the compiler uses, so what the shader reads is what the host wrote.

They differ in one place: their layout rules. A uniform block follows std140-shaped rules, under which an array's stride is rounded up to 16 - which mach's own layout does not do, so an array of anything narrower than 16 bytes is refused rather than silently repacked. A storage buffer follows std430-shaped rules, which use the element's natural stride, and that is mach's layout. So [8]f32 is fine in a storage block and rejected in a uniform one.

A compute stage's data path is storage: Vulkan forbids the Output storage class in a compute execution model, so a compute shader reads and writes buffers rather than varyings.

The "readonly" qualifier

storage takes memory qualifiers after the descriptor pair. There is one, and it says that nothing writes the binding.

rec Palette { columns: [512]f32x4; }
#[storage(0, 3, "readonly")] var palette: Palette;

A store through a "readonly" binding is a compile error on every target, naming the line that wrote it. That is what the qualifier buys over what the compiler works out on its own: a buffer no body in the module stores through is emitted with the SPIR-V NonWritable decoration whether or not it is marked, and Vulkan reads that decoration to decide whether a stage needs vertexPipelineStoresAndAtomics. So an accidental write does not produce a wrong module, it produces a correct one that quietly costs a hardware feature. Marking the binding turns that into a diagnostic instead.

The inference is one-sided on purpose: anything the compiler cannot follow, such as the binding's address handed to a function, counts as a write, so a missing decoration is possible and a wrong one is not.

Textures and samplers

sampler binds a handle by descriptor set and binding, at the same descriptor addressing uniform and storage use, so a host binds one the way it binds the others. Its type must be a handle type - a bodyless def carrying #[handle] - and a handle type must carry this decorator: a handle names a descriptor rather than an object with storage, so one with no descriptor address is reachable from no stage.

Sampling is an #[op(...)] declaration rather than a language form, because a sample is one SPIR-V instruction, exactly as sqrt and dot are.

#[op("spirv", "core", "OpImageSampleImplicitLod")]
fun sample(s: Sampler2D, uv: f32x2) f32x4;

#[stage("fragment")]
fun frag_main() {
    out_colour = sample(albedo, in_uv);
}

The separately-bound form works the same way, with the instruction that combines an image and a sampler declared alongside it.

#[op("spirv", "core", "OpSampledImage")]
fun combine(t: Texture2D, s: Sampler) Sampler2D;

#[sampler(1, 0)] var base_tex: Texture2D;
#[sampler(1, 1)] var base_smp: Sampler;

#[stage("fragment")]
fun frag_sep() { out_colour = sample(combine(base_tex, base_smp), in_uv); }

The combined value is handed straight to the sample rather than named: SPIR-V requires an OpSampledImage result be consumed in the block that produced it, which is the same rule that makes a handle-typed local a compile error.

op(target, set, name): a function that is an instruction

A shader needs sqrt, normalize, dot, and mix. None of them is an operator, and none of them is a call SPIR-V can make: each is one instruction. This directive says which one a function is, so that on a spirv target a call to it becomes that instruction, inline, rather than a call.

#[op("spirv", "GLSL.std.450", "Sqrt")]
pub fun sqrt(x: f32) f32;

#[op("spirv", "GLSL.std.450", "Normalize")]
pub fun normalize(v: f32x4) f32x4;

#[op("spirv", "core", "OpDot")]
pub fun dot(a: f32x4, b: f32x4) f32;

The first argument names the target - the ISA name the manifest selects with - the second the instruction set, and the third the instruction within it. All three value sets are closed and checked at compile time on every target: the directive is legal everywhere, so a typo caught only where it is acted on would go unreported on a CPU build. The parameter count is checked against the instruction's own operand count, which is not uniform across a family that looks it: Reflect takes two operands where Refract takes three, and FMin two where FClamp takes three.

SetMeaning
"core"the core opcode space; needs no import
"GLSL.std.450"the standard extended set; imported once per module, on use

The substitution is uniform: the emitted instruction's result type is the function's declared return type and its operands are the function's parameters in declaration order. That is what lets dot and length return a scalar from vectors, and refract mix a scalar operand with vector ones, without any of them being a special case.

Check the specification, not GLSL

dot is core OpDot, not a GLSL.std.450 instruction, even though GLSL spells it beside normalize and length. Check each function against the SPIR-V specification rather than against GLSL's surface.

On every target other than spirv the directive is inert and a decorated function is an ordinary function. A bodyless one - which is what the shader-side maths library uses - is then an undefined symbol, so a CPU build that calls it fails at link naming the symbol. That is the library's design choice, not a property of the directive: a decorated function may have a body, and if it does, that body is what every non-spirv target runs while spirv substitutes the instruction.

Shaders across modules

A shader can call a function defined in another module and can use an interface variable one declares, so a shared library may own a whole descriptor set. A drawn-in module contributes globals by reachability, not membership: a shared module may declare a whole set, and a shader importing it for one helper does not inherit the rest. The root is exempt - its interface list is the module's pipeline contract, declared whole.

A repeated (set, binding) pair is checked over the set one entry point reaches, across the descriptor roles rather than within one, since a #[uniform(0, 0)] and a #[storage(0, 0)] are two descriptor types claiming one slot. Within one module a repeat is a typo its author can see; across modules it is neither, and the shader importing both is where the conflict first exists. spirv-val does not catch it, because two variables at one pair are well-formed SPIR-V and the conflict is with the pipeline layout, which is not in the module. The diagnostic names the declaring module when it is not the one being compiled.

What a stage may not do

See also

  • Types - handle types, and the SIMD vectors a stage computes over
  • Decorators - the full directive set and its applicability matrix
  • Manifest - declaring a spirv target and what its build delivers