gpu

tape
import gpu from "gpu";
import io from "io";
import ui from "ui";
import bytes from "bytes";

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

@tape { gpu.gen_uniforms(Step); }
@tape { gpu.gen_uniforms(View); }
@tape { bytes.gen_of(Dot); }

fn run(dev: *gpu.Device) -> void or gpu.GpuError {
    let step = dev.compute_pipeline(&advance) or return;
    let draw = dev.render_pipeline(&dot_vs, &dot_ps, gpu.RenderOptions {
        primitive: gpu.Primitive.TriangleStrip;
    }) or return;
    let s_dots = step.slot("dots");
    let s_corner = draw.slot("corner");
    let s_dot = draw.slot("d");

    var start: [1024]Dot;
    for (i in 0..1024) {
        let a = i as f32 * 2.399;
        start[i] = Dot { pos: gpu.splat2(0.0); vel: gpu.vec2(gpu.cos(a), gpu.sin(a)) * 0.3; };
    }
    let dots = dev.buffer(1024 * 16, gpu.STORAGE | gpu.VERTEX) or return;
    dots.write(dev, 0, of_Dot(start[0..]));
    let corners = [0.0 as f32, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 1.0];
    let quad = dev.buffer(32, gpu.VERTEX) or return;
    quad.write(dev, 0, bytes.of_f32(corners[0..]));

    var su = Step { dt: 0.016; count: 1024; };
    var vu = View { size: 0.02; tint: gpu.vec4(1.0, 0.8, 0.2, 1.0); };
    while (!dev.closed()) {
        while (true) {
            let ev = dev.poll() or { break; };
            match (ev) {
                gpu.Event.Key { e } => { if (e.key == ui.Key.Escape) { return; } }
                _ => {}
            }
        }
        var cp = dev.compute();                 // move the dots
        cp.pipeline(&step);
        uniforms_Step(&cp, &su);
        cp.storage(s_dots, &dots, 0);
        cp.dispatch_for(1024);
        cp.end();

        var rp = dev.render(null, gpu.vec4(0.05, 0.05, 0.08, 1.0));   // draw them
        rp.pipeline(&draw);
        uniforms_View(&rp, &vu);
        rp.vertices(s_corner, &quad, 0);
        rp.vertices(s_dot, &dots, 0);           // what the compute pass wrote, as instances
        rp.draw(4, 1024);
        rp.end();
        dev.present() or return;
    }
}

fn main() -> i32 {
    var dev = gpu.open(800, 600, "dots", gpu.Options {}) or {
        io.println(err.desc());
        return 1;
    };
    run(&dev) or { io.println(err.desc()); };
    dev.close();
    return 0;
}

#shader
import gpu from "gpu";

compute(64) fn advance(dots: []Dot, u: uniform Step) {
    let i = gpu.global_id().x;
    if (i >= u.count) { return; }
    var d = dots[i];
    d.pos = d.pos + d.vel * u.dt;
    if (d.pos.x < -1.0 || d.pos.x > 1.0) { d.vel.x = -d.vel.x; }
    if (d.pos.y < -1.0 || d.pos.y > 1.0) { d.vel.y = -d.vel.y; }
    dots[i] = d;
}

pub struct DotOut { position: gpu.Vec4; uv: gpu.Vec2; }

vertex fn dot_vs(corner: gpu.Vec2, d: instance Dot, u: uniform View) -> DotOut {
    let p = d.pos + (corner - gpu.splat2(0.5)) * u.size;
    return DotOut { position: gpu.vec4(p.x, p.y, 0.0, 1.0); uv: corner; };
}

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

gpu is one GPU API that a renderer is written against once: Metal underneath on macOS, OpenGL 4.3 on Windows and Linux, through the gpu100 runtime library that tape build puts beside the program. Shaders are tape, in a #shader region; each stage’s name is a gpu.Shader that pipelines are made from. Work happens in the order the program asks for it, and gpu places the barriers between passes. It works the same under tape run and in a built program.

Windows and Linux need a driver with OpenGL 4.3 (compute shaders); on Linux that is Mesa or the GPU vendor’s driver. macOS uses Metal. It is a capability module: it opens windows and talks to the GPU.

The device

CallGives
gpu.open(width, height, title, opts) -> Device or GpuErrorA window and the GPU drawing into it
gpu.offscreen(opts) -> Device or GpuErrorThe GPU with no window
tape
pub struct Options {
    vsync: bool = true;
    visible: bool = true;          // false: a hidden window, shown later with dev.show()
    debug: bool = false;           // OpenGL: a debug context; Metal: the API validation layer
}

offscreen is for tests and tools that render into textures: on Metal it needs no window server at all; OpenGL has no context without a window, so there it is a hidden window’s.

MethodDoes
dev.poll() -> ?EventOne waiting event, null when none: drain it at the top of each frame
dev.wait() -> EventBlock until an event comes
dev.present()Show the frame in the window (void or GpuError)
dev.finish()Wait for all the work asked for so far (void or GpuError)
dev.read_frame(out)The window’s frame as drawn so far, Bgra8, rows top down (void or GpuError)
dev.gpu_ms() -> ?f64The last finished frame’s GPU time, null until one has finished
dev.size() -> SizeThe window’s drawable area, in pixels (0 by 0 offscreen)
dev.info() -> Infobackend (gpu.Backend.Metal or Gl, .desc() names it) and the device’s name
dev.set_title(text), dev.show()The title bar; show a window opened with visible: false
dev.focused() -> bool, dev.closed() -> boolThe window is in front; the user closed it, or close() ran
dev.close()Release everything and the window; twice is fine

A frame is passes, then present(). A resize needs nothing from the program: the next pass into the window has the new size, and Resize says it happened. read_frame needs width * height * 4 bytes; rows come top down on both backends, so a screenshot needs no flip.

The screen and fullscreen

A window counts the screen’s real pixels, so the GPU renders at the screen’s own resolution on a scaled screen too (150 %, Retina), and fullscreen is the screen’s full resolution.

CallGives
gpu.primary_display() -> DisplayThe primary screen, before any window is open
dev.display() -> DisplayThe screen most of the window is on
dev.set_mode(m)gpu.Mode.Windowed, Borderless (no frame, covering the screen) or Fullscreen
dev.mode() -> ModeThe window’s mode now
tape
pub struct Display {
    x: i64; y: i64; width: i64; height: i64;              // the whole screen
    work_x: i64; work_y: i64; work_width: i64; work_height: i64;   // less the taskbar, dock, panels
    scale: f64;                                            // the desktop's scaling: 1.0, 1.5, 2.0
}

Open at a share of the screen, and toggle fullscreen with a key:

tape
let d = gpu.primary_display();
var dev = gpu.open(d.work_width * 8 / 10, d.work_height * 8 / 10, "game", gpu.Options {}) or return;
// ... on F11:
if (dev.mode() == gpu.Mode.Windowed) { dev.set_mode(gpu.Mode.Fullscreen); }
else { dev.set_mode(gpu.Mode.Windowed); }

A mode change arrives as a Resize, as a resize by the user does. Windowed again is where the window was and the size it had. No display mode is ever changed: fullscreen is the screen’s current resolution (on macOS, its own Space). scale is for sizing your own interface: draw text and buttons scale times larger on a 150 % screen.

Events

tape
import ui from "ui";

pub tagged Event {
    Key { e: ui.KeyEvent; }
    Pointer { e: ui.PointerEvent; }
    Resize { width: i64; height: i64; }
    Focus { focused: bool; }
    Close;
}

They are gl’s events, filled the same way: keys and the pointer as ui’s own events (e.key == ui.Key.Escape, the wheel in e.wheel_x and e.wheel_y), Focus when the window comes to the front or leaves it, Close once when the user closes it. poll also runs what your threads sent back, so a worker’s callback runs on the GPU thread.

Buffers

tape
pub const VERTEX: u32 = 1;         // usages, combined with |
pub const STORAGE: u32 = 2;
pub const READ: u32 = 4;           // the CPU reads it back
CallDoes
dev.buffer(size, usage) -> Buffer or GpuErrorA buffer of size bytes, zeroed
buf.write(dev, offset, data)Bytes into it; data may be reused at once
buf.clear(dev, offset, size)Bytes to zero
buf.read(dev, offset, out) -> void or GpuErrorBytes back into out
buf.ready(dev) -> boolA read now would not wait
buf.size() -> i64Its size in bytes
buf.release(dev)Free it; the GPU keeps it until it is done with it
  • Writes, clears and reads take effect at their place in the program: a pass after write sees the bytes, a pass before it doesn’t. They go between passes, never inside one.
  • read waits only for the work that used this buffer, never for the whole queue. A program that reads last frame’s results (two buffers, ping-ponged) never waits; ready() asks without waiting.
  • A buffer can be STORAGE | VERTEX: written by a compute shader and drawn as instances with no copy.
  • A range outside the buffer traps (gpu: buffer write of 64 bytes at 4096: the buffer holds 4096).
  • Bytes come from bytes: bytes.of_f32(xs[0..n]), of_u32, …, and @tape { bytes.gen_of(Dot); } makes of_Dot for a struct of your own. Its layout is the GPU’s as it is.

Textures

tape
pub enum Format : u32 { Bgra8 = 1; Rgba8 = 2; Rgba32f = 3; R32f = 4; R32u = 5; }
pub enum Filter : u8 { Nearest = 0; Linear = 1; }
pub enum Wrap : u8 { Clamp = 0; Repeat = 1; }
pub struct Sampler { filter: Filter = Filter.Nearest; wrap: Wrap = Wrap.Clamp; }
CallDoes
dev.texture(width, height, format, target) -> Texture or GpuErrorA 2D texture; target: true lets a render pass draw into it (and read it back)
tex.write(dev, x, y, w, h, data, row_bytes)A w by h rectangle at (x, y), rows row_bytes apart in data
tex.read(dev, out) -> void or GpuErrorThe whole texture, rows top down
tex.width(), tex.height()Its size
tex.release(dev)Free it, as a buffer
  • Row 0 is the top, on every backend.
  • Bgra8 is a gfx surface’s pixels as they are (premultiplied 0xAARRGGBB): write one with row_bytes its stride * 4 and draw it with Blend.Premultiplied. Rgba32f is four f32 a texel, R32f one, R32u one u32 (a gpu.UTexture2D in a shader).
  • Textures and buffers can be made, written and released at any time between passes, mid-game included.

Pipelines

tape
pub enum Format : u32 { Bgra8 = 1; Rgba8 = 2; Rgba32f = 3; R32f = 4; R32u = 5; }
pub enum Primitive : u8 { Triangles = 0; TriangleStrip = 1; Lines = 2; Points = 3; }
pub enum Blend : u8 { None = 0; Premultiplied = 1; Alpha = 2; Additive = 3; }

pub struct RenderOptions {
    primitive: Primitive = Primitive.Triangles;
    blend: Blend = Blend.None;
    format: Format = Format.Bgra8;   // the target's: the window's is Bgra8
}
CallDoes
dev.render_pipeline(&vs, &ps, opts) -> Pipeline or GpuErrorA vertex and a pixel shader, drawn with opts
dev.compute_pipeline(&cs) -> Pipeline or GpuErrorA compute shader
p.slot(name) -> SlotA resource or vertex stream, by its parameter’s name in the shader
p.release(dev)Free it
BlendIs
Nonethe colour replaces what is there
PremultipliedONE, ONE_MINUS_SRC_ALPHA: gfx surfaces, a shader’s premultiplied colour
AlphaSRC_ALPHA, ONE_MINUS_SRC_ALPHA
AdditiveONE, ONE

Look the slots up once, next to the pipeline, and keep them. A render pipeline joins its two stages by name: a parameter in both is one slot, and their uniforms are one struct. Two pipelines from one shader are fine (the same blit with and without blending).

Passes

A pass begins with dev.render(target, clear) or dev.compute() and ends with pass.end(). A render pass draws into target, a ?*const gpu.Texture: null is the window. clear is a ?gpu.Vec4: a colour clears the target first, null keeps what it holds.

MethodDoes
pass.pipeline(&p)Draw or dispatch with p from here on; its bindings start afresh
uniforms_T(&pass, &u)The uniforms, from a T (made by @tape { gpu.gen_uniforms(T); }); copied at the call
pass.storage(slot, &buf, offset)A storage buffer, from offset: a multiple of 256
pass.vertices(slot, &buf, offset)A vertex or instance stream
pass.texture(slot, &tex, gpu.Sampler {})A texture and how it is sampled
pass.draw(vertices, instances)Draw (render passes)
pass.draw_from(first_vertex, vertices, first_instance, instances)Draw part of the bound streams
pass.dispatch(x, y, z)Run x * y * z workgroups (compute passes)
pass.dispatch_for(n)Enough workgroups of the shader’s size for n invocations in x
pass.end()End the pass
  • Bindings and the uniforms hold for the draws after them until the next pipeline. Call uniforms_T before each draw to give each its own (blits, each with its rectangle); the struct may change at once.
  • One pass may draw with several pipelines; set the bindings again after each pipeline.
  • storage at an offset lets one buffer be two parameters: a header struct *Head at 0 and a []u32 at 256. The offset is a multiple of 256 on every backend, so a program behaves the same everywhere.
  • After a compute pass, everything it wrote is visible to what follows: vertex input, storage reads, reads back. gpu places the barriers.

Errors

tape
pub enum Stage : u8 { Vertex = 0: "vertex"; Pixel = 1: "pixel"; Compute = 2: "compute"; }

pub tagged GpuError {
    Unsupported { what: string; }                 // the platform or driver lacks it
    NoDevice { reason: string; }                  // no GPU, no OpenGL 4.3: what the system gave
    NoWindow;                                     // the window could not be made
    Compile { stage: Stage; log: string; }        // the driver rejected the shader's text
    Link { log: string; }                         // the pipeline could not be made
    OutOfMemory { what: string; }                 // "buffer of 536870912 bytes: ..."
    Lost { reason: string; }                      // the device was lost (driver reset, GPU removed)
}

err.desc() says it in a line: "no GPU device: ...", "pixel shader: the driver rejected it: ...". A shader you wrote wrong never gets this far: it is a compile error at its line. Compile means the driver refused what gpu made, a driver limit or a gpu bug, and carries the driver’s log.

Mistakes in the program trap, saying what was wrong:

  • a range outside a buffer or texture, or an out too small for a read;
  • write, clear or read inside a pass; a second pass begun while one is open; present, finish or read_frame with a pass open;
  • slot with a name the pipeline lacks; a slot of the wrong kind (storage with a texture’s slot);
  • a draw or dispatch with a slot or the uniforms never set (gpu: draw: "atlas" was not set);
  • uniforms of another struct than the pipeline’s, or of another size;
  • a storage offset that is not a multiple of 256;
  • draw on a compute pass, dispatch on a render pass, a compute pipeline in a render pass (or the reverse), a call on a pass that has ended;
  • a render pipeline whose vertex shader’s output is not its pixel shader’s input, or a name that is a different kind of resource in its two stages;
  • rendering into a texture made without target: true, or into the window of an offscreen device;
  • a released buffer, texture or pipeline, or a closed device.

Reading results back

tape
import gpu from "gpu";
import io from "io";

fn sum_back(dev: *gpu.Device, vals: *const gpu.Buffer) -> void or gpu.GpuError {
    var out: [256]u32;
    vals.read(dev, 0, (&out[0] as *u8)[0..1024]) or return;     // waits for the work that used vals
    var total: u64 = 0;
    for (i in 0..256) { total = total + out[i] as u64; }
    io.println("sum {total}");
}

fn main() -> i32 { return 0; }

A frame’s GPU time comes a frame late: dev.gpu_ms() is the last finished frame’s, from its first pass to its present.

tape
import gpu from "gpu";

fn show_time(dev: *gpu.Device) {
    let ms = dev.gpu_ms() or { return; };
    dev.set_title("gpu {ms} ms");
}

fn main() -> i32 { return 0; }

Last modified: