gl

tape
import gl from "gl";
import io from "io";

fn main() -> i32 {
    var win = gl.open(800, 600, "triangle", gl.Options { major: 3; minor: 3; }) or {
        io.println(err.desc());
        return 1;
    };
    while (!win.closed()) {
        while (true) {
            let ev = win.poll() or { break; };      // null: nothing waiting
            match (ev) {
                gl.Event.Resize { width; height } => { gl.glViewport(0, 0, width as i32, height as i32); }
                _ => {}
            }
        }
        gl.glClearColor(0.1, 0.1, 0.12, 1.0);
        gl.glClear(gl.GL_COLOR_BUFFER_BIT);
        win.present();
    }
    win.close();
    return 0;
}

gl opens a window with an OpenGL core context and gives you GL itself: every function under its Khronos name with gl.xml’s parameter names, so docs.gl documents the tape call. What it adds on top is small: the window and its events, shaders and programs that return the compiler’s log as an error, check() for glGetError, and debug messages. It works the same under tape run and in a built program.

gl runs on Windows (WGL) and Linux (GLX, on the X11 window, with the system’s libGL.so.1: Mesa or the GPU vendor’s driver). On macOS, whose OpenGL stops at 4.1 without compute, gl.open gives GlError.Unsupported. It is a capability module: it opens windows and talks to the driver.

The window

tape
pub fn open(width: i64, height: i64, title: []const u8, opts: Options) -> Window or GlError;

pub struct Options {
    major: i64 = 3;
    minor: i64 = 3;
    vsync: bool = true;
    visible: bool = true;          // false: a hidden window (tests, offscreen work)
    depth_bits: i64 = 24;
    stencil_bits: i64 = 8;
    debug: bool = false;           // a debug context, its messages through gl.messages()
}

open gives a window with a core context of at least major.minor, current on the calling thread, every function loaded, and the viewport set to the whole window. One thread uses it: every gl call goes to the context current on that thread.

Windows keeps a window within the desktop: one asked for larger than the screen (a map’s full size on a laptop) opens smaller. Size what you draw from win.size(), not from the numbers you asked for, and again on each Resize.

MethodDoes
win.poll() -> ?EventOne waiting event, null when none: drain it at the top of each frame
win.wait() -> EventBlock until an event comes (a tool that redraws only on input)
win.present()Show what was drawn (swap buffers, waiting for the display when vsync is on)
win.size() -> SizeThe drawable area now, in pixels
win.set_title(text)The title bar’s text; any language’s letters show (frame times there are the cheapest profiler)
win.show()Show a window opened with visible: false
win.focused() -> boolThe window is in front: the keyboard’s keys go to it
win.closed() -> boolThe user closed it, or close() ran
win.close()Release the context and the window; twice is fine
win.make_current()Make its context current again, for a program with two windows

Events

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

Keys and the pointer come as ui’s own events, filled the way a ui window fills them: e.key == ui.Key.Escape, e.modifiers, clicks, the wheel in e.wheel_x and e.wheel_y. Resize is yours to answer with glViewport. Close comes once, when the user closes the window; it is gone then, present() does nothing and closed() is true.

Focus { focused } comes when the window comes to the front (true) or another window does (false), and win.focused() says which it is now. A game that reads held keys with uwm.get_key_state asks focused() first: the keyboard’s state is the same whichever window is in front, so the camera would move while the player types elsewhere. One that tracks held keys from Down and Up lets them all go on Focus { focused: false }: the Up of a key released in another window comes to that window.

The events drive a ui.Screen as they are, so a ui tree can be the HUD over a GL scene: Key { e } goes to screen.key(e.kind, e.keycode, e.codepoint, e.modifiers), a wheel to screen.scroll(e.x, e.y, e.wheel_x, e.wheel_y), any other pointer event to screen.hold(e.modifiers) then screen.pointer(e.kind, e.x, e.y).

A gfx surface’s pixels, a Screen’s included, are premultiplied 0xAARRGGBB (gfx.Format.Argb): upload them as they are with GL_BGRA, GL_UNSIGNED_BYTE and GL_UNPACK_ROW_LENGTH set to the stride, and draw them with glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA). A sprite atlas drawn with gfx, a decoded image or a HUD all go up the same way.

poll also runs what your threads sent back, as ui’s loop does, so a worker’s callback runs on the GL thread.

Errors

tape
pub tagged GlError {
    NoWindow;
    NoPixelFormat;
    NoContext { major: i64; minor: i64; got: string; }
    Missing { names: string; }
    Compile { kind: ShaderKind; log: string; }
    Link { log: string; }
    Gl { code: u32; name: string; }
    Unsupported;
}

err.desc() says it in a line: "compute shader: 0(4) : error C1503: undefined variable "v"", "OpenGL error GL_INVALID_ENUM (1280)". NoContext carries what the driver gives instead (got: "OpenGL 3.1 (Intel HD Graphics 3000)"); Missing names the functions of your version the driver lacks.

Shaders and programs

tape
pub fn program(vert: []const u8, frag: []const u8) -> u32 or GlError;
pub fn compute(src: []const u8) -> u32 or GlError;
pub fn shader(kind: ShaderKind, src: []const u8) -> u32 or GlError;
pub fn link(shaders: []const u32) -> u32 or GlError;
pub fn shader_log(id: u32) -> string;
pub fn program_log(id: u32) -> string;

Write GLSL in raw strings, or keep it in its own file: a plain literal reads { as interpolation.

tape
const VERT: string = r"""
#version 330 core
layout(location = 0) in vec2 pos;
void main() { gl_Position = vec4(pos, 0.0, 1.0); }
""";
const FRAG: string = @embed("./shaders/flat.frag");  // ./: beside this file

let prog = gl.program(VERT, FRAG) or {
    match (err) {
        gl.GlError.Compile { kind; log } => { io.println("the {kind.desc()} shader: {log}"); }
        gl.GlError.Link { log } => { io.println("link: {log}"); }
        _ => { io.println(err.desc()); }
    }
    return 1;
};
gl.glUseProgram(prog);
  • program compiles both shaders and links them, compute one compute shader (GL 4.3). Each deletes its shaders: you get the program only. The first failure is the error: the vertex shader, then the fragment shader, then the link.
  • shader compiles one; Compile’s log is the driver’s, with its line numbers. link links shaders you made, attaching and detaching them; they stay yours to delete (glDeleteShader).
  • A shader that compiles with warnings is not an error: shader_log(id) and program_log(id) show them.

Compute

tape
let prog = gl.compute(ADD_ONE) or { io.println(err.desc()); return 1; };
let buf = gl.gen_buffer();
gl.glBindBuffer(gl.GL_SHADER_STORAGE_BUFFER, buf);
gl.buffer_alloc(gl.GL_SHADER_STORAGE_BUFFER, n * 4, gl.GL_DYNAMIC_COPY);
gl.glBindBufferBase(gl.GL_SHADER_STORAGE_BUFFER, 0, buf);   // binding = 0 in the shader
gl.glUseProgram(prog);
gl.glDispatchCompute(((n + 63) / 64) as u32, 1, 1);           // local_size_x = 64
gl.glMemoryBarrier(gl.GL_BUFFER_UPDATE_BARRIER_BIT);           // before reading it back
gl.glGetBufferSubData(gl.GL_SHADER_STORAGE_BUFFER, 0, out[0..n * 4]);

Between two dispatches where the second reads what the first wrote, the barrier is GL_SHADER_STORAGE_BARRIER_BIT; before drawing from the buffer as vertices, GL_VERTEX_ATTRIB_ARRAY_BARRIER_BIT. Read back only what you need: glGetBufferSubData waits for the GPU to finish.

ShaderKind is Vertex, Fragment, Geometry, Compute, TessControl and TessEvaluation, with kind.desc() ("vertex", …).

Checking for errors

tape
pub fn check() -> void or GlError;
pub fn messages() -> []const DebugMessage;

pub struct DebugMessage { source: u32; kind: u32; id: u32; severity: u32; text: string; }

check() is glGetError as a value: Gl { code; name } for the first error since the last check, the rest cleared. Calling it once a frame finds a mistake within a frame:

tape
gl.check() or { io.println(err.desc()); };

With Options { debug: true } on GL 4.3 (or a driver with GL_KHR_debug), the driver also explains: messages() gives what it logged since the last call, oldest first, each message right after the call that caused it. severity is GL_DEBUG_SEVERITY_HIGH, _MEDIUM, _LOW or _NOTIFICATION. The slice is valid until the next call; without a debug context it is empty.

tape
for (m in gl.messages()) {
    if (m.severity != gl.GL_DEBUG_SEVERITY_NOTIFICATION) { io.println(m.text); }
}

The GL functions

The core functions up to GL 4.3 that a program draws and computes with (buffers, vertex arrays, shaders and uniforms, textures, framebuffers, instancing, compute, queries, debug output) are there under their Khronos names, with tape types where C has pointers:

  • A buffer, a texture’s pixels and a readback are slices: glBufferData(target, data: []const u8, usage), glTexImage2D(..., pixels: []const u8), glReadPixels(..., pixels: []u8), glGetBufferSubData(target, offset, out: []u8). Numbers become bytes without a copy through bytes: bytes.of_f32(verts[0..n]), and for your own struct @tape { bytes.gen_of(Vertex); } makes of_Vertex. gl.buffer_alloc(target, size, usage) gives storage without data.
  • glGenBuffers(ids: []u32) fills a slice; for one, gl.gen_buffer(), gen_texture(), gen_vertex_array(), gen_framebuffer(), gen_renderbuffer(), gen_query(), and delete_buffer(id) and the rest.
  • glClearBufferData(target, internalformat, format, kind, data) fills a whole buffer with one value without uploading it (zeroing counters each frame: gl.GL_R32UI, gl.GL_RED_INTEGER, gl.GL_UNSIGNED_INT and four zero bytes).
  • A query of one value returns it: glGetShaderiv(shader, pname) -> i32, glGetQueryObjectui64v(query, pname) -> u64, glGetInteger(pname). glGetShaderInfoLog and glGetProgramInfoLog return strings, glMapBufferRange the mapped bytes as a []u8.
  • Names are slices: glGetUniformLocation(program, "mvp"), glShaderSource(shader, source).
  • An offset into a bound buffer stays an i64, as GL means it: glVertexAttribPointer(0, 2, gl.GL_FLOAT, false, 16, 0).
  • The types are gl.xml’s, and the constants are u32: where gl.xml says GLint for an enumerant, the constant is cast. The two every program meets are glTexImage2D’s internalformat (gl.GL_RGBA8 as i32) and glTexParameteri’s value (gl.GL_NEAREST as i32).

Timing the GPU

A GL_TIME_ELAPSED query measures the GPU’s time between glBeginQuery and glEndQuery, and glGetQueryObjectui64v(query, gl.GL_QUERY_RESULT) gives it in nanoseconds. Asking for a result in the frame that made it waits for the GPU, so keep two queries and read last frame’s:

tape
let q = queries[frame % 2];
gl.glBeginQuery(gl.GL_TIME_ELAPSED, q);
draw();
gl.glEndQuery(gl.GL_TIME_ELAPSED);
if (frame > 0) {
    let ns = gl.glGetQueryObjectui64v(queries[(frame + 1) % 2], gl.GL_QUERY_RESULT);
    win.set_title("gpu {ns / 1000} us");
}

The constants are gl.GL_.... A function newer than your context traps naming it (gl.glDispatchCompute: not loaded: this OpenGL context lacks it) instead of crashing; gl.has("GL_ARB_bindless_texture") asks for an extension, and gl.version() and gl.renderer() say what you got.

Last modified: