Tutorial: make your own 64k intro with MICROCOSM
From a ten-line shader to a size-optimised binary

Table of Contents

↑ Back to the index

There are two ways into MICROCOSM, and you should start with the small one.

If all you want is a fragment shader on screen, such as a plasma, an SDF scene or a shadertoy-style experiment, reach for tiny64.h. It's a header-only facade over the engine, and it gets you a windowed, animated shader in about ten lines.

When one shader stops being enough, because you want meshes, a camera, instancing, the HDR post stack, a soundtrack or a packed 64k file, drop the facade and use the full mc_ API. The rest of this tutorial builds that up, from an empty file to a size-optimised binary, and ends with what a demo directory of your own needs. For a finished example, read demos/hello-world/main.c alongside it.

Prerequisites: a Linux box with the requirements installed, and the MICROCOSM tree. Every command below runs from the top of the tree.

1. 0. The ten-line version (tiny64)

#define TINY64_IMPL          // pull in the implementation (exactly one .c)
#define TINY64_SHORT_NAMES   // running(), swap(), shader(), uniform1f(), ...
#include <microcosm/tiny64.h>

static const char *FRAG =
    "#version 450 core\n"
    "in vec2 uv; out vec4 o; uniform float t;\n"
    "void main(){ o = vec4(0.5 + 0.5*cos(t + uv.xyx + vec3(0,2,4)), 1.0); }";

int main(void) {
    if (!t64_open(1280, 720, "hi")) return 1;
    shader(FRAG);
    while (running()) {
        uniform1f("t", (float)t64_time());
        fullscreen_quad();
        swap();
    }
    t64_close();
}

That's a complete intro. Your fragment shader gets a full-screen quad, an in vec2 uv in [0,1], and whatever uniforms you set by name with t64_set1f, t64_set2f and the rest (or the uniform1f(...) shorthand). It leaves out HDR, bloom and meshes, because it's meant for when the shader is the whole demo.

To build it, save it as hi.c at the top of the tree, compile it against the engine sources and link the windowed backend. render.c includes the engine's post-pass shaders as a generated header, so generate that first:

python3 tools/genshaders.py --raw --engine src/shaders --engine-output build/inc/mc_engine_shaders.h
gcc -Iinclude -Ibuild/inc \
    src/math.c src/gl_loader.c src/platform_common.c \
    src/mesh.c src/render.c src/scene.c src/platform_x11_egl.c \
    hi.c -lEGL -lX11 -lm -lpthread -lasound -o hi
./hi                # ESC to quit

tiny64.h only calls the public engine API, so it links like any other program that uses MICROCOSM. The TINY64_SHORT_NAMES macros are opt-in because the bare names can collide with ordinary code. time() would clash with <time.h>, so it has no short name, and you call t64_time() instead.

tiny64 is for windowed builds like this one, and it can't be packed into a 64k file. The packed payload's compile doesn't declare mc_uniform, mc_set_f or mc_set_v3, which tiny64 uses to set uniforms by name, because the payload loads no GL call for them. t64_close also calls mc_window_close, which the payload leaves out as well, since it just exits. So a tiny64 program saved as demos/<name>/main.c gets a dev binary from make <name>, but the release half of the build fails to compile. To ship it in 64k, move it to the full API, which the rest of this tutorial uses.

2. 1. Open a window and make a renderer

Opening a window makes a GL 4.5 core context current and loads the GL function table for you. Make the renderer at the window's size, which is its output size: the size of the picture mc_renderer_end puts on screen. The HDR scene target it draws into has an internal resolution of its own, a percentage of the output size set with mc_renderer_set_scale (25-200, and 100 by default). Below 100 the post stack's TAA upsamples the picture into its output-size history, and above 100 it supersamples. To render at a lower resolution, lower the scale rather than making the renderer smaller. A renderer smaller than the framebuffer it's resolved into is drawn centred in it with black around it, which is how the demo letterboxes a screen of a different shape.

#include "microcosm/mc_platform.h"
#include "microcosm/mc_render.h"
#include "microcosm/mc_mesh.h"
#include "microcosm/mc_scene.h"
#include "microcosm/mc_math.h"
#include "microcosm/mc.h"
#include <math.h>
#include "microcosm/mc_imports.h"   // always the last include (see below)

mc_window_desc desc = { .width = 1280, .height = 720,
                        .title = "my intro", .vsync = 1 };
mc_window *w = mc_window_open(&desc);
if (!w) return 1;

mc_renderer *R = mc_renderer_make(1280, 720);        // output: the window's size
if (!R || !mc_renderer_set_scale(R, 75)) return 1;   // render 960x540; the TAA upsamples

mc_imports.h does nothing until you pack the intro, and then it has to come after every other header. mc_imports.h goes last explains why.

3. 2. Build and upload a mesh

Meshes are built on the CPU (mc_mesh.h) and then uploaded to GPU buffers (mc_mesh_upload, in mc_render.h). You can apply as many modifiers as you like before uploading, and once the mesh is uploaded you can free the CPU copy.

mc_mesh m = mc_mesh_icosphere(1.0f, 3);         // radius, subdivisions
mc_mesh_displace_noise(&m, 0.12f, 1.8f, 0xBEEFu); // make it organic
mc_gpu_mesh gpu = mc_mesh_upload(&m);
mc_mesh_free(&m);                                 // GPU has its own copy now

mc_mesh_upload binds vertex attribute 0 to the position and 1 to the normal, so your vertex shader should declare those locations. The CPU builders use realloc and free, which matters once you pack the intro (see free and realloc).

4. 3. Write a shader

mc_shader_make takes GLSL source strings. Scene shaders output linear HDR, and the post stack does the tonemapping to sRGB, so don't gamma-correct in your shader. Give every uniform you set from C a layout(location = N) that's unique within the program (an array takes N, N+1 and so on), and set it by that number. Nothing is looked up by name, although mc_uniform can still find a uniform that has no location by its name, outside the packed build. A sampler on any unit other than 0 gets a layout(binding = N) in the same way, so no C code assigns texture units either.

static const char *VS =
    "#version 450 core\n"
    "layout(location=0) in vec3 aPos;"
    "layout(location=1) in vec3 aNormal;"
    "layout(location=0) uniform mat4 uViewProj;"
    "layout(location=1) uniform mat4 uModel;"
    "out vec3 vN;"
    "void main(){"
    "  vN = mat3(uModel) * aNormal;"
    "  gl_Position = uViewProj * uModel * vec4(aPos, 1.0);"
    "}";

static const char *FS =
    "#version 450 core\n"
    "in vec3 vN; out vec4 o;"
    "void main(){"
    "  vec3 L = normalize(vec3(0.4, 0.8, 0.3));"
    "  float d = max(dot(normalize(vN), L), 0.0);"
    "  vec3 col = vec3(0.05, 0.06, 0.1) + vec3(1.0, 0.6, 0.3) * d;"
    "  o = vec4(col, 1.0);"
    "}";

enum { U_VIEWPROJ = 0, U_MODEL = 1 };   // the shader's uniform locations
mc_shader sh = mc_shader_make(VS, FS);

Strings in C are the simplest way to start. Once the shaders grow, move them into files in your own demo's demos/<name>/shaders/, so you can edit them while the dev build runs. The release build then bakes them into the binary, and your main.c has to fetch them from there (Shaders on disk need shaders_gen.h). Reloading them as you save is code in your demo, not in the engine (Hot-reloading shaders).

5. 4. The begin, draw, end loop

Each frame, pump events (mc_window_pump returns 0 on ESC or when the window closes), tell the renderer the frame's time and build your matrices. Then call mc_renderer_begin, draw, and call mc_renderer_end. Matrices are column-major and mc_m4_mul(a, b) is a * b, so the view-projection is proj * view, but let mc_renderer_camera build it for you. It shifts the projection by a sub-pixel step each frame, and the post stack's TAA blends those frames into an anti-aliased picture. If you leave out mc_renderer_frame and mc_renderer_camera, you still get frames, just without anti-aliasing.

double start = mc_time_seconds();
while (mc_window_pump(w)) {
    double t = mc_time_seconds() - start;
    int ww, hh; mc_window_size(w, &ww, &hh);
    mc_renderer_resize(R, ww, hh);                        // follow the window (same scale)
    mc_renderer_frame(R, t);                              // TAA: this frame's time

    mc_camera cam = mc_camera_default();
    cam.pos = mc_vec3(4.0f * cosf((float)t), 1.5f, 4.0f * sinf((float)t));
    mc_m4 view = mc_camera_view(&cam);
    mc_m4 proj = mc_camera_proj(&cam, mc_renderer_aspect(R));
    mc_m4 vp   = mc_renderer_camera(R, proj, view);       // proj * view, jittered

    mc_quat q   = mc_quat_axis_angle(mc_vec3(0, 1, 0), (float)t);
    mc_m4 model = mc_m4_trs(mc_vec3(0, 0, 0), q, mc_v3_splat(1.0f));

    mc_renderer_begin(R, mc_vec3(0.02f, 0.02f, 0.05f));  // bind HDR target + clear
    mc_shader_use(sh);
    mc_set_m4(U_VIEWPROJ, vp);
    mc_set_m4(U_MODEL, model);
    mc_mesh_draw(gpu);

    mc_post post = mc_post_defaults();
    post.time = (float)t;                                 // animates grain
    mc_renderer_end(R, mc_window_output_fbo(w), ww, hh, post);  // post -> window

    mc_window_swap(w);
}

mc_window_output_fbo(w) is framebuffer 0 on the windowed backend, so the post stack resolves straight to the screen. When the window is resized, mc_renderer_resize resizes the renderer to match, at the same scale, and the TAA's history starts over. If the size hasn't changed it returns straight away. A failed resize, to 0x0 for example, keeps the previous targets.

5.1. Drawing a crowd: instancing

To draw many copies cheaply, fill an mc_instance array (a model matrix and an RGB + emissive colour for each) and issue one instanced draw. Size the buffer once, up front, so it doesn't reallocate during the animation. The instanced path needs a vertex shader that reads attributes 2-5 as the model matrix's columns and attribute 6 as the colour, like demos/hello-world/shaders/object.vert.

mc_mesh_reserve_instances(&gpu, 512);      // once, after upload
/* each frame: */
mc_instance inst[512];
int n = 0;
for (int i = 0; i < 256; ++i) {
    mc_m4 mdl = mc_m4_trs(/* position */, /* rotation */, /* scale */);
    inst[n].model = mdl;
    inst[n].color = mc_vec4(1.0f, 0.6f, 0.3f, 0.0f);  // .a = emissive
    ++n;
}
mc_mesh_set_instances(&gpu, inst, n);
mc_mesh_draw_instanced(gpu, n);

6. 5. Add the synth

The synth renders interleaved stereo frames, and the audio backend runs it on its own thread. Drive your clock from mc_audio_time() so the visuals stay locked to the music. If audio fails to start, fall back to the wall clock.

#include "microcosm/mc_audio.h"     // before mc_imports.h, like every header

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
/* in the loop: */
double t = audio ? mc_audio_time(audio) : (mc_time_seconds() - start);
/* at shutdown (step 6 explains MC_TIDY): */
MC_TIDY(if (audio) mc_audio_stop(audio);
        mc_synth_free(synth));

Use the timeline helpers to sync motion to the beat:

mc_timeline tl = { .t = t, .duration = mc_synth_length(synth), .bpm = mc_synth_bpm(synth) };
float bounce = mc_beat_pulse(&tl, 5.0f);   // 0..1 spike each beat

7. 6. Clean up

MC_TIDY(mc_mesh_gpu_free(&gpu);
        mc_shader_free(&sh);
        mc_renderer_free(R);
        mc_window_close(w));

Put the cleanup inside MC_TIDY(...) from mc.h. The packed 64k build just exits and lets the OS take everything back, so mc_window_close, mc_audio_stop and mc_mesh_gpu_free aren't even declared there. MC_TIDY compiles its arguments out of that build, and every other build runs them. The toolkit has the details.

8. The complete program

This is the whole windowed intro, put together from the steps above. Save it as demos/my-intro/main.c. It compiles cleanly with -Wall -Wextra, as an ordinary program and as a packed 64k file.

/* demos/my-intro/main.c - a minimal MICROCOSM intro. */
#include "microcosm/mc_platform.h"
#include "microcosm/mc_render.h"
#include "microcosm/mc_mesh.h"
#include "microcosm/mc_scene.h"
#include "microcosm/mc_math.h"
#include "microcosm/mc.h"
#include "microcosm/mc_audio.h"
#include <math.h>
#include "microcosm/mc_imports.h"   /* last: the packed build calls libraries through it */

static const char *VS =
    "#version 450 core\n"
    "layout(location=0) in vec3 aPos;"
    "layout(location=1) in vec3 aNormal;"
    "layout(location=0) uniform mat4 uViewProj;"
    "layout(location=1) uniform mat4 uModel;"
    "out vec3 vN;"
    "void main(){"
    "  vN = mat3(uModel) * aNormal;"
    "  gl_Position = uViewProj * uModel * vec4(aPos, 1.0);"
    "}";

static const char *FS =
    "#version 450 core\n"
    "in vec3 vN; out vec4 o;"
    "void main(){"
    "  vec3 L = normalize(vec3(0.4, 0.8, 0.3));"
    "  float d = max(dot(normalize(vN), L), 0.0);"
    "  vec3 col = vec3(0.05, 0.06, 0.1) + vec3(1.0, 0.6, 0.3) * d;"
    "  o = vec4(col, 1.0);"
    "}";

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

int main(void) {
    mc_window_desc desc = { .width = 1280, .height = 720,
                            .title = "my intro", .vsync = 1 };
    mc_window *w = mc_window_open(&desc);
    if (!w) return 1;

    mc_renderer *R = mc_renderer_make(1280, 720);
    if (!R || !mc_renderer_set_scale(R, 75)) return 1;

    mc_mesh m = mc_mesh_icosphere(1.0f, 3);
    mc_mesh_displace_noise(&m, 0.12f, 1.8f, 0xBEEFu);
    mc_gpu_mesh gpu = mc_mesh_upload(&m);
    mc_mesh_free(&m);

    enum { U_VIEWPROJ = 0, U_MODEL = 1 };   // the shader's uniform locations
    mc_shader sh = mc_shader_make(VS, FS);

    mc_synth *synth = mc_synth_make(0x1234u);
    mc_audio *audio = mc_audio_start(audio_pull, synth);

    double start = mc_time_seconds();
    while (mc_window_pump(w)) {
        double t = audio ? mc_audio_time(audio) : (mc_time_seconds() - start);
        int ww, hh;
        mc_window_size(w, &ww, &hh);
        mc_renderer_resize(R, ww, hh);
        mc_renderer_frame(R, t);

        mc_camera cam = mc_camera_default();
        cam.pos = mc_vec3(4.0f * cosf((float)t), 1.5f, 4.0f * sinf((float)t));
        mc_m4 view = mc_camera_view(&cam);
        mc_m4 proj = mc_camera_proj(&cam, mc_renderer_aspect(R));
        mc_m4 vp   = mc_renderer_camera(R, proj, view);

        mc_quat q   = mc_quat_axis_angle(mc_vec3(0, 1, 0), (float)t);
        mc_m4 model = mc_m4_trs(mc_vec3(0, 0, 0), q, mc_v3_splat(1.0f));

        mc_renderer_begin(R, mc_vec3(0.02f, 0.02f, 0.05f));
        mc_shader_use(sh);
        mc_set_m4(U_VIEWPROJ, vp);
        mc_set_m4(U_MODEL, model);
        mc_mesh_draw(gpu);

        mc_post post = mc_post_defaults();
        post.time = (float)t;
        mc_renderer_end(R, mc_window_output_fbo(w), ww, hh, post);

        mc_window_swap(w);
    }

    /* the packed build leaves this out and just exits (step 6) */
    MC_TIDY(if (audio) mc_audio_stop(audio);
            mc_synth_free(synth);
            mc_mesh_gpu_free(&gpu);
            mc_shader_free(&sh);
            mc_renderer_free(R);
            mc_window_close(w));
    return 0;
}

8.1. Building it with make

Every directory under demos/ with a main.c in it is a demo, and the Makefile builds it by name. With the program saved as demos/my-intro/main.c:

make my-intro     # builds both binaries into build/my-intro/:
                  #   my-intro-dev.bin  (debuggable, on-screen)
                  #   my-intro.bin  (the 64k self-extractor)
make my-intro PACK=quick   # the same, without the packer's slow search
./build/my-intro/my-intro-dev.bin   # ESC to quit
./build/my-intro/my-intro.bin       # the packed one
make all          # build every demo under demos/
make clean

Each demo builds into its own build/<name>/, so demos never overwrite each other's files. make <demo> builds that demo's two binaries, and a bare make (the same as make all) builds every demo under demos/. This intro keeps its GLSL in C strings, so it needs nothing else in its directory. What a demo directory needs covers the rest: shader files, the programs.def manifest and the import list. Copying demos/hello-world/ is another way to start, with all of that already in place.

8.2. Building it by hand

You can also compile the program yourself, against the engine sources, with exactly one platform backend (platform_x11_egl.c for a window). Generate the engine's shader header first, as in step 0:

gcc -Wall -Wextra -Iinclude -Ibuild/inc \
    src/math.c src/gl_loader.c src/platform_common.c \
    src/mesh.c src/render.c src/scene.c src/synth.c src/audio.c \
    src/platform_x11_egl.c demos/my-intro/main.c \
    -lEGL -lX11 -lm -lpthread -lasound -o mintro
./mintro            # ESC to quit

That gives you the windowed program only. The packed file needs the release build, which is what make is for.

8.3. Scrubbing while you work

It's worth giving your dev build (<name>-dev.bin) the transport the reference demo uses to inspect its timeline. SPACE pauses, LEFT and RIGHT jump 5 seconds, and UP and DOWN double or halve the speed. Look for dev_transport in demos/hello-world/main.c. It's defined under #if !defined(MC_RELEASE) && !defined(MC_HEADLESS) && !defined(MC_CAPTURE) and called only outside the release and headless builds, so it costs nothing in the shipped file. If your intro simulates anything, it has to be able to jump backwards too, which the demo's sim_seek does by replaying from a fixed seed. Being able to stop on one frame and step back and forth around it makes tuning a transition much quicker.

9. 7. Build for size

make my-intro already does this: the release half of the build is the 64k pipeline. In short, these are its main steps:

  1. Minify and bake the shaders, the demo's and the engine's post-pass ones, into headers: python3 tools/genshaders.py demos/my-intro/shaders -o build/my-intro/shaders_gen.h --engine src/shaders --engine-output build/my-intro/mc_engine_shaders.h. A demo with shader files has to include the first header in its release build (Shaders on disk need shaders_gen.h).
  2. Compile for size. Use the release flags (-Oz -ffast-math -flto -ffunction-sections -fdata-sections -fno-pic -fno-pie -fvisibility=hidden -DMC_RELEASE ...), plus -DMC_IMPORT_HASH for the payload, and link with --gc-sections -z norelro -s ... and the payload's own entry point, src/mc_start.c. The full set is in the Makefile (REL_FLAGS, REL_LDFLAGS, PAYLOAD_DEFS and PAYLOAD_LDFLAGS).
  3. Strip the ELF with python3 tools/elfshrink.py build/you.elf -o build/you.elf --verify.
  4. Pack it into one ELF. tools/mkelf.sh build/you.elf you.bin produces a self-extracting ELF: the coded program inside a libc-free launcher that decodes it with a context-mixing model and runs it from memory. Before it writes the file, it checks that the file decompresses back to the original ELF and fits in 65,536 bytes.

For the reference demo, that turns a release ELF of about 249 KB into the 63,237-byte hello-world.bin. The pipeline doc tells the whole story, from the flags to the tools and where the bytes go. Its tools section also covers the steps the Makefile adds to these four.

10. What a demo directory needs

The Makefile treats every demos/<name>/main.c as a demo, and make <name> builds it. This is what the build expects to find in the directory, and what it leaves to your code.

demos/my-intro/
├── main.c          required, and the only file the Makefile compiles
├── programs.def    the program manifest, if the demo has shaders/
├── shaders/        optional: *.vert, *.frag, *.comp and *.glsl files
└── *.c, *.def      anything else main.c includes

The Makefile compiles main.c and nothing else from the directory. To split a demo into several files, #include them from main.c, as the reference demo does with scene3.c. Make tracks those includes, so editing one rebuilds the demo.

10.1. Shaders on disk need shaders_gen.h

If demos/<name>/shaders/ holds any shader files, the release build minifies them together with the engine's post-pass shaders and bakes them all into one blob in build/<name>/shaders_gen.h. The engine then gets its own shaders through mc_shader_at(), which only that header defines. So your main.c has to include it under MC_RELEASE, or the payload's link fails with undefined reference to `mc_shader_at'. The header also gives each program's two sources by index:

#ifdef MC_RELEASE
#include "shaders_gen.h"   /* in build/<name>/, which is on the release include path */
static mc_shader program_source(int i) {
    return mc_shader_make(mc_shader_at(mc_program_shaders[i][0]),
                          mc_shader_at(mc_program_shaders[i][1]));
}
#endif

mc_program_shaders[i] holds the blob indices of program i's two files, in programs.def order (next section). mc_demo_shader("name.frag") finds a shader by file name instead, but the payload's compile leaves it out because the payload has no strcmp, so only tools and checks can use it. The dev build doesn't get this header at all. There your code reads the files itself, from MC_SHADER_ROOT, the absolute path of demos/<name>/shaders that Make passes to the dev compile.

A demo without shader files, like the intro above, needs none of this. Its engine shaders go into a header of their own. See genshaders.py for how the bake works.

10.2. The programs.def manifest

demos/<name>/programs.def lists the demo's shader programs, and genshaders.py reads it to build mc_program_shaders. It's an X-macro list, with one entry per line:

/* demos/my-intro/programs.def */
MC_PROGRAM(blob,  "blob.vert",  "blob.frag")
MC_PROGRAM(place, "place.comp", "place.comp")   /* compute: its one file, twice */
MC_BAKE("quad.vert", "bake.frag")               /* drawn once at load */
#include "more.def"                             /* more entries, in place */

Each entry is MC_PROGRAM(field, "vertex file", "fragment file") or MC_BAKE("vertex file", "fragment file"), naming files in shaders/. The script treats the two the same: counting from 0 in file order, with included manifests expanded where they're included, entry i becomes mc_program_shaders[i]. It reads the file a line at a time, so keep each entry on one line, with both file names as string literals. A compute program names its .comp file twice, and mc_shader_make builds a compute program when both stages get the same source.

What field and the difference between the two macros mean is up to your C code. main.c defines MC_PROGRAM and MC_BAKE, includes the file, and gets an enum of program numbers, a struct field per program or a table of file names for the dev build, all in the same order. The reference demo does all three, so look for programs.def in its main.c. There a bake gets no field, because it's used once and freed. Make treats every *.def in the directory as an input of the bake, so editing the manifest bakes the shaders again.

10.3. mc_imports.h goes last

Include microcosm/mc_imports.h in main.c after every other header, the system headers too. In the payload build (MC_IMPORT_HASH) it turns each library function on its list into a macro that calls through a table, which the payload's entry point fills by name hash (the pipeline doc explains why). Each macro takes its type from the system header's declaration, so that header has to come first. A system header included after it would have its own declarations rewritten and fail to compile.

Leave the include out and your own calls go to the libraries by name. In the intro above GCC can even merge the sinf and cosf of one angle into one sincosf, which isn't on the list at all. In every other build the header is empty, so a mistake here only shows in the packed file.

10.4. free and realloc

The import list holds what the reference demo's payload calls, and that doesn't include free or realloc, because the demo builds every mesh in its shaders. The CPU mesh builders grow their arrays with realloc, and mc_mesh_free and mc_mesh_upload call free. If your intro uses them, as the one above does, its payload imports those two by name. It still builds and runs, but it then carries a symbol table, a glibc version requirement and two relocations. In the intro above that's about 100 bytes of the packed file.

To have them found by hash like everything else, add them to include/microcosm/mc_imports.h, to the list and beside calloc's macro:

#define MC_IMPORTS(X) \
    X(malloc) X(calloc) X(realloc) X(free) X(getenv) X(strtol) \
    ...
#define calloc          MC_IMP(calloc)
#define realloc         MC_IMP(realloc)
#define free            MC_IMP(free)

tools/genimports.py hashes the new list on the next build. The list is shared by every demo, though, and the reference demo's payload calls neither function. Its build checks strictly that every import is used (importcheck.py), so after that change make hello-world fails with "the payload resolves these at startup but never calls them". Build it with make hello-world IMPORTCHECK=warn from then on, which costs it a few bytes. Other demos are only warned about unused imports anyway.

10.5. Hot-reloading shaders

The engine doesn't watch files. Reading GLSL from disk and reloading it when it changes is the reference demo's own code, in its main.c under #ifndef MC_RELEASE. build_program reads a program's two files from MC_SHADER_ROOT. maybe_reload_shaders, called once a frame, hashes the files every quarter of a second and rebuilds the programs whose files changed. Then it swaps them all in together, or keeps the old ones if any of them fails to compile. Copy those functions if you want the same in your demo. The release build has no MC_SHADER_ROOT and reads nothing from disk.