Skip to content
11 · Tutorial · 9 min read

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.

You don't need to know GLSL. Describe the change in plain English ("make it spikier", "add a twist parameter", "turn this into a Julia variant") and let the model write the shader math. This guide is the page your prompt links to — it's the same contract the model reads.

The 60-second loop

  1. 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 .gmf plus a ready-made prompt.
  2. Paste both into your LLM and add one line describing the change you want.
  3. Paste the model's reply back into the load box (or save it as a .gmf and 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, optional juliaType/tags/shaderMeta. (required)
  • <Shader_Preamble> — global GLSL: helpers and mutable globals. You cannot call gmt_* helpers from here. (optional)
  • <Shader_Init> — runs once before the loop. The place to call gmt_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 of vec2 getDist(float r, float dr, float iter, vec4 z): statements only (no function wrapper — GLSL has no nested functions), ending with return vec2(distance, smoothIter);. Prefer setting quality.estimator instead. (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 from uParamB).
  • dr — running derivative for distance estimation. Starts at 1.0.
  • trap — orbit-trap accumulator for colour. Starts at 1e10.
  • 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

  1. Update dr every 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);
  2. Feed trap a 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.
  3. Handle Julia. For a power fractal: if (uJuliaMode > 0.5) z.xyz += c.xyz; — but note the engine already encodes Julia into c via a mix, so for the common case you just add c.xyz every iteration. Pick juliaType: "julia" (power), "offset" (fold/IFS), or "none" (hide the toggle).
  4. Only break the loop as a last resort. If <Shader_Loop> ends with break;, you MUST set "shaderMeta": { "selfContainedSDE": true }, read int(uIterations) to cap your own loop, and encode trap/iteration yourself. See the warning below.
Prefer per-iteration; avoid 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 uParamAuParamF; vec2 uVec2A/B/C, vec3 uVec3A/B/C, vec4 uVec4A/B/C.
  • System: float uIterations (a float — cap loops with int(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 uTime in the formula body — it breaks accumulation.
Double-wiring gotcha: uParamB initialises z.w (the 4th dimension) and uParamA becomes c.w in Julia mode. For ordinary scalars, prefer uParamCuParamF.

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 uParamAuParamF; group an x/y or xyz set into one uVec2*/uVec3* slider (an offset becomes a single uVec3A, not three scalars).
  • Add a parameters entry per slider — label, id, min/max/step, and default = the original value — and mirror the default into coreMath.
Be tasteful: 2–6 well-labelled knobs beat twenty. Leave purely structural constants baked in.

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 under uDistanceMetric
  • float snoise(vec3 v) — 3D simplex noise, −1…1
  • vec4 textureLod0(sampler2D tex, vec2 uv)
  • Rodrigues rotation: call gmt_precalcRodrigues(vec3 params) (params = azimuth, pitch, angle) from <Shader_Init>, then gmt_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.

There are no 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". Add c.xyz every iteration — do not branch on uJuliaMode (the c = mix(...) already encoded it).
  • Rotation: only if a variant folds/rotates — then call gmt_precalcRodrigues from 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 use 0). Sphere-fold: dr = dr*abs(scale) + 1.0; pure IFS: dr *= abs(scale). Guard scale == 1.0.
  • Julia "offset" (or "none" for symmetric IFS like Menger). c.xyz is a constant translation.
  • Interlace: supported. Trap: min(trap, abs(z.x)) or length(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.xyz is a per-iteration offset.
  • Fragile under interlace: list every mutable preamble global in shaderMeta.preambleVars (named u[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 with break; and <Shader_Dist> is a passthrough. Set shaderMeta.selfContainedSDE: true.
  • You branch on uJuliaMode yourself here (the engine's mix is 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 preambleVars on a formula with mutable globals → silent interlace corruption.
  • Negative or log-domain trap → one flat colour.
  • Branching on uJuliaMode in a per-iteration formula (the engine already encoded it in c).
  • Reaching for break; / self-contained when a per-iteration step would do (loses features).
  • Missing NaN guard: always pow(max(r,1e-10), …) and guard scale == 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 .gmf file 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."

If it errors with a shader problem: press F12 to open the browser console, copy the GLSL error text, and paste it back to your LLM so it can fix the math. A compile error means the GLSL is invalid, not that the kit is broken — the error message names the line and the problem.
Save what you make. Once a generated formula renders, File menu → Save Scene writes a .gmf that carries the full shader — share it or reload it anywhere.