Shader-side maths for Mach: the functions that lower to a single SPIR-V instruction.
use sh: shader.math;
#[input(0)] var in_normal: f32x3;
#[input(1)] var in_light: f32x3;
#[output(0)] var out_colour: f32x4;
#[stage("fragment")]
fun frag_main() {
val n: f32x3 = sh.normalize_3(in_normal);
val d: f32 = sh.max(sh.dot_3(n, in_light), 0.0);
out_colour = sh.mix_4(ambient, albedo, f32x4{d, d, d, d});
}
sh.normalize_3 does not compile to a call. Each declaration in shader.math
carries an #[op(target, set, name)] decorator naming the SPIR-V instruction it is,
and the compiler substitutes that instruction at the call site: an OpExtInst into
GLSL.std.450 for most of them, core OpDot for the dot_* family. The extended instruction
set is imported once per module and only when something in that module uses it.
shader.texture declares the image and sampler handles and the instructions that
read them. A handle is a bodyless def carrying #[handle(target, constructor, operands...)]: the compiler knows only that a declaration with no body has its
definition supplied by the owning target, and the SPIR-V target owns what a
constructor is and what each operand means.
use tex: shader.texture;
#[sampler(0, 0)] var albedo: tex.Sampler2D;
#[input(0)] var in_uv: f32x2;
#[output(0)] var out_colour: f32x4;
#[stage("fragment")]
fun frag_main() {
out_colour = tex.sample_2d(albedo, in_uv);
}
A Sampler2D is an OpTypeSampledImage over an OpTypeImage, and it NAMES the
image it wraps rather than restating that image's operands, so the two cannot
disagree about the shape they share. Adding a handle is a declaration in that file
rather than a compiler release.
A handle's operands are the seven OpTypeImage operands, the last of them the image
format, which is FORMAT_UNKNOWN for every sampled image. The module names each
operand value, the 42 formats included, so a shape the library does not declare is
one def in the consumer's own source.
Beside the implicit-lod samples it declares the explicit-lod ones a compute stage
samples with (sample_lod_*, sample_grad_*), the depth images and their comparisons
(sample_compare_*, sample_compare_lod_*, gather_compare_*), gathers
(gather_*), integer-coordinate fetches (fetch_*) and the size and level queries
(size_*, levels_*). A multisampled image is never sampled and is not declared, and
the target refuses the Rect and SubpassData dimensionalities by naming the
operand.
A storage image is read and written texel by texel, with no sampler, and binds
through #[storage] like a buffer, "readonly", "writeonly" and "coherent"
included:
use tex: shader.texture;
#[storage(0, 0, "readonly")] var src: tex.StorageImage2D;
#[storage(0, 1, "writeonly")] var dst: tex.StorageImage2D;
#[builtin("global_invocation")] var gid: u32x3;
#[stage("compute")]
#[workgroup(8, 8, 1)]
fun blit() {
val at: i32x2 = i32x2{gid[0]::i32, gid[1]::i32};
tex.image_write_2d(dst, at, tex.image_read_2d(src, at));
}
The StorageImage* handles are FORMAT_UNKNOWN, so one handle binds a view of any
format, under the storage_read_without_format and storage_write_without_format
extensions that vulkan1.3 selects. The *R32f, *R32i, *R32ui, *R64i and
*R64ui images carry the formats Vulkan defines an image atomic on, and
image_texel_* gives an atomic in shader.atomic the address of one of their
texels. A texel buffer is a TexelBuffer, fetched and bound by #[sampler], or a
StorageTexelBuffer, read and written and bound by #[storage].
A compute stage is a #[stage("compute")] function sized by #[workgroup(x, y, z)], and it emits a GLCompute entry point with that LocalSize. It reads and
writes #[storage(set, binding)] buffers rather than varyings, since Vulkan has no
Output class in a compute stage, and a buffer marked "readonly" is emitted
NonWritable. Its ids are the global_invocation, local_invocation and
workgroup_id built-ins, each a u32x3. The maths in shader.math works in a
compute body exactly as it does in a fragment one.
use sh: shader.math;
rec Field { values: [256]f32; }
#[storage(0, 0, "readonly")] var src: Field;
#[storage(0, 1)] var dst: Field;
#[builtin("global_invocation")] var gid: u32x3;
#[stage("compute")]
#[workgroup(64, 1, 1)]
fun step() {
val i: u32 = gid[0];
dst.values[i] = sh.sqrt(src.values[i]);
}
All of that is the compiler's. What a compute body calls is this library's, each
one a bodyless #[op] declaration like the maths:
shader.sync: theSCOPE_*andSEMANTICS_*operand constants,control_barrierandmemory_barrier, and the wrappersworkgroup_barrier,storage_barrier,image_barrierandsubgroup_barrier.shader.atomic: every SPIR-V atomic overu32,i32,u64andi64, and the load, store, exchange, add, min and max overf16,f32andf64.shader.subgroup: theOpGroupNonUniform*families, theGROUP_*operations and theQUAD_SWAP_*directions.shader.texture: the storage images and texel buffers above.
use at: shader.atomic;
use sy: shader.sync;
rec Histogram { bins: [64]u32; }
#[storage(0, 0)] var histogram: Histogram;
#[shared] var local_bins: [64]u32;
#[builtin("local_invocation_index")] var lid: u32;
#[stage("compute")]
#[workgroup(64, 1, 1)]
fun count() {
at.atomic_add_u32(?local_bins[lid & 63], sy.SCOPE_WORKGROUP, sy.SEMANTICS_RELAXED, 1);
sy.workgroup_barrier();
at.atomic_add_u32(?histogram.bins[lid], sy.SCOPE_DEVICE, sy.SEMANTICS_RELAXED, local_bins[lid]);
}
An atomic's pointer takes its storage class from the call, so one declaration
covers a storage buffer, workgroup memory and an image texel. A scope, a memory
semantics, a group operation and a broadcast lane are constants SPIR-V takes by
value, and each is refused at the call unless it is a literal or a pub val.
That is also why the wrappers are only the four barriers. Each writes its operands as
constants in its own body, so they reach the instruction as constants whether the
wrapper is inlined (O2) or called (O0), and conform_sync_comp holds that at both
levels. A wrapper that took a scope or semantics as a parameter would compile at O2,
where inlining folds it, and be refused at O0, so none is shipped.
Every type other than a 32-bit integer atomic, and every subgroup family but
Elect, needs a Vulkan device feature that the target names in its extensions
(buffer_float32_atomic_add, subgroup_arithmetic, and so on). The module headers
list them, and a call whose feature the target does not select is refused with its
name.
std.math.sqrt_f32 promises a documented IEEE result and delivers it on every
target. GLSL.std.450 Sqrt promises what the driver does. They are different
functions, and substituting one for the other across a target boundary gives an
answer that is nearly right with nothing to report. Keeping the shader set in its
own module makes the difference visible where it matters, at the call site.
Nothing here is IEEE-exact and nothing here is portable between drivers. SPIR-V
leaves most of GLSL.std.450 implementation-defined in precision; Vulkan's precision
table gives ULP bounds for some entries and none at all for Pow, Exp, Log and
the trigonometric family. Two GPUs may return different bits for the same input,
and so may one GPU under two driver versions.
Use it to shade a pixel. Do not use it for anything compared for equality across machines.
Nothing here has a body. On a SPIR-V target that is invisible, because no call to any of these is emitted as a call. On any other target, a program that calls one fails to link:
error: undefined symbol: _M6shader4mathN4sqrt
That is deliberate. The alternative - native bodies, so the same source also runs
on a CPU - would mean writing a second float-maths library beside the one
mach-std already owns, and the only sensible implementation of each would be to
call std.math. That would make sh.sqrt and math.sqrt_f32 the same function on
a CPU and different functions on a GPU: the one outcome of the three that never
errors and is always slightly wrong. A link failure at build time is loud and
early.
f32, f32x2, f32x3 and f32x4, and the integer rows over 16-, 32- and 64-bit
integers at the same widths, with 254 entries across them.
A scalar entry takes the plain name. A vector entry always carries its lane count
after an underscore: sqrt, sqrt_2, sqrt_3, sqrt_4. The geometric entries
follow the same rule despite having no scalar form, so they are dot_3 and
normalize_3, and cross_3 carries its width even though a cross product exists
at no other one.
The separator is not decoration. exp2 and log2 are the base-2 entries, where
the digit belongs to the instruction rather than to a width, so a bare digit
suffix would make the 2-lane exp collide with the scalar exp2. Because the two
also differ in operand type, reaching for the wrong one is a type error rather
than a wrong result.
modf and frexp return one part of a value and store the other through a
pointer, which may address a local, a #[shared] variable or a storage buffer:
sh.modf(x, ?whole). frexp stores its exponent as an i32 with as many lanes as
the value.
The 32-bit float family is the one unsuffixed type in shader.math. The integer
rows carry their component type after an underscore and then the lane count:
min_i32, min_u64_3, find_msb_i32_4. The instruction follows the signedness,
so min_i32 is SMin and min_u32 is UMin, and abs_* and sign_* exist only
signed. abs, sign, min, max and clamp take 16-, 32- and 64-bit integers, and
a 16- or 64-bit entry needs the target's int16 or int64 where it names an env.
The bit scans find_lsb and find_msb are 32-bit only, the one width GLSL.std.450
defines them on.
shader.atomic and shader.subgroup grow along the component type as well, so an
entry over a value the caller chooses carries that value's mach type after the
underscore: atomic_add_u32, atomic_add_f32, subgroup_min_i32. No type is the
unsuffixed default there. The signed and unsigned forms differ where the instruction
does, so atomic_min_i32 is OpAtomicSMin and atomic_min_u32 is OpAtomicUMin. An
entry whose operand types are fixed, like subgroup_ballot, takes the plain name.
shader.texture names the handle instead, as it always has: image_read_2d_u,
fetch_buffer_i.
The rule is uniform so that the axes this module grows along - the instruction,
the lane count, and eventually the component type - each stay a drop-in. An
unsuffixed dot meaning the 4-wide form would have had to break the first time a
shader wanted the 3-wide one, which is the common case in lighting.
Add it with mach dep add, which declares the dependency at a caret range over
the newest compatible release and realizes it:
mach dep add . shader --git https://github.com/briar-systems/mach-shaderThat writes this stanza to mach.toml:
[dep.shader]
git = "https://github.com/briar-systems/mach-shader"
version = "^0.6.0"Requires Mach 6.10. The manifest declares mach = "^6.10", the release that
declares a signed-format image over a signed texel type and carries the Modf,
Frexp and integer GLSL.std.450 rows.
src/math.mach is generated by tools/genmath.py from a table of instructions
and a list of widths, because the library is that rule applied 254 times and
hand-maintaining it would let the widths drift apart. src/atomic.mach and
src/subgroup.mach are generated the same way, by tools/genatomic.py and
tools/gensubgroup.py, from their instructions and the types each takes. Each
generator passes its output through mach fmt -, so it needs mach 5.1.0 or newer
(see tools/README.md), either the mach on PATH or the one the MACH
environment variable names, and a formatted tree never drifts from it.
The library itself compiles to nothing - every declaration is bodyless - so
building it proves nothing. conform/ is the actual check: conform_frag, a
fragment shader generated by tools/genmath.py --conform, calls every entry in
shader.math, writing each result to its output so no call can be eliminated. A
decorator naming an instruction that does not exist, or a signature SPIR-V will not
accept at that width, fails there. Beside it,
conform_tex_frag binds and samples every texture handle, conform_sample_frag
makes every comparison, gather, fetch and query, and conform_comp is a compute
stage with a workgroup size, every compute built-in and a read-only and a read-write
buffer. conform_atomic_comp calls every atomic on a storage buffer and on workgroup
memory, conform_image_comp reads, writes and queries every storage image and texel
buffer and reaches each atomic-format texel, conform_sync_comp calls every barrier
and conform_subgroup_comp every subgroup operation.
conform/verify.sh (needs spirv-tools) builds them at O0 and O2, runs spirv-val
over each both as SPIR-V and against vulkan1.3, and checks the disassembly for
what validation alone would not catch: tools/opcheck.py requires every #[op]
declared in shader.math, shader.texture, shader.sync, shader.atomic and
shader.subgroup to be called by a conformance shader and to reach its module, and
tools/mathcheck.py requires each shader.math entry to leave its own instruction,
at its own types, in conform_frag.
Versions follow semver, judged from what has landed on dev since the last tag:
a breaking change is major, a feature is minor, and a fix is patch.
- On a
chore/<issue>branch offdev, setversioninmach.toml, rename## [Unreleased]in CHANGELOG.md to## [X.Y.Z] - YYYY-MM-DD, open a fresh## [Unreleased]above it, and update the compare links. Merge it intodev. - Merge
devintomainwith a merge commit. - Tag that merge
vX.Y.Z(annotated) and push the tag. The Release workflow (.github/workflows/cd.yml) checks the tag against the manifest, builds on every host the library ships to, and publishes the GitHub release with that version's changelog section as notes. Watch that run to success. - Merge
mainback intodev.
gh workflow run cd.yml --ref dev rehearses the same path without a tag,
and deletes its draft when it finishes.
The GLSL.std.450 set is much larger than this. The omissions are reasoned rather than pending:
ModfStruct,FrexpStructreturn a two-member struct that is not spellable as a Mach return type.modfandfrexpgive the same two results through a pointer.Determinant,MatrixInverseneed a matrix type, which Mach does not have by design - matrices belong in a library over vectors.- the
Pack*/Unpack*family reinterprets one width's bits as another's, which logical addressing forbids. InterpolateAt*need theInterpolationFunctioncapability, which the emitted module does not declare.IMixwas removed from GLSL.std.450, so there is no row to declare.NMin,NMax,NClampdiffer from theFforms only in NaN handling. Both spellings existing with no visible difference is a trap; theFforms are what GLSL's ownmin/max/clampcompile to.
Each of those is a row in the compiler's table and a declaration here on the day its blocker clears.
MIT. See LICENSE.