Create a Formula with AI
Use Modify with AI to author or remix a fractal formula with Claude, Gemini, or any LLM — then paste the result straight back into GMT.
What this is
GMT's formulas are plain text. You can write — or have an LLM write — a small .gmf file, then
drag it onto the app or paste it in through Modify with AI. It registers and renders on the
spot. No build, no install, no registration code.
The 60-second loop
- In GMT, open the Formula tab, click the ⋮ options button next to the
dropdown, and choose Modify with AI. You get the current formula exported as a minimal
.gmfplus a ready-made prompt. - Paste both into your LLM and add one line describing the change you want.
- Paste the model's reply back into the load box (or save it as a
.gmfand drag it onto the canvas). It registers and renders immediately.
GMF file structure
A .gmf is plain text. A leading comment block (the authoring kit) comes first, then a metadata
header, then labelled GLSL blocks. Only three blocks are required:
<Metadata>— JSON:id,name,parameters,defaultPreset, optionaljuliaType/tags/shaderMeta. (required)<Shader_Preamble>— global GLSL: helpers and mutable globals. You cannot callgmt_*helpers from here. (optional)<Shader_Init>— runs once before the loop. The place to callgmt_precalcRodrigues(...). (optional)<Shader_Function>— your formula function. (required)<Shader_Loop>— the single call placed inside the engine's loop. (required)<Shader_Dist>— optional, usually omit. The body ofvec2 getDist(float r, float dr, float iter, vec4 z): statements only (no function wrapper — GLSL has no nested functions), ending withreturn vec2(distance, smoothIter);. Prefer settingquality.estimatorinstead. (optional)
The formula function
The signature is fixed:
void formula_NAME(inout vec4 z, inout float dr, inout float trap, vec4 c) z— current point.z.xyz= position;z.w= 4th dimension (seeded fromuParamB).dr— running derivative for distance estimation. Starts at1.0.trap— orbit-trap accumulator for colour. Starts at1e10.c— the iteration constant. Mandelbrot:c= the start point. Julia:c = vec4(uJulia, uParamA).
The engine owns the loop and handles the escape/bailout check — do not loop or test escape yourself (unless you use the self-contained pattern below, which costs you features).
The four MUST-DOs
- Update
drevery call to match your math, or the surface is garbage. Power fractal:dr = power * pow(max(r,1e-10), power-1.0) * dr + 1.0;IFS / fold:dr *= abs(scale); - Feed
trapa POSITIVE distance:trap = min(trap, length(z.xyz));. The colourer clamps anything ≤ 0 to a floor, so log-distances or negatives produce one flat colour. - Handle Julia. For a power fractal:
if (uJuliaMode > 0.5) z.xyz += c.xyz;— but note the engine already encodes Julia intocvia amix, so for the common case you just addc.xyzevery iteration. PickjuliaType:"julia"(power),"offset"(fold/IFS), or"none"(hide the toggle). - Only break the loop as a last resort. If
<Shader_Loop>ends withbreak;, you MUST set"shaderMeta": { "selfContainedSDE": true }, readint(uIterations)to cap your own loop, and encode trap/iteration yourself. See the warning below.
break;. A self-contained formula (one that runs its own
loop and breaks the engine's) disables interlace, hybrid box-fold, and engine burning-ship — the
formula renders, but loses those features. Author a normal per-iteration formula (one step per call, no
break;) by default. Reach for self-contained only when the math genuinely cannot be expressed as
independent per-iteration steps.
Uniforms you can read
- Sliders:
float uParamA…uParamF;vec2 uVec2A/B/C,vec3 uVec3A/B/C,vec4 uVec4A/B/C. - System:
float uIterations(a float — cap loops withint(uIterations)),float uJuliaMode(> 0.5 = Julia),vec3 uJulia,float uDistanceMetric(0 Euclidean, 1 Chebyshev, 2 Manhattan, 3 L4),float uEscapeThresh(colouring radius, default 4.0),float uDeBailout(raymarch bailout, default 100.0). - Avoid
uTimein the formula body — it breaks accumulation.
uParamB initialises z.w (the 4th dimension) and
uParamA becomes c.w in Julia mode. For ordinary scalars, prefer uParamC…uParamF.
Make it adjustable (turn constants into sliders)
A formula is only fun if its interesting numbers are knobs. If you're adapting source that hard-codes its values — most Distance Estimator Compendium formulas and Shadertoy snippets do — promote a tasteful few of those constants to sliders instead of baking them in:
- Expose what changes the shape or look: power, scale, fold limit, radius, rotation angle,
offset, julia seed, thickness, colour knobs. (Iteration count is already
uIterations.) - Map a scalar to
uParamA…uParamF; group an x/y or xyz set into oneuVec2*/uVec3*slider (an offset becomes a singleuVec3A, not three scalars). - Add a
parametersentry per slider —label,id,min/max/step, anddefault= the original value — and mirror the default intocoreMath.
Helper functions
void sphereFold(inout vec3 z, inout float dr, float minR, float fixedR)void boxFold(inout vec3 z, inout float dr, float foldLimit)float getLength(vec3 p)— distance underuDistanceMetricfloat snoise(vec3 v)— 3D simplex noise, −1…1vec4 textureLod0(sampler2D tex, vec2 uv)- Rodrigues rotation: call
gmt_precalcRodrigues(vec3 params)(params = azimuth, pitch, angle) from<Shader_Init>, thengmt_applyRodrigues(inout vec3 p)/gmt_applyTwist(inout vec3 p, float amount)inside the formula. - Constants:
PI,TAU,INV_PI,INV_TAU,phi(golden ratio).
Both folds take the running derivative dr as their second argument. A common model
mistake is calling boxFold(z, foldLimit) (or sphereFold(z, minR, fixedR)) and omitting
dr — that won't compile. Always pass it: boxFold(z.xyz, dr, 1.0);,
sphereFold(z.xyz, dr, 0.5, 1.0);.
GLSL ES 3.0 note: do not initialise a const with sqrt()/
normalize()/cos(); use a non-const global set in a precalc function instead.
Rotations
Rotate inline with a 2×2 matrix — this is the primary, always-available way to rotate around an axis or in a plane:
// rotate around Z (acts on x,y) by angle a:
float ca = cos(a), sa = sin(a);
p.xy = mat2(ca, sa, -sa, ca) * p.xy;
// around X -> rotate p.yz ; around Y -> rotate p.xz
p.yz = mat2(ca, sa, -sa, ca) * p.yz; // around X
p.xz = mat2(ca, sa, -sa, ca) * p.xz; // around Y
The axis-angle alternative is the Rodrigues trio: call gmt_precalcRodrigues(vec3(azimuth, pitch, angle))
once in <Shader_Init>, then gmt_applyRodrigues(p) (or
gmt_applyTwist(p, amount)) inside the formula. Reach for it when you want a single shared,
UI-driven axis-angle rotation; use the inline 2×2 for everything else.
gmt_rotate_x/gmt_rotate_y/gmt_rotate_z helpers
(nor rotX/rotateY/etc.) — they will not compile. The only rotation
helpers are the Rodrigues axis-angle trio above. For per-axis or per-plane rotations, rotate inline with the 2×2
matrix as shown.
Distance estimators
Pick one in defaultPreset.quality.estimator (or supply <Shader_Dist> for full control):
- 0 — Analytic / Log
0.5*r*ln(r)/dr— power fractals (Mandelbulb). - 1 — Linear
(r-1.0)/dr— IFS / box-fold (Menger, Sierpinski). - 2 — Pseudo
r/dr— sparse / artistic. - 3 — Dampened
0.5*r*ln(r)/(dr+8)— fixes slicing on thin structures. - 4 — Linear(2.0)
(r-2.0)/dr— classic Menger offset. - 5 — Cutting-plane — formula-gated; ignore unless you know you need it.
Tune fudgeFactor, maxSteps, and iterations in the same quality block.
Set fudgeFactor to about 0.5 for AI- or hand-authored formulas — a hand-written
distance estimate is rarely exact, and values below 1.0 take smaller raymarch steps so an over-estimating DE
doesn't overshoot the surface and leave flat "slices"/holes (the app labels this Slice Optimization).
Raise it toward 1.0 for speed once the surface looks correct.
Choosing a family (this is the part that matters)
Most fractals fall into one of four archetypes. The right estimator, juliaType, and trap differ per
family — get these from the family, not by guessing.
Power fractals — Mandelbulb, Quaternion, Bristorbrot
- Estimator 0 (analytic log).
dr = pow(max(r,1e-10), power-1.0) * power * dr + 1.0; - Julia
"julia". Addc.xyzevery iteration — do not branch onuJuliaMode(thec = mix(...)already encoded it). - Rotation: only if a variant folds/rotates — then call
gmt_precalcRodriguesfrom Init and set"usesSharedRotation": true. - Interlace: the gold standard — fully supported. Trap:
trap = min(trap, length(z.xyz));.
IFS / box-fold — Mandelbox, Menger, Sierpinski, polyhedra
- Estimator 1 (linear) — set
defaultPreset.features.quality.estimator = 1(power fractals use0). Sphere-fold:dr = dr*abs(scale) + 1.0; pure IFS:dr *= abs(scale). Guardscale == 1.0. - Julia
"offset"(or"none"for symmetric IFS like Menger).c.xyzis a constant translation. - Interlace: supported. Trap:
min(trap, abs(z.x))orlength(z.xyz).
Kleinian / Möbius — KleinianMobius, PseudoKleinian, Apollonian
- Custom
<Shader_Dist>— these don't escape; track an inversion-scaling accumulator in preamble globals and reset it in Init. - Julia
"offset";c.xyzis a per-iteration offset. - Fragile under interlace: list every mutable preamble global in
shaderMeta.preambleVars(namedu[INITIALS]_name) or a hybrid silently renders wrong. Iteration colouring must be synthesized.
Self-contained — MandelTerrain, JuliaMorph, V4 imports (last resort)
- You own the whole loop;
<Shader_Loop>ends withbreak;and<Shader_Dist>is a passthrough. SetshaderMeta.selfContainedSDE: true. - You branch on
uJuliaModeyourself here (the engine'smixis bypassed). - Interlace, hybrid, and engine burning-ship are blocked. Encode every colour channel yourself, and keep trap a positive distance.
At a glance
- Power → estimator 0 ·
"julia"· interlace ✓ (best) - IFS / fold → estimator 1 ·
"offset"/"none"· interlace ✓ - Kleinian → custom getDist ·
"offset"· interlace ✓ but fragile - Self-contained → passthrough getDist · manual Julia · interlace ✗ (avoid)
Common mistakes a model makes
- Wrong estimator for the family (power → 0, IFS → 1, Kleinian → custom).
- Forgetting
preambleVarson a formula with mutable globals → silent interlace corruption. - Negative or log-domain
trap→ one flat colour. - Branching on
uJuliaModein a per-iteration formula (the engine already encoded it inc). - Reaching for
break;/ self-contained when a per-iteration step would do (loses features). - Missing NaN guard: always
pow(max(r,1e-10), …)and guardscale == 1.0.
Copy-paste starter
Paste this whole block into GMT (drag a saved .gmf, or the Modify with AI load box) to
confirm the round-trip, then mutate it. It's a minimal power bulb.
<Metadata>
{
"id": "MyBulb",
"name": "My Bulb",
"juliaType": "julia",
"parameters": [
{ "label": "Power", "id": "paramA", "min": 2, "max": 14, "step": 0.1, "default": 8 },
null, null, null, null, null
],
"defaultPreset": {
"formula": "MyBulb",
"features": {
"coreMath": { "iterations": 10, "paramA": 8 },
"quality": { "estimator": 0, "fudgeFactor": 0.5, "maxSteps": 300 }
}
}
}
</Metadata>
<Shader_Function>
void formula_MyBulb(inout vec4 z, inout float dr, inout float trap, vec4 c) {
vec3 zp = z.xyz;
float r = length(zp);
float power = uParamA;
dr = pow(max(r, 1e-10), power - 1.0) * power * dr + 1.0;
float theta = acos(zp.z / max(r, 1e-10));
float phi = atan(zp.y, zp.x);
float rp = pow(r, power);
zp = rp * vec3(sin(theta * power) * cos(phi * power),
sin(theta * power) * sin(phi * power),
cos(theta * power));
zp += c.xyz;
z.xyz = zp;
trap = min(trap, length(zp));
}
</Shader_Function>
<Shader_Loop>
formula_MyBulb(z, dr, trap, c);
</Shader_Loop> Pasting the result back
Two ways to load what your LLM gives you:
- Drag a saved
.gmffile onto the GMT canvas, or - Paste the model's whole reply into the Modify with AI load box.
The loader is tolerant — it strips code fences and chat boilerplate as long as the leading
<!-- comment or <Metadata> tag stays first. If nothing loads, the most
common cause is leading prose before the first tag, or the model returned a snippet instead of the complete file —
ask it to "return the complete GMF, all blocks included."
.gmf that carries the full shader — share it or reload it anywhere.