#shader Region

Purpose

A #shader region holds the programs a GPU runs: vertex, pixel and compute shaders, written in tape. The compiler checks them with the rest of the module, and the standard library’s shader compiler turns each one into GLSL (for OpenGL) and the Metal Shading Language (for Metal) while the program compiles. You never write GLSL or Metal yourself. In tape code, each shader’s name is a gpu.Shader constant that the gpu module makes pipelines from.

tape
#code
import gpu from "gpu";

pub struct Particle { pos: gpu.Vec2; vel: gpu.Vec2; }
pub struct Step { dt: f32; count: u32; }
pub struct View { scale: gpu.Vec2; tint: gpu.Vec4; }

fn pipelines(dev: *gpu.Device) -> void or gpu.GpuError {
    let step = dev.compute_pipeline(&advance) or return;       // advance is a gpu.Shader
    let dots = dev.render_pipeline(&dot_vs, &dot_ps, gpu.RenderOptions {
        primitive: gpu.Primitive.TriangleStrip;
    }) or return;
    let s_particles = step.slot("ps");                          // slots by parameter name
    let s_instances = dots.slot("p");
}

#shader
import gpu from "gpu";

compute(64) fn advance(ps: []Particle, u: uniform Step) {
    let i = gpu.global_id().x;
    if (i >= u.count) { return; }
    var p = ps[i];
    p.pos = p.pos + p.vel * u.dt;
    ps[i] = p;
}

pub struct DotOut { position: gpu.Vec4; tint: gpu.Vec4; }

vertex fn dot_vs(corner: gpu.Vec2, p: instance Particle, u: uniform View) -> DotOut {
    let at = p.pos + corner * 0.01;
    return DotOut { position: gpu.vec4(at.x * u.scale.x, at.y * u.scale.y, 0.0, 1.0);
                    tint: u.tint; };
}

pixel fn dot_ps(v: DotOut, u: uniform View) -> gpu.Vec4 {
    return v.tint;
}

The region needs import gpu from "gpu"; (in the region or in #code: one import serves both). A shader written wrong is a compile error at its line, from tape check, tape run and tape build alike, on every platform: the GLSL and the Metal text are both made wherever you compile.

Stages

A stage is a function with a stage word before fn:

DeclarationRunsReturns
vertex fn name(params) -> Outonce per vertexa struct with a field position: gpu.Vec4 (clip space)
pixel fn name(v: Out, params) -> gpu.Vec4once per covered pixelthe pixel’s colour, premultiplied
compute(x) fn name(params)once per invocationnothing
  • compute(x), compute(x, y) and compute(x, y, z) give the workgroup size: constant integers, each 1 to 1024. compute fn without it is an error.
  • A vertex shader’s output struct passes its other fields on to the pixel shader, interpolated across the triangle (integer fields are passed flat, not interpolated).
  • A pixel shader’s first parameter is that same struct, interpolated. The render pipeline checks that the vertex shader’s output and the pixel shader’s input are the same struct.

Clip space is the same on every backend: x and y from -1 to 1 with y up, z from 0 to 1.

Parameters

A stage’s parameters are where its data comes from. Their types say what each one is:

ParameterIsThe program binds
vals: []Ta storage buffer, read and writtena buffer with gpu.STORAGE
vals: []const Ta storage buffer, read onlythe same
ctr: *Ta storage buffer holding one struct (counters, a header)the same
atlas: gpu.Texture2Da texture of floatsa texture and a gpu.Sampler
ids: gpu.UTexture2Da texture of unsigned integers (R32u)the same
u: uniform Tthe stage’s constants for one draw or dispatch: a struct, at most one per stagea value of T
corner: T (vertex)the vertex’s attributes: a scalar, a vector or a struct of thema buffer with gpu.VERTEX
p: instance T (vertex)the same, one per instancea buffer with gpu.VERTEX, drawn instanced
v: Out (pixel, first)the vertex shader’s outputnothing: the pipeline joins the stages

One buffer can be storage for one shader and a vertex stream for another: a compute shader writes the particles, and the render pass draws the same buffer as instances, with no copy (make it gpu.STORAGE | gpu.VERTEX).

Slots and uniforms

The program never counts bindings. It looks each one up by the parameter’s name once, next to the pipeline, and keeps the gpu.Slot. The uniforms are a struct of your own: gpu.gen_uniforms(T) makes a setter for it, uniforms_T(&pass, &u), called in a pass before the draw or dispatch.

tape
#code
import gpu from "gpu";

pub struct Particle { pos: gpu.Vec2; vel: gpu.Vec2; }
pub struct Step { dt: f32; count: u32; }

@tape { gpu.gen_uniforms(Step); }          // makes uniforms_Step(pass, u)

fn step_all(dev: *gpu.Device, particles: *const gpu.Buffer, n: u32) -> void or gpu.GpuError {
    let step = dev.compute_pipeline(&advance) or return;
    let s_ps = step.slot("ps");             // the parameter's name
    var u = Step { dt: 0.016; count: n; };
    var cp = dev.compute();
    cp.pipeline(&step);
    uniforms_Step(&cp, &u);
    cp.storage(s_ps, particles, 0);
    cp.dispatch_for(n as i64);              // n invocations, in groups of 64
    cp.end();
}

#shader
import gpu from "gpu";

compute(64) fn advance(ps: []Particle, u: uniform Step) {
    let i = gpu.global_id().x;
    if (i >= u.count) { return; }
    ps[i].pos = ps[i].pos + ps[i].vel * u.dt;
}
  • A render pipeline joins its vertex and pixel stages by name: a parameter of the same name in both stages is one binding (and must be the same kind of resource in both), and uniform parameters in both stages are the pipeline’s one uniform struct, set once for both.
  • slot with a name the pipeline lacks traps, naming it.
  • uniforms_T passes the struct’s bytes and its name; the pass traps if the pipeline’s uniform struct is another one or has another size.
  • A dispatch or draw with a slot or the uniforms never set traps, naming what is missing.

Sharing declarations with #code

The region’s structs, enums, consts and helper functions are the module’s, like those of #code, and a struct declared in #code is usable in a shader. The particle struct the compute shader writes is the one the program fills on the CPU:

tape
#code
import gpu from "gpu";
import io from "io";

fn main() -> i32 {
    io.println("{cell_of(gpu.vec2(3.5, 2.0), 10)} of {CELLS}");   // 23 of 100
    return 0;
}

#shader
import gpu from "gpu";

const CELLS: u32 = 100;

fn cell_of(p: gpu.Vec2, width: u32) -> u32 {
    return (p.y as u32) * width + (p.x as u32);
}

compute(16, 16) fn mark(cells: []u32) {
    let id = gpu.global_id();
    let c = cell_of(gpu.vec2(id.x as f32, id.y as f32), 16);
    if (c < CELLS) { cells[c] = 1; }
}

A helper is ordinary tape, so #code can call it on the CPU. A stage function can’t be called: in #code its name is the gpu.Shader value (cannot call a value of type 'gpu.Shader').

A helper can take a buffer or texture as a parameter of its own; what it is given must be one of the stage’s resource parameters (or of the calling helper’s):

tape
#shader
import gpu from "gpu";

fn at(din: []const u32, x: u32, y: u32, width: u32) -> u32 {
    return din[y * width + x];
}

compute(64) fn copy_row(din: []const u32, dout: []u32) {
    let i = gpu.global_id().x;
    dout[i] = at(din, i, 0, 64);
}

What a shader can use

Inside #shader the language is tape’s, cut down to what a GPU runs.

Types: bool, i32, u32, f32; gpu’s vectors and Mat4; structs and fixed arrays ([4]u32) of these; enums, which are their integers on the GPU. Not i64, u64 or f64 (GPUs lack them), string, optionals, slices other than parameters, or pointers other than a *T parameter and the & of an atomic.

Statements: let, var, assignment (=, +=, …), if/else, while, for (i in a..b), break, continue, return. Not match, defer, or, fire, or for over anything but a range.

Calls: the region’s helpers and gpu’s functions: the vectors’ methods and operators, the math, and the intrinsics below. A helper may not call itself, directly or through another. A function of #code or of another module is not a shader function and is an error at the call.

The region holds imports, structs, enums, consts and functions, and nothing else:

  • no var or let at the top level: a shader reads only its parameters, consts and literals;
  • no components, threads, @tape blocks or @link;
  • no methods, extern fn or @tape fn (vector maths is gpu’s methods, which shaders call);
  • no union or tagged.

Vectors and matrices

TypeLanes
gpu.Vec2, gpu.Vec3, gpu.Vec4f32
gpu.IVec2, gpu.IVec3, gpu.IVec4i32
gpu.UVec2, gpu.UVec3, gpu.UVec4u32
gpu.Mat4four Vec4 columns, c0 to c3

They are plain structs (v.x, v.y, …) that work on the CPU as well.

  • Made with gpu.vec2(x, y) … gpu.uvec4(...), and one value in every lane with gpu.splat2(v), splat3, splat4, isplat2 … usplat4.
  • + - * / between two vectors of one type, and with the lane type on either side (v * 2.0, 2.0 * v); unary - on float and signed vectors; % on integer vectors; == compares the whole value.
  • Vec3 and Vec4 have .xy, Vec4 .xyz (and the same on the integer vectors).
  • Float vector methods: length, normalize, dot, distance, abs, floor, ceil, fract, sign, min, max, clamp, mix, step, smoothstep, cross (Vec3), and to_i and to_u to the integer vectors. Integer vectors have min, max, clamp, abs (signed), to_f, and to_u or to_i.
  • Mat4 * Vec4, Mat4 * Mat4, gpu.mat4_identity() and gpu.mat4_ortho(left, right, bottom, top).

Scalar maths on f32: gpu.floor, ceil, fract, sin, cos, tan, atan2, sqrt, inverse_sqrt, pow, exp, log, abs, sign, min, max, clamp, mix, step, smoothstep, fmod (GLSL’s mod). On integers: gpu.min_i, max_i, clamp_i, abs_i, min_u, max_u, clamp_u. Tape has no overloading by parameter type, so the integer forms have their own names.

Intrinsics

CallStageGives
gpu.global_id() -> UVec3computethe invocation’s position in the whole dispatch
gpu.local_id() -> UVec3computeits position in its workgroup
gpu.group_id() -> UVec3computeits workgroup’s position
gpu.vertex_index() -> u32vertexthe vertex’s number
gpu.instance_index() -> u32vertexthe instance’s number
gpu.frag_coord() -> Vec4pixelthe pixel’s position in the target
gpu.sample(t, uv: Vec2) -> Vec4anythe texture at uv, through its sampler
gpu.load(t, at: IVec2) -> Vec4anyone texel, no filtering
gpu.load_u(t, at: IVec2) -> UVec4anyone texel of a UTexture2D
gpu.size(t) -> IVec2anythe texture’s width and height
gpu.atomic_add(p, v), atomic_min, atomic_max, atomic_exchangeanythe old value; p is &buf[i] or &ctr.field of a u32
atomic_add_i, atomic_min_i, atomic_max_i, atomic_exchange_ianythe same on i32

Texture coordinate (0, 0) is the first texel of row 0, and row 0 is the top of the texture, on every backend.

tape
#shader
import gpu from "gpu";

struct Counters { alive: u32; died: u32; }

compute(256) fn census(hp: []const f32, ctr: *Counters, bins: []u32) {
    let i = gpu.global_id().x;
    if (hp[i] > 0.0) { gpu.atomic_add(&ctr.alive, 1); } else { gpu.atomic_add(&ctr.died, 1); }
    gpu.atomic_add(&bins[i % 16], 1);
}

Layout

A struct that crosses to the GPU (a storage element, a vertex input, the uniforms) has the compiler’s layout there too, the same offsets as on the CPU, so the program writes it with bytes.gen_of(T)’s of_T and reads it back as it is. gpu’s vectors are aligned the way the GPU aligns them (Vec2 to 8 bytes, Vec4 to 16) to make that hold.

  • Vec3, IVec3 and UVec3 don’t cross: GLSL and Metal give them different sizes. Use the 4-lane vector. In locals they are fine.
  • bool doesn’t cross either: use a u32.
  • A vertex struct is read at the CPU’s offsets, so a @packed one is fine. A storage or uniform struct whose fields the GPU would place differently (a Vec4 at byte 8) is an error naming the field.

Errors

Every error is at a line of the shader and stops the compile:

WrittenError
var g: u32 = 0; at the top of the regiona #shader region has no variables: a shader reads its parameters, consts and literals
fn Particle.speed(self) -> f32a #shader region's functions are shaders and their helpers: no extern fn, @tape fn or method here
tagged Shape { ... }a #shader region holds structs, enums, consts and functions: a GPU has no unions or tagged unions
compute fn f(...)compute needs its workgroup size: compute(256) fn, compute(16, 16) fn
let x: f64 = 1.0;a f64 is not on GPUs
let s = "hi";a string is not on GPUs
a vertex result with no positionOut: a vertex shader's result has a field position: gpu.Vec4
vertex struct Q { ... }a stage word goes before a fn: vertex fn, pixel fn, compute(x) fn“
a pixel stage returning an f32pixel fn ps: a pixel stage returns its colour, a gpu.Vec4 (not f32)
a pixel stage without the vertex output firstpixel fn ps: a pixel fn's first parameter is the vertex shader's output struct, interpolated: pixel fn ps(v: Out, …)“
a compute stage with a resultcompute fn k: a compute stage returns nothing: it writes its results to storage buffers
two uniform parameters'b': a stage has one uniform struct; put its values in the first one's fields
u: uniform u32'u': a uniform is a struct (u: uniform Params): a u32 is one of its fields
instance on a pixel or compute parameter'z': instance is a vertex stage's: a stream advancing once per instance
uniform or instance on a helper’s parameter'u': uniform is a stage's parameter (a helper takes values)
one: *u32'one': a pointer parameter is one struct in a storage buffer; a *u32 has no binding of its own
let f = helper;a shader has no function values: call the function
helpers calling each other in a circlefn a calls itself (through the functions it calls): a GPU has no call stack, so a shader can't recurse; use a loop
pos: gpu.Vec3 in a storage structgpu.Vec3 does not cross to the GPU: std430 gives it 12 bytes, MSL 16; use the 4-lane vector
a call to a #code functionhost is not a shader function: a shader calls gpu's intrinsics and the helpers of its region
matcha shader can't use match: if/else is what the GPU runs
for (x in buffer)a shader's foris over a range:for (i in 0..n), then index the buffer
defera GPU has no unwinding; write the work where it belongs

When a pipeline is made, a resource two stages share by name must be the same kind and type in both ("data" is a f32 in one stage and a u32 in the other: one resource, one type), and so must the uniform struct: the program stops there, naming it.

What a stage compiles to

The gpu.Shader a stage’s name stands for is a struct of three strings: glsl (GLSL 4.30), msl (Metal Shading Language) and layout, a JSON description of its bindings, vertex streams and uniforms that gpu reads when it makes a pipeline. Both texts are made on every platform, and the backend takes its own. Print one to see what a shader became:

tape
#code
import gpu from "gpu";
import io from "io";

pub struct Params { add: u32; count: u32; }

fn main() -> i32 {
    io.println(add_one.glsl);
    return 0;
}

#shader
import gpu from "gpu";

compute(64) fn add_one(vals: []u32, u: uniform Params) {
    let i = gpu.global_id().x;
    if (i >= u.count) { return; }
    vals[i] = vals[i] + u.add;
}

The texts carry #line directives with the shader’s tape lines, so a driver’s message points at your source.

Last modified: