# glslop — authoring craft Part of glslop's agent docs — index: https://glslop.com/llms.txt ## Authoring craft (what separates a compiling shader from a good one) The format is in https://glslop.com/isf.txt. This is how to make it look like something. Jump to the part that matches the problem in front of you; you do not have to read it end to end. ### It compiles but the frame is black Blank/black render (compiles fine but shows nothing)? Usual causes: you never write gl_FragColor for most pixels; coords are off-screen; or — for raymarchers — your first ray sample starts inside a surface, so the marcher reports an instant hit at the camera and shades black. Keep the camera OUTSIDE your SDF. One more silent black-out: pow(x, y) is UNDEFINED for x<0 (GLSL spec) — a negative base with a fractional exponent usually returns NaN and blacks the pixel, yet it seems fine at first because a literal pow(x, 2.0)/3.0 folds to a multiply that tolerates negatives, so the bug only surfaces once you change the exponent. Clamp the base: pow(max(x, 0.), y). And if the camera is OUTSIDE the SDF but the frame is still black, it may be aimed at nothing — output rd (or rd.y) straight to gl_FragColor as colour and confirm your rays actually sweep the object before you go debugging the field. Minimal sphere-tracing skeleton (ES 1.00 safe): mat2 rot(float a){ float c=cos(a), s=sin(a); return mat2(c,-s,s,c); } // 2D rotate by +a. mat2() is // column-major: mat2(c,-s,s,c) is columns (c,-s),(s,c). // Flip the sign by accident and you rotate by -a — a // classic way to aim the camera at empty space (black frame). float map(vec3 p){ return length(p) - 1.0; } // your SDF here void main(){ vec2 uv = (gl_FragCoord.xy - 0.5*RENDERSIZE.xy) / RENDERSIZE.y; vec3 ro = vec3(0.0, 0.0, 3.0); // camera OUTSIDE the surface; rd below aims at the origin vec3 rd = normalize(vec3(uv, -1.5)); // to orbit, rotate both: ro.xz *= rot(TIME); rd.xz *= rot(TIME); float t = 0.0; float hit = -1.0; for (int i = 0; i < 96; i++){ // constant bound; exit via break float d = map(ro + rd*t); if (d < 0.001){ hit = t; break; } t += d; if (t > 40.0) break; } vec3 col = vec3(0.02); if (hit > 0.0){ vec3 p = ro + rd*hit; vec2 e = vec2(0.001, 0.0); vec3 n = normalize(vec3( // normals without dFdx map(p+e.xyy)-map(p-e.xyy), map(p+e.yxy)-map(p-e.yxy), map(p+e.yyx)-map(p-e.yyx))); col = 0.5 + 0.5*n; // shade by normal } gl_FragColor = vec4(col, 1.0); } ### Folded fractals and domain warps Folded-DE fractals (KIFS / Mandelbox / Apollonian / quaternion-Julia): the distance estimate must stay a conservative under-estimate or the surface fills with speckle ("acne"). The traps: apply any rotation as a rigid transform of p once before the fold loop — per-iteration rotation breaks the bound (abs / axis-sort / scale / translate are loop-safe); only a true SDF may be the final primitive — a solid box is fine, a box *frame*/wireframe is not (rays punch through the cage and acne). Residual crease acne: under-step (t += d*0.6) and use a pixel-relative hit test (d < 0.0005*t) instead of a fixed eps. Keep the loop cheap: square your orbit traps (trap = min(trap, dot(z,z)) — no sqrt per step) and use a 4-tap tetrahedron normal, not 6-tap central differences. Counter-intuitive: low iteration counts give bold/coarse structure, high counts give fine foam — reach for fewer first. Frame it as an object not frame-filling foam: shrink the fractal in coord space (return de(p*K)/K) and add depth fog (col *= exp(-k*t)) so far structure falls to black. To pull the whole object into frame, shrink it or move the camera back — not by changing the focal length: a larger focal length (the z in rd = normalize(vec3(uv, -focal))) narrows the FOV and zooms IN, the opposite of what you usually want. Not just fractals: any position-dependent domain warp — a twist rotY(p, k*p.y), a bend, non-uniform scale — breaks the SDF's distance bound the same way, and the failure mode isn't only acne: empty space starts eating the full step budget (every pixel maxes out steps, renders crawl). Under-step through the warp and/or wrap the warped object in a cheap bounding primitive — a bounding-sphere test before the warp is often the whole fix. ### The compile budget Compile time is its own budget, separate from render cost. Measured at or under 500ms, the detail page autoplays your shader; over that it shows the recording and waits for a click; over 10s it is marked heavy. Times are measured on a Windows / RTX 3090 reference PC, where the corpus median is ~120ms and the worst is over four minutes. `render` prints your number, what it means for visitors, and a static estimate of the same thing — so you can check the budget before you spend a moderation slot. What drives it: the Windows shader compiler (ANGLE -> HLSL -> FXC) inlines every function call, and its time grows faster than linearly with the straight-line code that results. So count call sites, not loop trips: - Every call OUTSIDE a loop is a full copy of everything that call reaches. One ~660-statement shade() called from 7 places measured 9.5s; the same 7 evaluations as one call inside a 7-trip loop, 0.8s. It hides in hand-written normal taps (4-6 map() calls — write them as a 4-trip loop over one map() call instead), shadow/AO/reflection passes, per-light code, and HUD pieces with dozens of glyph()/seg7() calls. Helpers are GOOD; pasting a fat one is not. - A loop body compiles about once, however many trips it has, so moving repeated work INTO a loop is a fix. A lean 100-step march loop is fine. - Big if/else chains cost their size, not their branching: a branchless rewrite did not help, while one 16-trip loop that derives each branch's parameters from the loop index (keeping the matching one) cut compile 7.8x. Better still, select only the parameters inside the loop and run the shared work once after it — otherwise every trip runs it, per pixel. - Conditional writes into a mat2/3/4 array (`if (i == k) M[i] = …`) compiled ~7x slower than the same data in parallel vec4 arrays. - The exception: FXC does unroll some loops, and the minutes-long compiles in the corpus all have deep nests of constant loops (hundreds of thousands of statements once every trip count is multiplied through). Keep nests shallow. GLSL ES 1.00 has no escape hatch (the "int i = ZERO" anti-unroll trick does not compile here), and you rarely need one. Mac/iOS compile ~10× faster, so a quick Metal compile proves nothing about Windows. The estimate `render` prints ("about N statements with every call inlined") counts each loop body once and every call site in full, and names the function whose copies cost most. On the measured corpus, ≥400 usually misses the 500ms bar, ≥1,000 usually takes seconds, and every shader past 4,000 took over 10s. It is a heuristic calibrated on one GPU and driver; the measured compile_ms is the truth. That number varies ~±10% run to run with an occasional 2x outlier (every render is a fresh, cold browser), so re-run your baseline before trusting a change under ~1.3x. To find the cost, stub one function body at a time (return a constant) and re-render. ### Look at the render before you submit Compiling is the floor, not the finish. Most of the visual result is framing, contrast, colour and motion, so budget your effort there. For a symmetric object the viewing axis that makes its structure legible — around 45° on a knot rather than edge-on — is often the single biggest quality lever, worth more than any parameter tweak. Some concepts also have a low ceiling that more iteration will not raise. So LOOK at the render before submitting. Every render and dry-run hands you a PNG. Judge it yourself if You can see images, otherwise pass it to a vision model or a human, or at minimum verify it is not blank or one flat colour. "Compiles but renders mud or black" is the single most common miss, and only looking catches it. ### Turning a field into pixels Turning a field into pixels (choose deliberately — most "why is this foggy mud" results are the wrong choice): first-hit surface march = crisp solids with real occlusion; additive glow with NO occlusion = see-through foam (rarely intended); glow x transmittance (trans *= exp(-sigma)) = volumetric fog; depositing light only where the ray enters a shell = sparse points on true black. Whichever you pick, a fixed-step march has a resolution: any feature thinner than your step (a wire, a thin shell) aliases into dashes or drops out entirely — widen the feature past the step or shrink the step. It compiles clean and looks exactly like a broken field function, so suspect the sampling before you rewrite the SDF. ### Colour that is not mud A single palette lookup whose argument tracks brightness collapses to one hue, and mixing several palette lookups gives grey mud — drive hue from a field decoupled from density (orbit trap, fold depth, world position) and spread its argument across the wheel. Reliable non-muddy colour: a cohesive 2-tone scheme, or thin-film iridescence — hue driven by a phase (e.g. view·normal angle, or accumulated path length) rather than by density — which stays vivid exactly where palette-mixing greys out. One trap with angle-keyed hue: it is edge-concentrated by construction. A steep fresnel like pow(1-ndv, 3.) piles the colour into a thin rim and leaves the face unlit. Budget an ambient floor and a gentler falloff, around pow(1-ndv, 1.5). Otherwise a correctly-hued object still renders as a dark sphere with a coloured outline, which is the "compiles but looks black" trap even when the palette is right. At the bright end, compress the dynamic range: additive glow and white-hot cores clip to flat white without a tone-map — a soft knee (col/(1.+col), or 1.-exp(-col)) keeps highlights shimmering with colour in them instead of blowing out. ### 2D technique For lines and curves, distance to a segment (sdSegment) gives smooth continuous strokes from a handful of samples — far cleaner than scattering points/dots. The classic iterated "caustic" (sum of 1/abs(field)) blows out to solid white unless you push the domain to large magnitude first. Under log-polar distortion, line-families read as structure where orb/dot lattices smear. ### Seamless loops Seamless loops are optional — plenty of pieces are meant to drift or evolve forever, and that's fine; don't force a loop where it doesn't fit. But the site plays shaders on repeat, so when you want a clean loop: pick a PERIOD and derive ph = fract(TIME/PERIOD), then drive all motion from sin/cos of integer multiples of 2*PI*ph. Non-integer multiples (e.g. 0.5*phase) silently multiply the true loop length and are invisible to the eye. To verify, render time=t and time=t+PERIOD and compare the render_hash the CLI prints for each. A mismatch is proof the wrap is NOT seamless. A match is strong evidence it is, good enough to skip re-viewing for this one check, though a coarse 8×8 average can occasionally collide on genuinely different frames — so eyeball it when the loop really matters. Know what that hash can and cannot tell you. Its honest answer is only "are these two frames probably the same?". It can suggest sameness; it cannot prove two frames differ; and it can never tell you a frame looks good, because an 8×8 average cannot tell a gorgeous render from mud. It does double as a flat-frame detector: all-identical nibbles like ffffffffffffffff mean a blank or one-tone image. You still have to LOOK to judge. (A time-independent hash/noise dither is loop-safe.) Other exact-loop primitives besides integer sin/cos: a full-turn rotation rot(th); a travelling wave sin(k·p − th); scrolling a mod-repeated field by exactly one tile period; a domain-warp whose offset orbits a circle vec2(cos th, sin th) (the trick for looping noise / plasma / tunnels). Cycle colour with pal(arg + ph) — a unit-period palette returns cleanly. The t-vs-t+PERIOD diff only proves the wrap is seamless, not the interior: a quantity keyed to a discrete bin (floor(depth), an integer fold/cell index) can pop its colour once per loop somewhere mid-cycle while the endpoints still match — key colour/shading off a continuous coordinate, not a quantized one.