The MICROCOSM toolkit: API reference
The public mc_ API, module by module
Table of Contents
- 1. Orientation
- 2. Conventions
- 3. The easy path:
tiny64.h - 4.
mc.h: base header, types, utilities - 5.
mc_math.h: math - 6.
mc_gl.h: the GL loader - 7.
mc_platform.h: window, context, input, headless - 8.
mc_mesh.h: procedural meshes - 9.
mc_render.h: shaders, GPU meshes, targets, post - 10.
mc_scene.h: camera, tracks, timeline - 11.
mc_audio.h: synth and audio backend
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_atbuilds 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_beginculls back faces. - Angles are in radians, including
mc_camera'sfovy. mc_m4_perspectiveandmc_m4_orthoproduce 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_depthis 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, assrc/shaders/ao_blur.fragdoes: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'suvruns 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=c11hides the POSIX declarations that the platform and audio code need, such asclock_gettime, sosrc/platform_common.candsrc/audio.cfail 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_boxis six flat faces, each its own grid (add_cube_faces), and each face can be subdivided for the rounded-box path.mc_mesh_icospherestarts from a 12-vertex icosahedron, appliesmc_mesh_subdividesubdivisionstimes, then projects every vertex onto the sphere, so each normal is the normalised position.mc_mesh_cylinder'scapsis a flag (nonzero adds top and bottom caps).mc_mesh_rounded_boxclampsroundto[0,1]. The shape is the Minkowski sum of an inner box and a sphere of radiusround * min(half_extents), so flat faces stay flat and the edges and corners are rounded. Withround == 1it degenerates to a sphere of radiusmin(half_extents).mc_mesh_terrainraises an XZ plane (subdivisionscells per side) by fBm of amplitudeamp, 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_knotsweeps a round tube of radiustubealong the(p,q)knot curve, withcurve_segsamples along the curve andtube_sidesaround 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 >= 3andstacks >= 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:
- Optional half-resolution GTAO (
.r) and contact shadows (.g, whensun_diris set), with two depth-aware filter draws. - 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.
- 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.
- The composite, into an RGBA16F LDR target at the scene's size. It applies
camera motion blur (a 120° shutter, reaching
blurof 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'svol_texlight. Last come a filmic tonemap, a split-tone grade, saturation, vignette, gamma, fade and white. - 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.
- 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_wxout_hcompared 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));