The MICROCOSM toolkit: API reference
The public mc_ API, module by module

Table of Contents

↑ Back to the index

This is the reference for the public mc_ API. Every signature below is copied from the headers in include/microcosm/, and if this page and a header ever disagree, the header is right. Each module has its own section, with short snippets that show how the calls are meant to be used.


1. Orientation

The engine is a set of small C modules, all with the mc_ prefix, and each has its own header in include/microcosm/. Structs are plain value types, passed and returned by value where that's cheap. The modules that allocate have an explicit _free.

Header Implemented in Covers
mc.h (macros only) types, constants, logging, asserts, MC_TIDY
mc_math.h src/math.c vectors, matrices, quaternions, noise, easing, splines
mc_gl.h src/gl_loader.c the GL function table and the per-frame uniform block
mc_platform.h src/platform_x11_egl.c or src/platform_surfaceless.c, with src/platform_common.c window, context, input, clock, read-back
mc_mesh.h src/mesh.c building meshes on the CPU
mc_render.h src/render.c shaders, GPU meshes, render targets, the renderer and its post stack
mc_scene.h src/scene.c camera, frustum culling, keyframe tracks, timeline
mc_audio.h src/synth.c, src/audio.c the synth and the audio thread
tiny64.h (header-only) one full-screen shader in a window

Every frame runs the same sequence:

mc_renderer_frame(R, t);                      // TAA: this frame's time
mc_m4 vp = mc_renderer_camera(R, proj, view); // proj * view, jittered for the TAA
mc_renderer_begin(R, clear_rgb);              // bind the HDR scene target, clear
/* ... upload your frame block, bind shaders, set uniforms, draw ... */
mc_renderer_end(R, mc_window_output_fbo(win), w, h, post); // post stack -> output
mc_window_swap(win);

The internals explain how the modules depend on each other, why the engine links no GL library, how the two platform backends differ and what the renderer does between begin and end.

1.1. Build flavours

Three macros change what the headers declare and what some calls do.

Macro Defined for Effect on the API
MC_DEV the dev build, and the default when neither flavour is defined mc_log, mc_assert, MC_GL_CHECK and mc_window_save_png work
MC_RELEASE the release build and the capture binary logging, asserts and GL checks compile to nothing, and mc_window_save_png returns 0
MC_IMPORT_HASH only the packed payload, on top of MC_RELEASE MC_TIDY drops its contents, and the calls the payload never makes aren't declared

The payload's compile doesn't declare mc_window_close, mc_window_read_pixels, mc_audio_stop, mc_mesh_gpu_free, mc_gl_context_release, mc_uniform, mc_set_f, mc_set_v3, or the GL calls in MC_GL_TOOLS (see mc_gl.h). Using one there is a compile error rather than a call through an empty slot. Teardown goes inside MC_TIDY(...) (see mc.h).

The backend you link decides between a window and headless rendering. The make targets, each build's flags and the video capture are described in the pipeline doc.


2. Conventions

  • Coordinates are right-handed. mc_m4_look_at builds a view in which the camera looks down -Z, with +X to the right and +Y up, so everything in front of the camera has a negative view-space z.
  • Matrices are column-major and multiply column vectors, as in GLSL: proj * view * model * p.
  • Front faces wind counter-clockwise, and mc_renderer_begin culls back faces.
  • Angles are in radians, including mc_camera's fovy.
  • mc_m4_perspective and mc_m4_ortho produce GL's clip space, with depth running from -1 at the near plane to +1 at the far plane. The engine keeps GL's default depth range, so the depth buffer holds 0 at the near plane and 1 at the far plane.
  • mc_renderer_depth is a 24-bit depth texture (GL_DEPTH_COMPONENT24, nearest filtering) of that depth, which isn't linear: most of its precision sits close to the camera. To turn a sample into a distance along the view axis, undo the projection with the camera's near and far planes, as src/shaders/ao_blur.frag does:

    float view_depth(float d, float zn, float zf) {   // d: a depth-texture sample
        float z = d * 2.0 - 1.0;                       // back to GL's [-1, 1]
        return 2.0 * zn * zf / (zf + zn - z * (zf - zn));
    }
    
  • Texture and window coordinates follow GL, with (0, 0) at the bottom left. tiny64's uv runs from (0, 0) at the bottom left to (1, 1) at the top right. Only the read-back (mc_window_read_pixels, mc_window_save_png) turns rows around so that the top row comes first.
  • The code is C11 with GNU extensions, and the Makefile builds it with -std=gnu11. Strict -std=c11 hides the POSIX declarations that the platform and audio code need, such as clock_gettime, so src/platform_common.c and src/audio.c fail to compile under it.

3. The easy path: tiny64.h

Everything after this section is the real engine API. A full intro needs that much control, but it's more than you want when all you're after is a fragment shader on the screen. For that there's include/microcosm/tiny64.h, a header-only wrapper around the platform and the renderer, and with it an animated single-shader demo in a window takes about ten lines. Define TINY64_IMPL in exactly one translation unit to compile the implementation there, and include the header without it everywhere else.

#define TINY64_IMPL
#include <microcosm/tiny64.h>

int main(void) {
    t64_open(1280, 720, "demo");
    t64_shader("#version 450 core\n"
               "in vec2 uv; out vec4 o; uniform float t;"
               "void main(){ o = vec4(uv, 0.5+0.5*sin(t), 1.0); }");
    while (t64_running()) {
        t64_set1f("t", (float)t64_time());
        t64_quad();
        t64_swap();
    }
    t64_close();
}
Function Does
int t64_open(int w, int h, const char *title) open a window + GL 4.5 context; 1 on success
void t64_close(void) tear it down
int t64_running(void) pump events; 0 once the window is closed or ESC is pressed
double t64_time(void) seconds since t64_open
void t64_size(int *w, int *h) current framebuffer size
void t64_shader(const char *frag) compile frag against a built-in full-screen vertex shader and make it current
void t64_shader_lib(const char *lib, const char *frag) same, with a library chunk prepended (helpers, an SDF kit, …)
void t64_quad(void) draw the full-screen triangle with the current shader
void t64_swap(void) present the frame
t64_set1f/2f/3f/4f/1i(name, ...) set a uniform by name (location looked up once, then cached)

The fragment shader gets in vec2 uv in [0,1], with (0, 0) at the bottom left, and you pass in everything else as uniforms. If a recompile fails, the previous program stays, so a typo never blanks the screen. Define TINY64_SHORT_NAMES before the include to also get the bare names running(), swap(), shader(), fullscreen_quad() and uniform1f(). They're off by default because they can clash with names in ordinary code. There's no bare time(), which would clash with <time.h>, so use t64_time() for that.

tiny64 has no HDR, mesh or post path. When you need those, use mc_render.h directly instead. tiny64 is only the engine with the setup hidden, so there's nothing to unpick when you move off it.

tiny64 is for windowed and dev builds, and it can't be packed into the 64k payload. The payload's compile (MC_IMPORT_HASH) doesn't declare mc_uniform, mc_set_f or mc_set_v3, which the uniform setters call, or mc_window_close, which t64_close calls, so the implementation doesn't compile there. A demo that's going to be packed uses the full API and sets its uniforms by layout(location). The tutorial shows how to build a tiny64 program.


4. mc.h: base header, types, utilities

mc.h has the common types and macros. Every other header includes it.

Integer type aliases

typedef uint8_t  mc_u8;
typedef uint32_t mc_u32;
typedef int32_t  mc_i32;
typedef uint64_t mc_u64;

Scalar constants

#define MC_PI      3.14159265358979323846f
#define MC_TAU     6.28318530717958647692f
#define MC_DEG2RAD (MC_PI / 180.0f)
#define MC_RAD2DEG (180.0f / MC_PI)

Utility macros

Macro Meaning
MC_ARRAY_COUNT(a) element count of a C array, as int
MC_MIN(a, b) / MC_MAX(a, b) min / max
MC_CLAMP(x, lo, hi) clamp x into [lo, hi]
MC_UNUSED(x) silence an unused-variable warning
MC_TIDY(...) teardown that the packed payload leaves out (below)

Logging and asserts (mc_log and mc_assert compile to nothing in release)

Macro Behaviour
mc_log(...) printf-style line to stderr with an [mc] prefix (dev); no-op (release)
mc_assert(c) abort with file/line if c is false (dev); no-op (release)
mc_fatal(...) report (dev) and exit(1), in both flavours (silently in release)

Teardown

MC_TIDY(...) wraps the cleanup a program does before it returns or exits, after a failed start or at the end of a run. In every build but one it expands to the statements as written, inside a do { } while (0). In the packed payload (MC_IMPORT_HASH) it's ((void)0), because the process ends right after and the OS releases everything.

mc_window_close, mc_audio_stop and mc_mesh_gpu_free are declared only outside the payload, and so is mc_gl_context_release. In code that goes into the payload, every call to them must sit inside MC_TIDY(...), or the payload doesn't compile. Code that never goes into it, such as a headless main(), can call them directly.

if (!d) { MC_TIDY(mc_window_close(w)); return 1; }   // a failed start
/* ... the show ... */
MC_TIDY(if (audio) mc_audio_stop(audio); mc_synth_free(synth); mc_window_close(w));

5. mc_math.h: math

Vectors and matrices are plain value types. Matrices are 4x4 and column-major in m[16], which is the OpenGL layout, so element (row r, col c) is m[c*4 + r]. Angles are in radians unless the name says otherwise.

Types

typedef struct { float x, y; }       mc_v2;
typedef struct { float x, y, z; }    mc_v3;
typedef struct { float x, y, z, w; } mc_v4;
typedef struct { float m[16]; }      mc_m4;   /* column-major */
typedef struct { float x, y, z, w; } mc_quat; /* (x,y,z,w), w is the scalar */

Constructors

mc_v2 mc_vec2(float x, float y);
mc_v3 mc_vec3(float x, float y, float z);
mc_v4 mc_vec4(float x, float y, float z, float w);
mc_v3 mc_v3_splat(float s);               // {s, s, s}

Scalar helpers

float mc_absf(float x);
float mc_signf(float x);                   // -1 / 0 / +1
float mc_minf(float a, float b);
float mc_maxf(float a, float b);
float mc_clampf(float x, float lo, float hi);
float mc_saturatef(float x);               // clamp to [0,1]
float mc_lerpf(float a, float b, float t);
float mc_fractf(float x);                  // x - floor(x)
float mc_modf_pos(float x, float m);       // positive modulo
float mc_smoothstepf(float e0, float e1, float x);
float mc_radians(float deg);
float mc_degrees(float rad);
float mc_remapf(float x, float a0, float a1, float b0, float b1);

vec2 / vec3 / vec4

mc_v2 mc_v2_add(mc_v2 a, mc_v2 b);
mc_v2 mc_v2_sub(mc_v2 a, mc_v2 b);
mc_v2 mc_v2_scale(mc_v2 a, float s);
float mc_v2_dot(mc_v2 a, mc_v2 b);
float mc_v2_len(mc_v2 a);
mc_v2 mc_v2_normalize(mc_v2 a);

mc_v3 mc_v3_add(mc_v3 a, mc_v3 b);
mc_v3 mc_v3_sub(mc_v3 a, mc_v3 b);
mc_v3 mc_v3_mul(mc_v3 a, mc_v3 b);         // component-wise
mc_v3 mc_v3_scale(mc_v3 a, float s);
mc_v3 mc_v3_neg(mc_v3 a);
float mc_v3_dot(mc_v3 a, mc_v3 b);
mc_v3 mc_v3_cross(mc_v3 a, mc_v3 b);
float mc_v3_len(mc_v3 a);
float mc_v3_len2(mc_v3 a);                  // squared length
float mc_v3_dist(mc_v3 a, mc_v3 b);
mc_v3 mc_v3_mad(mc_v3 a, mc_v3 b, float s); // a + b * s
mc_v3 mc_v3_dir(mc_v3 a, mc_v3 b);         // normalize(a - b): from b towards a
mc_v3 mc_v3_normalize(mc_v3 a);            // safe: {0,0,0} if ~zero
mc_v3 mc_v3_lerp(mc_v3 a, mc_v3 b, float t);
mc_v3 mc_v3_min(mc_v3 a, mc_v3 b);
mc_v3 mc_v3_max(mc_v3 a, mc_v3 b);
mc_v3 mc_v3_abs(mc_v3 a);
mc_v3 mc_v3_reflect(mc_v3 i, mc_v3 n);
mc_v3 mc_v3_clamp(mc_v3 v, float lo, float hi);

mc_v4 mc_v4_add(mc_v4 a, mc_v4 b);
mc_v4 mc_v4_scale(mc_v4 a, float s);
float mc_v4_dot(mc_v4 a, mc_v4 b);

mc_v3_mad is the step you take most often: mc_v3_mad(p, v, dt) moves p along v for dt. mc_v3_dir(a, b) is the unit vector that points from b towards a, so mc_v3_dir(target, eye) is a camera's forward direction. Like mc_v3_normalize, it returns the zero vector when the two points coincide.

mat4

mc_m4 mc_m4_identity(void);
mc_m4 mc_m4_mul(mc_m4 a, mc_m4 b);         // a * b
mc_m4 mc_m4_transpose(mc_m4 a);
mc_m4 mc_m4_inverse(mc_m4 a);              // general inverse (identity if singular)
mc_m4 mc_m4_translate(mc_v3 t);
mc_m4 mc_m4_scale(mc_v3 s);
mc_m4 mc_m4_scale1(float s);
mc_m4 mc_m4_rotate(mc_v3 axis, float angle);
mc_m4 mc_m4_from_quat(mc_quat q);
mc_m4 mc_m4_trs(mc_v3 t, mc_quat r, mc_v3 s);  // translate * rotate * scale
mc_m4 mc_m4_look_at(mc_v3 eye, mc_v3 center, mc_v3 up);
mc_m4 mc_m4_perspective(float fovy, float aspect, float znear, float zfar);
mc_m4 mc_m4_ortho(float l, float r, float b, float t, float n, float f);
mc_v4 mc_m4_mul_v4(mc_m4 a, mc_v4 v);
mc_v3 mc_m4_transform_point(mc_m4 a, mc_v3 p);  // w-divides
mc_v3 mc_m4_transform_dir(mc_m4 a, mc_v3 d);    // ignores translation

Quaternions ((x,y,z,w), w scalar)

mc_quat mc_quat_identity(void);
mc_quat mc_quat_axis_angle(mc_v3 axis, float angle);
mc_quat mc_quat_euler(float x, float y, float z);   // radians, XYZ order
mc_quat mc_quat_mul(mc_quat a, mc_quat b);
mc_quat mc_quat_normalize(mc_quat q);
mc_quat mc_quat_slerp(mc_quat a, mc_quat b, float t);
mc_v3   mc_quat_rotate(mc_quat q, mc_v3 v);

Hash & deterministic PRNG

mc_u32 mc_hash_u32(mc_u32 x);
mc_u32 mc_hash2(mc_u32 x, mc_u32 y);

typedef struct { mc_u32 s; } mc_rng;       // user-held state
mc_rng mc_rng_seed(mc_u32 seed);
mc_u32 mc_rng_next(mc_rng *r);
float  mc_rng_f(mc_rng *r);                 // [0,1)
float  mc_rng_range(mc_rng *r, float lo, float hi);
mc_v3  mc_rng_unit_v3(mc_rng *r);          // uniform point on the unit sphere

The PRNG is a 32-bit xorshift whose state lives in mc_rng, and a seed of 0 is replaced by a fixed non-zero constant. It's deterministic, so procedural content comes out the same from frame to frame and on every machine.

Noise (value/fBm in [-1,1])

float mc_noise2(float x, float y);
float mc_noise3(float x, float y, float z);
float mc_fbm2(float x, float y, int octaves, float lacunarity, float gain);
float mc_fbm3(mc_v3 p, int octaves, float lacunarity, float gain);
mc_v3 mc_curl3(mc_v3 p);                    // divergence-free flow direction

Easing

typedef enum {
    MC_EASE_LINEAR = 0,
    MC_EASE_IN_QUAD, MC_EASE_OUT_QUAD, MC_EASE_INOUT_QUAD,
    MC_EASE_IN_CUBIC, MC_EASE_OUT_CUBIC, MC_EASE_INOUT_CUBIC,
    MC_EASE_OUT_BACK, MC_EASE_OUT_ELASTIC, MC_EASE_OUT_BOUNCE,
    MC_EASE_INOUT_SINE,
    MC_EASE_COUNT
} mc_ease_kind;

float mc_ease(mc_ease_kind kind, float t);  // t is clamped to [0,1]

Splines

mc_v3 mc_catmull_rom(mc_v3 p0, mc_v3 p1, mc_v3 p2, mc_v3 p3, float t);
mc_v3 mc_bezier3(mc_v3 p0, mc_v3 p1, mc_v3 p2, mc_v3 p3, float t);
// Sample a Catmull-Rom path through n control points at u in [0,1].
// loop != 0 wraps the path; otherwise the endpoints are duplicated.
mc_v3 mc_spline_sample(const mc_v3 *pts, int n, float u, int loop);
// example: a looping camera path
static const mc_v3 path[] = { /* ... control points ... */ };
mc_v3 p = mc_spline_sample(path, MC_ARRAY_COUNT(path), mc_fractf(t / DUR), 1);

6. mc_gl.h: the GL loader

MICROCOSM doesn't link libGL. Every OpenGL entry point is resolved at runtime into one table, mcgl, and each of the usual gl* names is a #define into that table. Call sites read like ordinary OpenGL, but the binary imports no GL symbols.

One X-macro list, MC_GL_FUNCS(X), names every entry point once, as X(Name, PFN type) where Name is the GL name without its gl. The list generates both the typed members of mc_gl_api and the loader's NUL-separated name blob, and mc_gl_load fills the table in one loop over the union's slot[] array. Adding a function takes one line in the list plus its #define.

The list holds only what the engine and the reference demo call, so some entries serve a single job. glDrawArraysInstanced draws geometry grown from vertex ids with no vertex buffer, glUniform4fv uploads the jungle's placement tables, and glClearDepth and glScissor clear a patch of the cached sun map. glDispatchCompute and glMemoryBarrier run the compute shaders that sculpt the animals and place the seabed, and glGetBufferSubData reads back their triangle counts and the placed props. The sampler calls let one depth map be read both with comparison and raw. glCopyTexSubImage2D takes the jelly's copy of the picture, and glTexImage3D makes the shaders' noise volume.

A second list, MC_GL_TOOLS(X), holds the calls that are left out of the payload (MC_IMPORT_HASH), so using one there by mistake fails to compile. First come the calls only the tools need: info logs, renderbuffers, glFinish, glGetError, glGetString, glReadPixels and glPixelStorei. The list also has uniform lookup by name with the float and vec3 setters (glGetUniformLocation, glUniform1f, glUniform3f) and the teardown the payload skips (glDeleteShader, glDeleteVertexArrays). The non-instanced glDrawElements is there too, because the engine draws every mesh through the instanced calls. The split is enforced the other way as well: the payload's link fails if MC_GL_FUNCS holds an entry point the payload never calls (tools/importcheck.py). A demo that needs one of the tools' calls in its payload moves that line up into MC_GL_FUNCS. mcgl1 is an alias of mcgl, which some of the checks use.

<GL/gl.h> and <GL/glext.h> are included only for the GLenum=/=GLuint types, the GL_* enum values, and the PFNGL*PROC typedefs. GL_GLEXT_PROTOTYPES is never defined, so no GL symbol is referenced at link time.

extern mc_gl_api mcgl;

typedef void (*mc_gl_proc)(void);
// Resolve all entry points; returns 1 on success, 0 if any are missing.
// `getproc` is eglGetProcAddress, passed in so this file needs no EGL.
int mc_gl_load(mc_gl_proc (*getproc)(const char *name));

// Shared per-context objects: an empty VAO for full-screen passes, and the
// per-frame std140 uniform block on binding point 0 (re-upload between draws
// freely; every program's blocks start on binding 0 after each link).
GLuint mc_gl_fullscreen_vao(void);
void   mc_gl_frame_block(const void *data, GLsizeiptr bytes);
void   mc_gl_context_release(void);   // the platform calls this for you (not in the payload)

You don't normally call mc_gl_load yourself. The platform backend calls it right after it makes the context current, and from then on you can call glClear, glEnable, glDrawArrays and the rest directly.

There's one per-frame block, and the engine shares it with you. mc_renderer_end uploads the post stack's own data to it, so after end, binding 0 holds that instead of your block. Upload yours each frame before the first draw that reads it, and again after any pass that changes it, such as a shadow pass drawn from the light.

OpenGL 4.5 core was picked over Vulkan or D3D12 to save size, because the GL driver carries functionality that would otherwise end up in your binary. Every context is 4.5 core, and every shader starts with #version 450 core. The pipeline doc gives the full argument.

Dev-only error check

#if defined(MC_DEV)
void mc_gl_check_(const char *where);
#define MC_GL_CHECK() mc_gl_check_(__func__)   // logs any pending GL error
#else
#define MC_GL_CHECK() ((void)0)
#endif

7. mc_platform.h: window, context, input, headless

This is one API with two backends, and you link one of them (see Headless rendering below). Opening a window makes a GL 4.5 core context current and loads the GL table.

Types

typedef struct mc_window mc_window;

typedef struct {
    int         width;
    int         height;
    const char *title;
    int         vsync;       // 1: swaps wait for the display; 0: they don't
    int         fullscreen;  // 0: a window; 1: the screen's size, the EWMH
                             // fullscreen hint, a blank pointer
} mc_window_desc;

Both flags are plain switches, so a zeroed desc means no vsync and no fullscreen. Set .vsync = 1 for a real-time window. The headless backend ignores both.

Lifecycle

mc_window *mc_window_open(const mc_window_desc *desc);  // NULL on failure
void       mc_window_close(mc_window *w);   // not in the payload: call it inside MC_TIDY

With fullscreen set, the windowed backend ignores width and height and creates the window at the screen's size from the first frame. It also sets the _NET_WM_STATE_FULLSCREEN hint but doesn't rely on it, so fullscreen works on a server with no window manager to honour the hint. The pointer is hidden by giving the cursor an empty 1x1 bitmap for its picture and mask. If a window manager resizes the window anyway, that's handled like any other resize, through mc_window_size. The headless backend has no screen and ignores the flag.

mc_window_close, mc_audio_stop, mc_mesh_gpu_free and mc_gl_context_release aren't declared in the payload's compile (MC_IMPORT_HASH). The payload never tears anything down, so the imports and GL entry points these calls would need are left out too. Call them inside MC_TIDY(...) (see mc.h).

Per-frame

int    mc_window_pump(mc_window *w);   // process events; 0 => quit (closed or ESC)
void   mc_window_swap(mc_window *w);   // present (swap buffers; glFinish when headless)
void   mc_window_size(mc_window *w, int *width, int *height);
GLuint mc_window_output_fbo(mc_window *w); // 0 on-screen; a readable RGBA8 FBO headless

Time

double mc_time_seconds(void);          // monotonic seconds since first call

Input

typedef enum {
    MC_KEY_ESCAPE = 0, MC_KEY_SPACE,
    MC_KEY_LEFT, MC_KEY_RIGHT, MC_KEY_UP, MC_KEY_DOWN,
    MC_KEY_COUNT
} mc_key;
int mc_key_down(mc_window *w, mc_key key);

On the windowed backend, pressing ESC also makes the next mc_window_pump return 0. The headless backend reports no keys and never asks to quit.

Headless read-back

// Read the output framebuffer as tightly-packed top-left-origin RGBA8.
// `rgba` must hold width*height*4 bytes. Returns 1 on success. Not in the payload.
int mc_window_read_pixels(mc_window *w, unsigned char *rgba);
// Save the output framebuffer as a PNG (dev builds only; no-op in release).
int mc_window_save_png(mc_window *w, const char *path);

GL's rows start at the bottom left. Both backends flip them into image rows that start at the top left, so saved PNGs are upright. mc_window_save_png writes a lossless RGBA PNG with uncompressed DEFLATE blocks. The files are large, but the writer is simple to get right, and it only runs in dev builds.

// opening a window
mc_window_desc desc = { .width = 1280, .height = 720,
                        .title = "demo", .vsync = 1 };
mc_window *w = mc_window_open(&desc);
if (!w) return 1;

7.1. Headless rendering

Headless rendering is chosen when you link, and no macro selects it. Link src/platform_surfaceless.c in place of src/platform_x11_egl.c and leave out -lX11. The engine then needs only -lEGL -lm, plus -lpthread -lasound if you also link src/audio.c. The two files define the same functions, so link exactly one. mc_window_open then brings up a surfaceless EGL context, with no window and no display server, and it runs on Mesa's software llvmpipe too.

The headless backend uses only width and height from the desc. It reports no keys and never asks to quit. Its mc_window_output_fbo is a readable RGBA8 FBO of that size, mc_window_swap just calls glFinish, and mc_window_read_pixels and mc_window_save_png get the picture out. So the same frame code can drive a window or write PNGs on a machine with no display.

MC_HEADLESS and MC_CAPTURE belong to the reference demo, and the engine never reads them. In demos/hello-world/main.c they pick which main() is compiled: one that saves a fixed set of frames as PNGs, or the capture, which streams the show for make capture. Both are linked with the surfaceless backend, as above. The pipeline doc describes the capture.


8. mc_mesh.h: procedural meshes

mc_mesh.h builds meshes on the CPU. A mesh is an indexed triangle list of interleaved (position, normal, uv) vertices, held in heap arrays that grow as needed. Build meshes once at startup, upload them to the GPU (mc_mesh_upload, in mc_render.h), then free the CPU copy. Triangles wind counter-clockwise seen from outside, normals point outwards, and primitives are centred at the origin.

Types

typedef struct { mc_v3 pos; mc_v3 normal; mc_v2 uv; } mc_vertex;

typedef struct {
    mc_vertex *verts;  int vert_count;  int vert_cap;
    mc_u32    *indices; int index_count; int index_cap;  // 3 per face
} mc_mesh;

Low-level build

mc_mesh mc_mesh_new(void);
void    mc_mesh_free(mc_mesh *m);          // frees arrays, zeroes the struct
int     mc_mesh_push_vertex(mc_mesh *m, mc_v3 pos, mc_v3 normal);  // returns index
void    mc_mesh_push_tri(mc_mesh *m, mc_u32 a, mc_u32 b, mc_u32 c);
void    mc_mesh_recompute_normals(mc_mesh *m);  // area-weighted, smooth
void    mc_mesh_append(mc_mesh *dst, const mc_mesh *src, mc_m4 xform);
void    mc_mesh_grid(mc_mesh *m, int base, int rows, int cols);  // stitch a vertex grid

mc_mesh_append keeps the source's UVs. Its normals are only correct for a rigid transform or a positive uniform scale, so for a non-uniform scale, apply mc_mesh_scale to the source first. Subdivision interpolates UVs, so keep seam vertices separate.

mc_mesh_grid is the one stitcher behind every lofted or swept surface, such as the torus and the cylinder wall. Push a row-major block of (rows+1) x (cols+1) vertices, with vertex (i,j) at base + i*(cols+1) + j, then call it once. Each cell becomes the two triangles (i,j) (i,j+1) (i+1,j) and (i,j+1) (i+1,j+1) (i+1,j). They face front when (i,j), (i,j+1), (i+1,j) run counter-clockwise seen from outside. A strip of (bottom, top) vertex pairs is just cols = 1. The reference demo calls nothing in mc_mesh.h. It grows its shapes in vertex shaders from gl_VertexID over the same two-triangle cells, and it sculpts its animals and works out its seabed and boulders on the GPU.

Primitives (all centred at the origin, all return an owned mesh)

mc_mesh mc_mesh_box(mc_v3 half_extents);
mc_mesh mc_mesh_plane(float size, int subdivisions);            // XZ plane, +Y normal
mc_mesh mc_mesh_uv_sphere(float radius, int slices, int stacks);
mc_mesh mc_mesh_icosphere(float radius, int subdivisions);
mc_mesh mc_mesh_torus(float major_r, float minor_r, int major_seg, int minor_seg);
mc_mesh mc_mesh_cylinder(float radius, float height, int segments, int caps);
mc_mesh mc_mesh_cone(float radius, float height, int segments);
mc_mesh mc_mesh_rounded_box(mc_v3 half_extents, float round, int subdivisions);
mc_mesh mc_mesh_terrain(float size, int subdivisions, float amp, mc_u32 seed);
mc_mesh mc_mesh_torus_knot(float scale, float tube, int p, int q,
                           int curve_seg, int tube_sides);

Notes on behaviour (from src/mesh.c):

  • mc_mesh_box is six flat faces, each its own grid (add_cube_faces), and each face can be subdivided for the rounded-box path.
  • mc_mesh_icosphere starts from a 12-vertex icosahedron, applies mc_mesh_subdivide subdivisions times, then projects every vertex onto the sphere, so each normal is the normalised position.
  • mc_mesh_cylinder's caps is a flag (nonzero adds top and bottom caps).
  • mc_mesh_rounded_box clamps round to [0,1]. The shape is the Minkowski sum of an inner box and a sphere of radius round * min(half_extents), so flat faces stay flat and the edges and corners are rounded. With round == 1 it degenerates to a sphere of radius min(half_extents).
  • mc_mesh_terrain raises an XZ plane (subdivisions cells per side) by fBm of amplitude amp, shaped with a smootherstep so valleys come out flat and ridges stay sharp, and then recomputes the normals. This is the shape of Scene 2's seabed. The demo computes the same terrain on the GPU (uwplace.comp), and the checks keep the two in agreement.
  • mc_mesh_torus_knot sweeps a round tube of radius tube along the (p,q) knot curve, with curve_seg samples along the curve and tube_sides around each cross-section. The normal is the tube's radial direction, so it's correct without recomputing. The terrain and the knot are both textured by triplanar projection, so they carry no UVs.
  • Resolution parameters are floored to safe minimums. A sphere, for example, clamps to slices >= 3 and stacks >= 2.

Modifiers (in place)

void mc_mesh_subdivide(mc_mesh *m, int iterations);     // split each tri into 4, welded
void mc_mesh_displace_noise(mc_mesh *m, float amount, float frequency, mc_u32 seed);
void mc_mesh_spherify(mc_mesh *m, float radius, float t);  // blend toward a sphere, t in [0,1]
void mc_mesh_scale(mc_mesh *m, mc_v3 s);                 // scales positions; fixes normals

mc_mesh_subdivide caches midpoints in a hash, so shared edges split the same way and the mesh stays welded. mc_mesh_displace_noise pushes vertices along their normals by amount * fbm, and mc_mesh_spherify blends toward a sphere. Both recompute the normals afterwards. mc_mesh_scale transforms normals by the inverse transpose of the diagonal scale, which is exact under non-uniform scale and keeps hard edges.

// build an organic blob and hand it to the GPU
mc_mesh m = mc_mesh_icosphere(1.0f, 3);
mc_mesh_displace_noise(&m, 0.15f, 1.5f, 0xBEEFu);
mc_gpu_mesh gpu = mc_mesh_upload(&m);   // mc_render.h
mc_mesh_free(&m);                        // CPU copy no longer needed

9. mc_render.h: shaders, GPU meshes, targets, post

The renderer owns the HDR scene target and the fixed post shaders. The lower-level helpers for shaders, GPU meshes and render targets are public, so you can draw whatever you like between begin and end.

9.1. Shaders

typedef struct { GLuint prog; } mc_shader;

mc_shader mc_shader_make(const char *vs_src, const char *fs_src); // prog==0 on failure
void      mc_shader_free(mc_shader *s);
void      mc_shader_use(mc_shader s);
int       mc_uniform(mc_shader s, const char *name);   // location, or -1 if absent (not in the payload)

Uniform setters (by location). Give every uniform that the C side sets a layout(location = N) in its shader and set it by that number, so nothing is looked up by name. The location must be unique within its program, and an array takes consecutive locations. For a uniform without a location, mc_uniform is still there, and you look it up once. Give every sampler that isn't on unit 0 a layout(binding = N) so that no C code has to assign units either, and give blocks a layout(binding) the same way. glslmin.py renames uniforms that have a location or a binding like any other global. Uniforms without one keep their names, and so do blocks. A loc of -1 is silently ignored, so a missing uniform does no harm.

void mc_set_i (int loc, int v);
void mc_set_f (int loc, float v);   // not in the payload
void mc_set_v2(int loc, mc_v2 v);
void mc_set_v3(int loc, mc_v3 v);   // not in the payload
void mc_set_v4(int loc, mc_v4 v);
void mc_set_m4(int loc, mc_m4 v);   // column-major, no transpose

The shipped payload (MC_IMPORT_HASH) never looks a uniform up by name and calls neither mc_set_f nor mc_set_v3, so mc_uniform and those two setters are compiled out of it, along with their GL entry points. Calling one there is a compile error rather than a call through an empty slot.

In dev builds, compile and link failures are logged through mc_log with the GL info log. In release they fail quietly (prog == 0). If you pass the same pointer as both sources, mc_shader_make builds a compute program from it instead, which you dispatch with glDispatchCompute after mc_shader_use. A manifest names a compute program by listing its .comp file twice.

9.2. GPU meshes & instancing

typedef struct {
    GLuint vao, vbo, ibo;  // vbo 0: no vertex data (procedural); ibo 0: not indexed
    GLuint inst_vbo;     // per-instance buffer, 0 if unused
    int    inst_cap;     // instances the buffer is sized for
    int    index_count;
    int    vertex_count;
    GLenum index_type;   // GL_UNSIGNED_SHORT when eligible, otherwise GL_UNSIGNED_INT
    float  radius;       // reach from the origin: upload measures it, procedural is told
} mc_gpu_mesh;

mc_gpu_mesh mc_mesh_upload(const mc_mesh *m);  // vertex attribs: 0 = pos, 1 = normal, 7 = uv
mc_gpu_mesh mc_mesh_procedural(int count, const mc_u32 *indices, float radius);
void        mc_mesh_gpu_free(mc_gpu_mesh *g);   // not in the payload: call it inside MC_TIDY
void        mc_mesh_draw(mc_gpu_mesh g);        // one non-instanced draw

mc_mesh_procedural makes a mesh with no vertex data. The vertex shader builds each vertex from gl_VertexID, so the mesh is just a VAO (for instances) and a count. If you give it indices (count vertex ids, three per triangle, uploaded as 32-bit), it draws those, and a vertex shared by several triangles is shaded only once. That's worth it when the vertex shader is heavy, as it is for the demo's boulders. With NULL it draws count vertices as a plain triangle list. Every draw helper takes either kind of mesh: an indexed mesh draws its indices, and one without an index buffer draws its vertex_count vertices. Both go through the instanced calls.

mc_mesh_upload binds attribute 0 to the position and 1 to the normal of the interleaved mc_vertex layout, so a vertex shader must declare those locations. It also binds the UV to attribute 7. The upload turns the CPU's 32-bit indices into 16-bit GPU indices when the largest index used fits in 16 bits (up to 65535), and keeps 32-bit if allocating the converted copy fails. The draw helpers follow index_type. The CPU geometry and the vertex layout are left as they are.

Per-instance data

typedef struct {
    mc_m4 model;   // attribs 2,3,4,5 (one vec4 per column)
    mc_v4 color;   // attrib 6 (rgb + emissive in .a)
} mc_instance;

void mc_mesh_reserve_instances(mc_gpu_mesh *g, int capacity); // pre-size (optional)
void mc_mesh_set_instances(mc_gpu_mesh *g, const mc_instance *data, int count);
void mc_mesh_draw_instanced(mc_gpu_mesh g, int count);
void mc_mesh_draw_instance_range(mc_gpu_mesh g, int first, int count);

The instance buffer only ever grows. mc_mesh_set_instances updates it in place with glBufferSubData, and reallocates it (re-pointing the divisor attributes) only when count is larger than the current capacity. Call mc_mesh_reserve_instances once after the upload to size it up front, so it never has to reallocate in the middle of an animation. An instanced vertex shader reads attributes 2-5 as the model matrix columns and 6 as the per-instance colour.

// instanced draw of many shapes with one call
mc_mesh_reserve_instances(&gpu, 1024);         // once, after upload
/* ... fill `instances[0..n-1]` each frame ... */
mc_mesh_set_instances(&gpu, instances, n);
mc_mesh_draw_instanced(gpu, n);

9.3. Render targets

typedef struct {
    GLuint fbo, color, depth;
    int    w, h;
    int    hdr;   // 1 for RGBA16F; 0 for other formats
} mc_target;

mc_target mc_target_make(int w, int h, int hdr, int with_depth);
mc_target mc_target_make_format(int w, int h, GLenum format, int with_depth);
int       mc_target_ok(const mc_target *t); // false for failed creation
void      mc_target_free(mc_target *t);
void      mc_target_bind(const mc_target *t);     // NULL binds framebuffer 0
void      mc_target_bind_fbo(GLuint fbo, int w, int h);

void mc_fullscreen_triangle(void);  // draws 3 verts with the current shader, no VBO

mc_target_make builds an FBO with a colour texture (RGBA16F or RGBA8, linear filtering, clamp-to-edge) and, optionally, a DEPTH_COMPONENT24 texture you can sample. mc_target_make_format also accepts GL_R8, or a format of zero with depth enabled for a true depth-only framebuffer (draw and read buffers GL_NONE). On failure it returns a zero target, so check mc_target_ok before drawing. mc_fullscreen_triangle draws a full-screen triangle with no vertex data from you. The core profile still needs a VAO bound, and the function binds one itself. Every post pass is built on it.

GL's four switches as one value

enum { MC_ZTEST_ON = 1, MC_ZTEST_OFF = 2, MC_ZWRITE_ON = 4, MC_ZWRITE_OFF = 8,
       MC_CULL_ON = 16, MC_CULL_OFF = 32, MC_BLEND_ON = 64, MC_BLEND_OFF = 128 };
void mc_gl_switch(int s);

There are two bits each for the depth test, depth writes, face culling and blending. 0 leaves the switch alone, 1 turns it on and 2 turns it off. So mc_gl_switch(MC_ZTEST_OFF | MC_ZWRITE_OFF) does the same as glDisable(GL_DEPTH_TEST); glDepthMask(GL_FALSE). The demo folds every run of two or more such calls into one. The blend and depth functions and the culled face are still set separately, next to it.

9.4. Post-processing

typedef struct {
    float exposure;     // linear exposure before tonemap    (default 1.1)
    float bloom;        // bloom mix amount                   (default 0.08)
    float bloom_thresh; // luminance threshold, bright pass   (default 1.0)
    float vignette;     // 0..1                               (default 0.32)
    float grain;        // film grain amount                  (default 0.012)
    float chroma;       // chromatic aberration               (default 0.35)
    float saturation;   // colour saturation multiplier       (default 1.08)
    float fade;         // 0 = black, 1 = full image (fades)  (default 1.0)
    float time;         // seconds, animates grain            (default 0.0)
    float ssao;         // ambient occlusion (GTAO) strength, 0 = off (default 0)
    float blur;         // motion blur's furthest reach each way, a share of
                        // the screen (0 or less: 0.25%; never off) (default 0)
    float znear, zfar;  // the camera's near/far: AO, DOF     (default 0.1, 400)
    float projx, projy; // the camera's tan(fov/2) in x, y: AO (default 1, 1)
    float white;        // 0 = the image, 1 = pure white      (default 0)
    float dof;          // depth-of-field blur at infinity, px, 0 = off (default 0)
    float focus;        // focus distance, scene units        (default 0)
    GLuint vol_tex;     // caller's reduced-res HDR light to add (default 0: none)
    float vol;          // how much of vol_tex to add         (default 0)
    mc_v3 sun_dir;      // toward the sun (world): contact shadows (default zero: off)
} mc_post;

mc_post mc_post_defaults(void);

ssao, white, dof, vol_tex and sun_dir default to off. Motion blur can't be switched off. It's camera motion blur, worked out from each pixel's depth and the camera's move since the last frame, so it blurs only while the camera moves. Each pixel is smeared over a third of its movement, like a 120° shutter, and blur caps how far that smear reaches each way. At 0 or below the cap is 0.25% of the screen, and that's also the smallest cap there is.

znear, zfar, projx and projy must describe the camera the scene was drawn with. The defaults (0.1, 400 and a square 90° view) don't even match mc_camera_default (near 0.05, far 200, a 55° fovy). Set them from your camera every frame, as the reference demo does:

mc_post post = mc_post_defaults();
post.znear = cam.znear;
post.zfar  = cam.zfar;
post.projy = tanf(cam.fovy * 0.5f);
post.projx = post.projy * mc_renderer_aspect(R);  // the aspect the projection used

The ambient occlusion, the contact shadows and the depth of field rebuild view depth and position from these four. With the wrong values, the occlusion's reach and the focus distance come out wrong.

fade and white combine. The composite multiplies by fade (toward black) and then mixes toward white by white, which is how Scene 2 fades out to white and Scene 3 opens out of it. dof is the blur radius, in full-resolution pixels, that a point at infinity gets, and focus is the distance that stays sharp. vol_tex is any reduced-resolution HDR texture of light the caller has rendered, such as Scene 3's volumetric shafts. The composite upsamples it, scales it by vol and adds it before the tonemap.

ssao scales the ambient occlusion, and sun_dir turns on the contact shadows. The composite applies both according to the scene's alpha, which it reads as the ambient share of each pixel's light: col *= a * mix(1, ao, ssao) + (1 - a) * contact. A scene that writes alpha 1 gets AO on everything and no contact shadows. A scene that writes the true share, as Scene 3 does, keeps the AO's darkening off sunlit patches and gets the small sun shadows that the shadow maps miss. Additive passes should leave alpha alone (mask it, or blend it with GL_ZERO, GL_ONE).

9.5. The renderer

typedef struct mc_renderer mc_renderer;

mc_renderer *mc_renderer_make(int w, int h);        // presentation size, initially 100% internal
void         mc_renderer_free(mc_renderer *r);
int          mc_renderer_resize(mc_renderer *r, int w, int h); // 0: failure, old targets retained
int          mc_renderer_set_scale(mc_renderer *r, int percent); // 25..200; transactional
void         mc_renderer_size(const mc_renderer *r, int *w, int *h); // internal pixel dimensions

// TAA: call once at the start of each frame with its time, then build the
// camera with mc_renderer_camera (proj * view slid by the frame's sub-pixel
// jitter; the frame's first camera is the one the history follows).
void  mc_renderer_frame(mc_renderer *r, double t);
mc_m4 mc_renderer_camera(mc_renderer *r, mc_m4 proj, mc_m4 view);

void  mc_renderer_begin(mc_renderer *r, mc_v3 clear);  // bind HDR target, clear (linear RGB)
void  mc_renderer_end(mc_renderer *r, GLuint output_fbo, int out_w, int out_h,
                      mc_post params);                  // post stack -> output_fbo, centred
float mc_renderer_aspect(const mc_renderer *r);         // presentation aspect for projections
void  mc_renderer_bind_scene(mc_renderer *r);           // re-bind the HDR target mid-frame
GLuint mc_renderer_depth(const mc_renderer *r);         // the scene's depth texture
GLuint mc_renderer_color(const mc_renderer *r);         // ...and its HDR colour so far
// Depth-aware separable blur of a reduced-resolution buffer: a -> b -> a (same
// size; zn/zf linearise the scene depth it is weighted by).
void  mc_renderer_blur(mc_renderer *r, mc_target *a, mc_target *b, float zn, float zf);

mc_renderer_begin binds the HDR scene target, clears colour and depth, and sets a sensible default state (GL_DEPTH_TEST / LEQUAL, back-face culling, CCW front faces, depth writes on). mc_renderer_end runs these steps in order:

  1. Optional half-resolution GTAO (.r) and contact shadows (.g, when sun_dir is set), with two depth-aware filter draws.
  2. Optional depth of field. The scene and its circles of confusion are reduced to quarter resolution, and then a bokeh gather runs. The gather also measures how much blurred foreground reaches each texel, so the composite can spread it over the sharp background behind. A small bright point takes its colour from the mean two mip levels down, read through four taps under a large circle, so it opens into an even, round disc.
  3. When bloom is on, a bright pass at half resolution, then its mip chain. The bright pass averages four taps with Karis's 1 / (1 + luma) weights, so a lone glint can't flash a halo, and it drops non-finite pixels, so a stray NaN can't spread. The mip chain goes down six levels, from half resolution to 1/64 (Jimenez's 13 taps per step), and back up. On the way up, each level is spread by an exact 3x3 tent and added onto the one above.
  4. The composite, into an RGBA16F LDR target at the scene's size. It applies camera motion blur (a 120° shutter, reaching blur of the screen each way, 0.25% unless set) and chromatic aberration (red and blue each spread over half to all of their offset). Then come depth of field, the AO and contact shadows (upsampled depth-aware and weighted by the ambient share), bloom and the caller's vol_tex light. Last come a filmic tonemap, a split-tone grade, saturation, vignette, gamma, fade and white.
  5. TAA. The jittered samples are folded into the history at the output size, which also upsamples a reduced internal resolution. The rebuilt centre is sharpened slightly, in luma only.
  6. The history is drawn onto the requested output framebuffer, with film grain and a dither. The grain is high-passed, re-seeded 24 times a second, added in the display's own steps and strongest in the midtones. Its cells are whole pixels, one pixel on a side for every 1080 lines of output, so a 4K screen gets the same grain as a 1080p one. The dither is triangular, ±1 step on the 8-bit write, and the fades don't remove it, so pure black and pure white stay pure. The picture is centred. If the framebuffer is larger than the renderer's output size (out_w x out_h compared with the last resize), the picture goes in its middle with opaque black around it. That's how the demo letterboxes to 16:9.

A caller can draw its own passes between begin and end, such as a shadow map into its own FBO, and then rebind the scene target with mc_renderer_bind_scene. mc_renderer_depth gives such passes the scene's depth texture. mc_renderer_color gives them the picture drawn so far, for a pass that reflects or refracts it: bind it to a texture unit and draw into a target of your own. mc_renderer_blur smooths a reduced-resolution buffer with the same depth-aware filter the AO uses, ready to hand to the composite as vol_tex.

mc_renderer_depth and mc_renderer_color return a different texture every frame. At the end of each frame, mc_renderer_end swaps the scene target with the previous frame's, which the TAA reads, and a resize or mc_renderer_set_scale replaces both. So query them each frame, between begin and end, and don't keep the handles.

mc_renderer_end also overwrites the per-frame uniform block on binding 0 with its own (see mc_gl.h). Upload your frame block again each frame before your draws.

Without mc_renderer_frame and mc_renderer_camera, every frame is plain and unjittered, and the TAA has no history to blend. The output is still correct, just not anti-aliased. With them, the jitter depends only on the time, and the history starts over on any jump, pause or step back, so two frames rendered on their own at the same time are identical.

Both begin and the post passes disable blending. A resize builds a complete replacement set of targets before swapping them in, so check its return value. Half-resolution sizes round up, including for odd sizes and 1x1 outputs.

Create and resize the renderer at the presentation size, then pick an internal percentage with mc_renderer_set_scale. Resizing keeps the percentage. For example, 1280x720 at 75% renders internally at 960x540 and presents at the native size through the TAA. Above 100% the frame is supersampled: the TAA resolves it at its own size and the present pass filters it down. The demo renders Scene 1 and the end card at 2160 lines and Scenes 2 and 3 at 1620, which is 200% and 150% at 1080p. For shader resolution or LOD calculations, query the actual rounded size with mc_renderer_size. An invalid scale or a failed target allocation keeps the previous complete setup. Callers must leave scissor, stencil and polygon offset off, colour writes on and polygon fill mode active. The header spells out this GL-state contract.


10. mc_scene.h: camera, tracks, timeline

Small, allocation-free helpers that turn time into camera matrices and animated values.

Camera

typedef struct {
    mc_v3 pos; mc_v3 target; mc_v3 up;
    float fovy;   // radians
    float znear; float zfar;
} mc_camera;

mc_camera mc_camera_default(void);
mc_m4     mc_camera_view(const mc_camera *c);
mc_m4     mc_camera_proj(const mc_camera *c, float aspect);

mc_camera_default puts the eye at (0, 1.5, 6) looking at the origin with +Y up, with a 55° fovy, the near plane at 0.05 and the far plane at 200. mc_camera_view is mc_m4_look_at and mc_camera_proj is mc_m4_perspective with the camera's own values. Pass mc_renderer_aspect(R) as the aspect, and give the post stack the same camera (see Post-processing).

Frustum culling

typedef struct { mc_v4 plane[6]; } mc_frustum;
mc_frustum mc_frustum_make(mc_m4 view_proj);
int mc_frustum_sphere(const mc_frustum *f, mc_v3 center, float radius);

mc_frustum_make takes the six planes of a view-projection matrix's clip volume: left, right, bottom, top, near and far, for GL's -w..w range. Each plane is normalised, so a point's distance from it comes out in world units. mc_frustum_sphere returns 0 when the sphere is wholly outside one of the planes, and 1 otherwise. The test is conservative: a sphere just off a corner of the frustum can still pass, but a visible one never fails. The demo culls both against the camera and against the sun's view, to keep shadow casters that are off screen.

mc_frustum fr = mc_frustum_make(vp);          // once per frame, per camera
if (mc_frustum_sphere(&fr, pos, gpu.radius * scale))
    mc_mesh_draw(gpu);

Keyframe tracks (keys sorted by .t, and .ease selects the curve into that key)

typedef struct { float t; float v; int ease; } mc_key_f;
typedef struct { float t; mc_v3 v; int ease; } mc_key_v3;

float mc_track_f (const mc_key_f  *keys, int n, float t);
mc_v3 mc_track_v3(const mc_key_v3 *keys, int n, float t);

.ease is an mc_ease_kind value. A track can have any number of keys, and when several keys share a timestamp, the last one wins. Outside the keys' range the value is clamped, and seeking backwards works.

Timeline & beat helpers

typedef struct {
    double t;         // current time in seconds
    double duration;  // total length in seconds
    double bpm;       // beats per minute
} mc_timeline;

double mc_beats(const mc_timeline *tl);                        // t, in beats
float  mc_beat_pulse(const mc_timeline *tl, float sharpness);  // 0..1, peaks each beat
int    mc_window_active(const mc_timeline *tl, double start, double len);  // 1 if inside
float  mc_window_phase(const mc_timeline *tl, double start, double len);   // 0..1, clamped

mc_beat_pulse returns a (1-phase)^sharpness falloff from each beat, which suits anything that should bounce on the beat. mc_window_active and mc_window_phase switch scene sections on and off by the clock and give each one a 0..1 phase.

mc_timeline tl = { .t = t, .duration = DUR, .bpm = 124.0 };
float bounce = mc_beat_pulse(&tl, 5.0f);          // snappy per-beat pulse
if (mc_window_active(&tl, 15.48, 7.74)) { /* only during "hook A", bars 8-12 */ }

11. mc_audio.h: synth and audio backend

A software synth that uses no samples, and a real-time backend that runs it on its own thread. The demo syncs its visuals to mc_audio_time(), a monotonic estimate of the playback position: the frames submitted so far minus the ALSA device delay. Between updates it interpolates, but only while playback is running, and never past the submitted position.

#define MC_SAMPLE_RATE 44100
#define MC_CHANNELS    2

Synth

typedef struct mc_synth mc_synth;

mc_synth *mc_synth_make(mc_u32 seed);  // seed perturbs incidental detail only
void      mc_synth_free(mc_synth *s);
// Render `frames` interleaved stereo frames (2*frames floats) in [-1,1].
// Advances internal time. Real-time safe: no allocation, no I/O.
void      mc_synth_render(mc_synth *s, float *out, int frames);
double    mc_synth_bpm(const mc_synth *s);
double    mc_synth_length(const mc_synth *s);   // song length in seconds

The song is fixed: mc_synth_bpm returns 124 and mc_synth_length about 278.7 s, and the song doesn't loop. seed varies only incidental detail, so a given seed always renders the same piece. The soundtrack describes the music and how to change it.

Backend

// Pull callback: fill `out` with `frames` interleaved stereo frames.
// Called from the audio thread; must be real-time safe.
typedef void (*mc_audio_cb)(float *out, int frames, void *user);

typedef struct mc_audio mc_audio;

mc_audio *mc_audio_start(mc_audio_cb cb, void *user);  // spawns the thread; NULL on failure
void      mc_audio_stop(mc_audio *a);  // not in the payload: call it inside MC_TIDY
double    mc_audio_time(mc_audio *a);  // monotonic playback estimate; see fallback limitations below

The backend tries ALSA directly: FLOAT_LE, 44100 Hz, stereo, set up with one snd_pcm_set_params call that asks for a 100 ms buffer in four periods. ALSA resamples if the device runs at another rate. If that fails, the backend falls back to a silent sink that paces the callback in real time, so the clock still advances. mc_audio_time is guarded by a mutex and safe to read from the render thread. Pending samples survive short writes, retryable errors and ALSA's underrun recovery, and a new synth block is only generated once the pending one has been consumed. ALSA writes are nonblocking and every wait is bounded. There is no fallback to a subprocess or a pipe. The silent sink sleeps until absolute monotonic deadlines, so the sleeps don't drift block by block. Each 1024-frame wait is about 23.2 ms. Shutdown needs your audio callback to return promptly, since the engine can't put a time limit on a callback that blocks.

static void audio_pull(float *out, int frames, void *user) {
    mc_synth_render((mc_synth *)user, out, frames);
}

mc_synth *synth = mc_synth_make(0x1234u);
mc_audio *audio = mc_audio_start(audio_pull, synth);  // may be NULL; fall back to a clock
/* ... */
double t = audio ? mc_audio_time(audio) : (mc_time_seconds() - start);
/* ... */
MC_TIDY(if (audio) mc_audio_stop(audio); mc_synth_free(synth));