gpu
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
| Call | Gives |
|---|---|
gpu.open(width, height, title, opts) -> Device or GpuError | A window and the GPU drawing into it |
gpu.offscreen(opts) -> Device or GpuError | The GPU with no window |
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.
| Method | Does |
|---|---|
dev.poll() -> ?Event | One waiting event, null when none: drain it at the top of each frame |
dev.wait() -> Event | Block 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() -> ?f64 | The last finished frame’s GPU time, null until one has finished |
dev.size() -> Size | The window’s drawable area, in pixels (0 by 0 offscreen) |
dev.info() -> Info | backend (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() -> bool | The 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.
| Call | Gives |
|---|---|
gpu.primary_display() -> Display | The primary screen, before any window is open |
dev.display() -> Display | The screen most of the window is on |
dev.set_mode(m) | gpu.Mode.Windowed, Borderless (no frame, covering the screen) or Fullscreen |
dev.mode() -> Mode | The window’s mode now |
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:
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
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
pub const VERTEX: u32 = 1; // usages, combined with |
pub const STORAGE: u32 = 2;
pub const READ: u32 = 4; // the CPU reads it back| Call | Does |
|---|---|
dev.buffer(size, usage) -> Buffer or GpuError | A 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 GpuError | Bytes back into out |
buf.ready(dev) -> bool | A read now would not wait |
buf.size() -> i64 | Its 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
writesees the bytes, a pass before it doesn’t. They go between passes, never inside one. readwaits 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); }makesof_Dotfor a struct of your own. Its layout is the GPU’s as it is.
Textures
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; }| Call | Does |
|---|---|
dev.texture(width, height, format, target) -> Texture or GpuError | A 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 GpuError | The 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.
Bgra8is agfxsurface’s pixels as they are (premultiplied0xAARRGGBB): write one withrow_bytesitsstride * 4and draw it withBlend.Premultiplied.Rgba32fis fourf32a texel,R32fone,R32uoneu32(agpu.UTexture2Din a shader).- Textures and buffers can be made, written and released at any time between passes, mid-game included.
Pipelines
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
}| Call | Does |
|---|---|
dev.render_pipeline(&vs, &ps, opts) -> Pipeline or GpuError | A vertex and a pixel shader, drawn with opts |
dev.compute_pipeline(&cs) -> Pipeline or GpuError | A compute shader |
p.slot(name) -> Slot | A resource or vertex stream, by its parameter’s name in the shader |
p.release(dev) | Free it |
Blend | Is |
|---|---|
None | the colour replaces what is there |
Premultiplied | ONE, ONE_MINUS_SRC_ALPHA: gfx surfaces, a shader’s premultiplied colour |
Alpha | SRC_ALPHA, ONE_MINUS_SRC_ALPHA |
Additive | ONE, 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.
| Method | Does |
|---|---|
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. Calluniforms_Tbefore 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. storageat an offset lets one buffer be two parameters: a header struct*Headat 0 and a[]u32at 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.
gpuplaces the barriers.
Errors
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
outtoo small for a read; write,clearorreadinside a pass; a second pass begun while one is open;present,finishorread_framewith a pass open;slotwith a name the pipeline lacks; a slot of the wrong kind (storagewith 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;
drawon a compute pass,dispatchon 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
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.
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: