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 DSL — shader / raymarch | Raw GLSL — glsl | |
|---|---|---|
| you write | manic formulas (let r = …) | a real GLSL fragment shader |
| best for | fields, SDF art, ray-marched scenes you want in the scene | pasting an existing Shadertoy, or exotic multi-pass tricks |
| integrates with | parameters, camera3, the timeline, compositing, the editor’s autocomplete/checks | the universal iTime/iResolution/iMouse (plus opt-in uniforms) |
| runs on | GPU and a deterministic CPU fallback | GPU (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) + optionalsat/val(HSL — great withatan2),- 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/vare 0..1 on both axes, so a rawlength(u-0.5, v-0.5)is an ellipse on a wide canvas. Correct withasp(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 effect →
glsl(). 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()suppliesiTime/iResolution/iMouse(and theiCam*basis withcamera3) — but noiChannel0..3textures/audio/video/buffers. A paste that readstexture(iChannel0, …)won’t compile. When the channel was only a noise source, swap it for a procedural hash:texture(iChannel0, vec2(k)).x→fract(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 it — plot, 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.