Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Shaders — per-pixel worlds

Most shapes in manic are things — a circle, a line, a label you name and move. A shader is different: it colours every pixel from a formula. No cast, no objects — just maths evaluated once per pixel, every frame. It’s how you get plasmas, fractals, fields, ray-marched 3D, and the whole “generative” look.

manic gives you two paths to this, and they meet in the middle:

The DSLshader / raymarchRaw GLSLglsl
you writemanic formulas (let r = …)a real GLSL fragment shader
best forfields, SDF art, ray-marched scenes you want in the scenepasting an existing Shadertoy, or exotic multi-pass tricks
integrates withparameters, camera3, the timeline, compositing, the editor’s autocomplete/checksthe universal iTime/iResolution/iMouse (plus opt-in uniforms)
runs onGPU and a deterministic CPU fallbackGPU (Metal / llvmpipe / WebGL)

Rule of thumb: reach for the DSL when the shader should be part of your animated scene (driven by a slider, orbited by a camera, composited over other shapes, recorded deterministically). Reach for glsl() when you already have the shader, or when you need something the DSL deliberately doesn’t have (loops with feedback, textures, multi-pass).

Both are first-class scene citizens — you show, fade, and time them like any entity.


Path 1 — shader: a 2-D colour field

The per-pixel twin of cloud. Each pixel’s colour is a closed-form function of its normalised coordinates u/v (0..1), aspect asp (width/height), and live time t:

canvas("16:9");
shader(bg) {
  let x = (u - 0.5) * asp;          // centre + aspect-correct
  let y = v - 0.5;
  let d = length(vec2(x, y));        // distance from the middle
  let hue = mod(d * 300.0 - t * 40.0, 360.0);
  let sat = 0.7;
  let val = 0.6;
}

Output one of:

  • r / g / b — each 0..1 (RGB),
  • hue (degrees) + optional sat / val (HSL — great with atan2),
  • a lone c — greyscale,
  • and optionally let alpha (0..1) so the field is transparent and composites over the scene behind it.

Everything else you write is an intermediate let. Fill the canvas, or render into a panel: shader(id, (cx,cy), w, h) { … }.

⚠️ u/v are 0..1 on both axes, so a raw length(u-0.5, v-0.5) is an ellipse on a wide canvas. Correct with asp (let x = (u-0.5)*asp) as above.

Draw with distance — the SDF shape library

You rarely need to derive a shape by hand. manic ships signed-distance builtins — negative inside the shape, positive outside — that you band, fill, outline, or combine:

shader(art) {
  let x = u * asp;
  let y = v;
  // three shapes, unioned (smin ≈ min; there's no callable `min` in a formula)
  let d = smin(smin(
            sdhexagon(x - 0.6, y - 0.5, 0.15),
            sdstar(x - 1.05, y - 0.5, 0.17, 5), 0.01),
            sdcircle(x - 1.5, y - 0.5, 0.13), 0.01);
  let fl = fill(d, 0.0, 0.004);        // solid inside
  let ol = stroke(d, 0.0, 0.03, 0.004); // bright outline
  let r = mix(0.1, 0.16, fl) + ol;
  let g = mix(0.1, 0.52, fl) + ol;
  let b = mix(0.2, 0.62, fl) + ol;
}

2-D shapes (all of centred x,y): sdcircle(x,y,r), sdtriangle(x,y,r), sdhexagon(x,y,r), sdpolygon(x,y,r,n) (pentagon/octagon/… by side count), sdstar(x,y,r,n), sdrhombus(x,y,rx,ry), sdvesica(x,y,r,d), sdroundbox(x,y,w,h,r). Combine: smin(a,b,k) (smooth union — also a plain union with tiny k), sdsub(a,b) (carve), sdint(a,b) (intersect), sdround(d,r) (grow), sdonion(d,r) (shell). Tile: rep(x,r) (infinite grid), rand2(i,j) (per-cell hash).

Shaping, colour & easing helpers

Scalar helpers usable in any formula: map(v,iMin,iMax[,oMin,oMax]), saturate, within, select(a,b,c) (branchless), gain/parabola/gaussian/bump (iq curves), smootherstep/quintic/cubic, mirror (triangle wave), decimate (quantise). Colour: contrast(v,k), blends blendscreen/blendoverlay/ blendsoftlight(a,b), gamma2linear/linear2gamma. Springy easings of a 0..1 value: elasticout(t), bounceout(t), backout(t). Plus the GLSL idioms (mix, smoothstep, clamp, fract, step, atan2) and the escape-hatch generators voronoi(x,y), mandelbrot(x,y), julia(zx,zy,cx,cy).

Vector maths — custom SDFs & domain warps

For anything a builtin doesn’t cover — a fold you invent, a reflection, or warping space itself — the DSL has real vectors (in shader/raymarch only):

shader(warp) {
  let p = vec2(u * asp - 0.9, v - 0.5);  // name the coordinate as a vec2
  let ang = length(p) * 7.0 - t;         // twist grows with radius
  let q = rot2(p, ang);                   // rotate SPACE, reuse the vec2
  let hue = mod(200.0 + q.x * 500.0 + q.y * 300.0, 360.0);
  let sat = 0.8;
  let val = 0.5 + 0.4 * sin(q.y * 40.0);
}

Build vec2(a,b) / vec3(a,b,c), combine component-wise (+ − * /, scalars broadcast), read parts with .x / .y / .z, and use dot, cross, length, normalize, distance, reflect, and rot2(v, angle). A let can be a vector (let p = vec2(...)) — name it once, reuse it. (A shader’s final r/g/b/etc. must still be a number.)

Driven by a slider

Reference any scene parameter by name — the field re-renders as it animates, not only with time:

parameter(freq, (640, 660), 3, 1, 14, "freq", 0);
shader(rings) {
  let d = hypot((u - 0.5) * asp, v - 0.5);
  let hue = mod(d * freq * 90.0, 360.0);   // ← the slider drives the pattern
  let sat = 0.8;
  let val = 0.55;
}
to(freq, value, 14, 6, smooth);

Path 1 (cont.) — raymarch: a ray-marched 3-D scene

Write only the signed-distance field let d (the distance from any point x/y/z at time t to your scene). The engine marches a ray per pixel, finds the surface, computes the normal, and shades it — no loop, no vectors required:

canvas("16:9");
template("black");
camera3((3.2, -3.6, 2.4), (0, 0, 0), 40, perspective);

raymarch(scene) {
  let tor = sdtorus(x, y, z, 0.95, 0.26);      // ring in the XY plane
  let oct = sdoctahedron(x, y, z - 1.05, 0.5);  // floating above
  let d = smin(tor, oct, 0.18);                 // smooth-union them
  // optional hit colour, from the surface normal nx/ny/nz + height hz + t
  let hue = mod(190.0 + nz * 70.0 + t * 30.0, 360.0);
  let sat = 0.82;
  let val = 0.52 + 0.32 * nz;
}
orbit3(70, 0, 5.4, 20, smooth);   // the marcher re-reads camera3 every frame

3-D primitives (Z is the main axis): sdsphere(x,y,z,r), sdbox3(x,y,z,bx,by,bz), sdtorus(x,y,z,R,r), sdcylinder(x,y,z,h,r), sdcapsule(x,y,z,h,r), sdoctahedron(x,y,z,r), sdplane(x,y,z,h) — combined with the same smin/sdsub/sdint. Add let r/g/b or let hue(+sat/val) for your own colour, and let alpha to make missed rays transparent so the object composites over the scene. The camera comes from camera3, so orbit3 sweeps it. And you can use the vec3 maths above for custom operators.


Path 2 — glsl: run a real GLSL shader

Already have a shader? Paste it. glsl(id, ) hands a Shadertoy-style mainImage straight to the graphics pipeline — unchanged — at full resolution:

canvas("16:9");
glsl(bg, `
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    vec2 uv = fragCoord / iResolution.xy;
    vec3 col = 0.5 + 0.5 * cos(iTime + uv.xyx + vec3(0, 2, 4));
    fragColor = vec4(col, 1.0);
}
`);
wait(6);

You get iTime, iResolution, and iMouse for free. GLSL ES 2.0 rules apply (loops need constant bounds — fine for a fixed-step ray-march). The same shader runs on Metal (Mac), llvmpipe (headless/servers), and WebGL (browser).

A raw paste can still opt into the scene: declare uniform float u_<name>; and it auto-binds to the scene parameter <name>; declare the camera basis uniform vec3 iCamEye; (+ iCamFwd/iCamRight/iCamUp/iCamThf) and it binds to camera3, so orbit3 sweeps your raw shader too.


Which path?

  • A field, an SDF gallery, a ray-marched object you want animated in your scene → the DSL (shader / raymarch). You get parameters, camera3, the timeline, alpha compositing, deterministic recording, and editor autocomplete/checks.
  • An existing GLSL shader, or a multi-pass/texture/feedback effectglsl(). It runs as-is; the DSL doesn’t try to be a full GLSL.
  • Not sure? Start in the DSL. If you hit a wall, glsl() is always there as the escape hatch — the two share one pipeline, so nothing you learn is wasted.

Reproducible exports

The DSL path has a deterministic CPU fallback, so manic FILE --cpu-shaders re-renders shader/raymarch scenes byte-for-byte (GPU output can vary by a few least-significant bits across drivers). Handy for byte-exact export jobs. Raw glsl() is GPU-only and unaffected.

Porting a Shadertoy — two things to know

Real Shadertoys mostly run unchanged, but two mismatches are worth knowing up front:

  • No input channels. glsl() supplies iTime / iResolution / iMouse (and the iCam* basis with camera3) — but no iChannel0..3 textures/audio/video/buffers. A paste that reads texture(iChannel0, …) won’t compile. When the channel was only a noise source, swap it for a procedural hash: texture(iChannel0, vec2(k)).xfract(sin(k*91.7)*43758.5453).
  • Don’t shadow a builtin. A variable named mix, step, length, … shadows the GLSL function of the same name; strict compilers reject it. Rename the variable.

fragCoord already matches Shadertoy’s bottom-left origin, so up/down is correct out of the box. With those two caveats, classics like TDM’s Seascape run byte-for-byte.

Power move — a shader hosting a lesson

Because a glsl() shader is an ordinary, low-z scene entity, the entire teaching layer composites on top of itplot, tangent, slope, deriv, extrema, inflections, equation (LaTeX), counters, captions. So a real Shadertoy can be the living stage for a rigorous lesson, with the shader animating underneath the whole time.

examples/glsl-derivative-wave.manic“manic meets Shadertoy” — runs TDM’s raymarched ocean while a full Calculus-1 lesson plays over it: the derivative as the slope of a wave (zero at each crest), then the second derivative for concavity, the second-derivative test (max vs. min), and inflection points — the whole sin → cos → −sin ladder, on a living sea.

That’s the point of the shared pipeline: the shader isn’t a wallpaper you switch to — it’s one more entity in the same scene as your maths, your 3-D, and your timeline.