#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.
#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:
| Declaration | Runs | Returns |
|---|---|---|
vertex fn name(params) -> Out | once per vertex | a struct with a field position: gpu.Vec4 (clip space) |
pixel fn name(v: Out, params) -> gpu.Vec4 | once per covered pixel | the pixel’s colour, premultiplied |
compute(x) fn name(params) | once per invocation | nothing |
compute(x),compute(x, y)andcompute(x, y, z)give the workgroup size: constant integers, each 1 to 1024.compute fnwithout 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:
| Parameter | Is | The program binds |
|---|---|---|
vals: []T | a storage buffer, read and written | a buffer with gpu.STORAGE |
vals: []const T | a storage buffer, read only | the same |
ctr: *T | a storage buffer holding one struct (counters, a header) | the same |
atlas: gpu.Texture2D | a texture of floats | a texture and a gpu.Sampler |
ids: gpu.UTexture2D | a texture of unsigned integers (R32u) | the same |
u: uniform T | the stage’s constants for one draw or dispatch: a struct, at most one per stage | a value of T |
corner: T (vertex) | the vertex’s attributes: a scalar, a vector or a struct of them | a buffer with gpu.VERTEX |
p: instance T (vertex) | the same, one per instance | a buffer with gpu.VERTEX, drawn instanced |
v: Out (pixel, first) | the vertex shader’s output | nothing: 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.
#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
uniformparameters in both stages are the pipeline’s one uniform struct, set once for both. slotwith a name the pipeline lacks traps, naming it.uniforms_Tpasses 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:
#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):
#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
varorletat the top level: a shader reads only its parameters, consts and literals; - no components,
threads,@tapeblocks or@link; - no methods,
extern fnor@tape fn(vector maths isgpu’s methods, which shaders call); - no
unionortagged.
Vectors and matrices
| Type | Lanes |
|---|---|
gpu.Vec2, gpu.Vec3, gpu.Vec4 | f32 |
gpu.IVec2, gpu.IVec3, gpu.IVec4 | i32 |
gpu.UVec2, gpu.UVec3, gpu.UVec4 | u32 |
gpu.Mat4 | four 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 withgpu.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.Vec3andVec4have.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), andto_iandto_uto the integer vectors. Integer vectors havemin,max,clamp,abs(signed),to_f, andto_uorto_i. Mat4 * Vec4,Mat4 * Mat4,gpu.mat4_identity()andgpu.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
| Call | Stage | Gives |
|---|---|---|
gpu.global_id() -> UVec3 | compute | the invocation’s position in the whole dispatch |
gpu.local_id() -> UVec3 | compute | its position in its workgroup |
gpu.group_id() -> UVec3 | compute | its workgroup’s position |
gpu.vertex_index() -> u32 | vertex | the vertex’s number |
gpu.instance_index() -> u32 | vertex | the instance’s number |
gpu.frag_coord() -> Vec4 | pixel | the pixel’s position in the target |
gpu.sample(t, uv: Vec2) -> Vec4 | any | the texture at uv, through its sampler |
gpu.load(t, at: IVec2) -> Vec4 | any | one texel, no filtering |
gpu.load_u(t, at: IVec2) -> UVec4 | any | one texel of a UTexture2D |
gpu.size(t) -> IVec2 | any | the texture’s width and height |
gpu.atomic_add(p, v), atomic_min, atomic_max, atomic_exchange | any | the old value; p is &buf[i] or &ctr.field of a u32 |
atomic_add_i, atomic_min_i, atomic_max_i, atomic_exchange_i | any | the 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.
#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,IVec3andUVec3don’t cross: GLSL and Metal give them different sizes. Use the 4-lane vector. In locals they are fine.booldoesn’t cross either: use au32.- A vertex struct is read at the CPU’s offsets, so a
@packedone is fine. A storage or uniform struct whose fields the GPU would place differently (aVec4at byte 8) is an error naming the field.
Errors
Every error is at a line of the shader and stops the compile:
| Written | Error |
|---|---|
var g: u32 = 0; at the top of the region | a #shader region has no variables: a shader reads its parameters, consts and literals |
fn Particle.speed(self) -> f32 | a #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 position | Out: 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 f32 | pixel fn ps: a pixel stage returns its colour, a gpu.Vec4 (not f32) |
| a pixel stage without the vertex output first | pixel 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 result | compute 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 circle | fn 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 struct | gpu.Vec3 does not cross to the GPU: std430 gives it 12 bytes, MSL 16; use the 4-lane vector |
a call to a #code function | host is not a shader function: a shader calls gpu's intrinsics and the helpers of its region |
match | a 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 |
defer | a 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:
#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: