Skip to content

UserBulb Guide

Bradley Brown edited this page Aug 13, 2026 · 1 revision

User Bulb 3D — Guide

Fracturing Fog's User Bulb engine is the 3D analogue of User Equation. Author a per-iteration step function in either full C# (Roslyn) or a restricted DSL (Sandbox); the engine handles raymarching, distance estimation, lighting, AO, fog, and surface normals.

This guide covers the dialog, the Vec3 / Quat APIs, distance-estimation tradeoffs, the chain editor, mesh export, the Sandbox DSL grammar, and 14 ready-to-paste example bodies.

Companion pages: User Index · CalcGen User Guide · Fractal Equation Design Guide (Technical)

Mandelbulb 3D distance-estimated render at p = 8 with the "New 3D" Phong palette — home view, 1600 × 1000.


A friendly tour

The 2-D fractals you know — Mandelbrot, Julia, Burning Ship — all live in the complex plane: every point has a real part and an imaginary part. The User Bulb moves that idea into three dimensions, so every point has an x, a y, and a z. Run a recipe over and over on every point; the points that stay close to home form a shape in space. Light it from a direction and you get a sculpture.

The most famous shape of this kind is Daniel White's Mandelbulb, which uses what mathematicians call triplex algebra. It looks like this:

$$ \vec{v}_{n+1} = \vec{v}_n^{,p} + \vec{c} \quad\text{where}\quad \vec{v}^{,p} = r^p \big(\sin(p\theta)\cos(p\varphi),; \sin(p\theta)\sin(p\varphi),; \cos(p\theta)\big) $$

Power p = 8 is the classic. Fracturing Fog ships that recipe as the default, plus a full editor that lets you swap it for anything you want to try. You can write it in real C#, or in a tiny DSL that protects you from making the app crash.

Mandelbulb p = 4 — soft jelly silhouette. Mandelbulb p = 8 — classic spiked sphere. Mandelbulb p = 12 — denser quill pattern.
p = 4 — soft, organic, jelly-like. p = 8 — the canonical look. p = 12 — finer detail, quill-like.

Worked example — "Spin a Mandelbulb on its axis for a video loop"

  1. Switch the toolbar Type dropdown to User Bulb (3D).
  2. Press R to reset to the default Mandelbulb.
  3. The default body is already correct — no editing needed.
  4. Open Floating Menu → Params → scroll to Animation. Enable the animated t parameter, period 10 seconds.
  5. In the Camera group, set Theta animation = t * 360° so the camera rotates around once per period.
  6. Floating Menu → Video → duration 10 seconds, framerate 30 fps, output .mp4.
  7. Click Render.

The result is a perfectly looping spin of the bulb — drop it on a Discord channel as an animated banner.

Worked example — "Try the Burning Ship in 3-D"

  1. Switch the toolbar Type to User Bulb (3D).
  2. Floating Menu → Params → editor opens.
  3. Paste this body:
// 3-D Burning Ship: absolute-value all three components before the bulb-power step.
var w = new Vec3(Math.Abs(v.X), Math.Abs(v.Y), Math.Abs(v.Z));
return BulbPow(w, 8.0) + c;
  1. Hit Compile & Load. The render switches in ~500 ms.

You will see a much rougher, almost rocky shape — sharp masts, jagged hulls — because the absolute-value step makes the map non-smooth.


Table of Contents

  1. Open the Editor
  2. Step Signature
  3. Vec3 API
  4. Quat API (4D mode)
  5. Algebra Mode
  6. DE Modes
  7. Backends (CPU vs GPU)
  8. Camera + Lighting
  9. Render Knobs
  10. Param Bank
  11. Animation (global t)
  12. Julia Mode
  13. Color Drivers
  14. Chain Editor
  15. Save / Load / Promote
  16. Mesh Export (OBJ / STL)
  17. Example Gallery
  18. Pitfalls + Troubleshooting
  19. Sandbox DSL Compiler

1. Open the Editor

Fractal Type → User Bulb (3D) → Floating Menu → Params button (or click the gear icon in the toolbar).

Modeless dialog. Auto-compiles ~500 ms after the last keystroke. Errors render in red below the editor. Camera, lighting, iter count, epsilon, bailout, Jacobian h, params, animation, color driver, lighting weights, and view knobs update without recompiling — only changes to source body / algebra / chain steps / param names trigger a fresh compile.


2. Step Signature

The compiled body is wrapped as:

// Vec3 mode
Vec3 Step(Vec3 z, Vec3 c, int n, double[] p)

// Quat mode
Quat Step(Quat z, Quat c, int n, double[] p)
Param Description
z Previous iterate. Starts at Zero on iter 0.
c Per-pixel constant. Vec3 in 3D mode; (px.X, px.Y, px.Z, SliceW) in Quat mode. Replaced with JuliaC in Julia mode.
n 0-based iteration index.
p Named param vector. Trailing slot reserved for global animation t. Each Params row exposes a bare local double <name>.

Body must return a Vec3 (or Quat in Quat mode):

// Single expression form
Vec3.Pow(z, 8) + c

// Multi-statement form
var v = Vec3.Pow(z, 8);
return v + c;

3. Vec3 API

FracturingFog.Models.Vec3 — readonly record struct, 3 components, double precision.

// Fields
double X, Y, Z

// Properties
double Length          // sqrt(X² + Y² + Z²)
double LengthSquared   // X² + Y² + Z²

// Operators
+ - (unary -)  scalar *  scalar /

// Constants
Vec3.Zero, Vec3.One

// Static geometric / arithmetic
Vec3.Dot(a, b)double
Vec3.Cross(a, b) → Vec3
Vec3.Sin(v), Vec3.Cos(v), Vec3.Sinh(v), Vec3.Cosh(v), Vec3.Exp(v), Vec3.Abs(v)

// Static fractal-authoring helpers
Vec3.Pow(v, n)               // triplex spherical power (Mandelbulb)
Vec3.Rot(v, axis, angle)     // Rodrigues rotation
Vec3.BoxFold(v, limit)       // Tglad fold (Mandelbox component)
Vec3.SphereFold(v, rMin, rMax)  // inversion fold
Vec3.AbsX(v), Vec3.AbsY(v), Vec3.AbsZ(v)  // asymmetric folds
Vec3.Mod(v, period)          // tile per axis
Vec3.SMin(a, b, k)           // C¹ smooth-min DE blend (scalars)
Vec3.ToSpherical(v)(r, theta, phi)
Vec3.FromSpherical(r, theta, phi) → Vec3

// Instance
v.Normalized()

Construct with new Vec3(x, y, z).


4. Quat API (4D mode)

FracturingFog.Models.Quat — readonly record struct, 4 components (W, X, Y, Z), double precision. W is the real part; X, Y, Z are the i, j, k imaginary axes. In Quat mode the compiled body is Quat Step(Quat z, Quat c, int n, double[] p) and every helper below is in scope under Roslyn (the assembly imports FracturingFog.Models).

Important

Everything in this API is quaternion algebra, not component-wise arithmetic. Quat.Sin(q) is the true quaternion sine (it treats q as a rotation-carrying number), not sin applied to each of the four fields. That is the whole point of Quat mode — the maps behave like a genuine 4-D analogue of the complex plane. If you want per-axis math, use the Vec3 helpers on q.ToVec3() instead.

// Fields
double W, X, Y, Z

// Properties
double Length          // sqrt(W² + X² + Y² + Z²)
double LengthSquared   // W² + X² + Y² + Z²

// Operators
+  -  (unary -)         // component-wise
q * s   s * q           // scalar scale
a * b                   // Hamilton product (quaternion multiply — NOT commutative)

// Constants
Quat.Zero               // (0, 0, 0, 0)
Quat.Identity, Quat.One // (1, 0, 0, 0)
Quat.Pi                 // (π, 0, 0, 0)
Quat.HalfPi             // (π/2, 0, 0, 0)

// Structure / conversion
q.Conjugate() → Quat            // (W, -X, -Y, -Z)
Quat.Dot(a, b)double
Quat.FromVec3(v, w = 0)Quat  // lifts a Vec3 into the imaginary axes
q.ToVec3() → Vec3               // drops W, keeps (X, Y, Z)
Quat.QuatAxis(q)Quat         // unit "imaginary axis" of q (see note below)

// Algebra
Quat.Pow(q, exp)Quat         // real exponent — see semantics below
Quat.Sqrt(q)Quat             // = Pow(q, 0.5)
Quat.Exp(q), Quat.Log(q)Quat // principal-axis exp / log
Quat.Inverse(q)Quat          // q⁻¹ = conj(q) / |q|²
Quat.Scale(q, s)Quat         // same as q * s, static form

// Trig (quaternion-valued)
Quat.Sin, Quat.Cos, Quat.Tan
Quat.Csc, Quat.Sec, Quat.Cot            // reciprocals: 1/Sin, 1/Cos, Cos/Sin

// Hyperbolic
Quat.Sinh, Quat.Cosh, Quat.Tanh
Quat.Csch, Quat.Sech, Quat.Coth

// Inverse trig / inverse hyperbolic
Quat.Asin, Quat.Acos, Quat.Atan
Quat.Asinh, Quat.Acosh, Quat.Atanh

Construct with new Quat(w, x, y, z).

4.1 Pow semantics

Quat.Pow(q, exp) picks its algorithm from the exponent:

  • Non-negative integer exponent → exact repeated Hamilton self-multiply. Pow(q, 0) = Identity, Pow(0, n) = 0 for n > 0. This is the fast, exact path — prefer integer powers when you can.
  • Fractional or negative exponent → the analytic form q^exp = exp(exp · log q), evaluated on q's principal axis. This is what makes Sqrt, Asin, Acos, Asinh, and Acosh work (they all route through Sqrt).

4.2 The escape contract — these ops never throw

The quaternion DE hot loop has no try/catch by design (it compiles once, smoke-tests the delegate with finite inputs, then trusts it). A throw whose trigger depended on a runtime value the smoke test never hit would crash the whole render. So every op here returns a non-finite quaternion for undefined inputs (Pow(0, -1), Log(0), a divide by a zero-norm quat, …) instead of throwing. The loop's !double.IsFinite guard turns that non-finite result into a cleanly escaped pixel. You therefore never need to guard denominators inside a Quat-mode body the way you do in Vec3 mode — a bad value just paints as "outside the set."

Note

Quat.QuatAxis(q) returns the unit imaginary axis that makes Asin/Acos/Atan well-defined — it is the direction q "points" in the 3-space of {i, j, k}. When q is (numerically) a pure real number there is no natural axis, so the helper falls back to the x-axis by convention. If an inverse-trig map shows a hard seam along the x-axis, that fallback is the cause; rotate your input or add a small imaginary bias to avoid the pure-real degeneracy.

In Quat mode the raymarched 3-space slice comes from the camera ray's (x, y, z) plus the user-chosen Slice W coordinate. Changing Slice W explores different 3-D slices of the same 4-D set.

4.3 Worked snippets

// Quaternion Julia with a transcendental twist — sine of the square, plus c.
return Quat.Sin(z * z) + c;

// Fractional-power bulb (analytic Pow path). Non-integer exponent → exp(exp·log q).
return Quat.Pow(z, 2.5) + c;

// Exponential map — bounded, so read it with Color driver = FinalMagnitude.
return Quat.Exp(z) * 0.5 + c;

5. Algebra Mode

Mode Type Slice W Speed
Vec3 (3D) Vec3 ignored Fast
Quat (4D) Quat active Slower

Algebra change triggers a recompile.


6. DE Modes

Four distance-estimation modes selectable from the DE mode combo:

Mode When valid Speed Accuracy
Auto Default; detects triplex-power patterns Fast when matched, Numerical fallback otherwise Best per-map
Analytic Hubbard-Douady analytic DE only valid for triplex power maps ~4× faster Exact for matched maps; wrong for everything else
Numerical Always (escape-time maps) Slow Works for any escaping map
Non-escaping Maps that never escape (pseudo-Kleinian / lattice / Amoser sine) Single-trajectory Stability-clamp + running min(1/dr)

Analytic formula:

DE(p) = 0.5 · ln(|z|) · |z| / dr
dr  = p · r^(p-1) · dr + 1

Numerical formula: 4 lockstep trajectories at (c, c+h·êx, c+h·êy, c+h·êz), dr = max(|z_px-z_base|, |z_py-z_base|, |z_pz-z_base|) / h. Works for ANY escaping map.

Jac h slider controls the finite-difference perturbation. 1e-4 default. Too small → cancellation noise; too large → soft-edged surface.

6.1 Non-escaping DE (maps that never leave the bailout)

Auto / Analytic / Numerical are all escape-time: they assume the orbit eventually crosses |z| > Bailout. Some of the richest 3-D maps never do — their orbit is trapped forever in a bounded slab. Examples: pseudo-Kleinian and lattice maps, and the Amoser complex-sine:

z = ( sin(z.x)·cosh(z.y),
      cos(z.x)·cos(z.z)·sinh(z.y),
      sin(z.z)·cosh(z.y) ) + c

On these, the escape-time modes read a nonsense DE and you get a blank or garbage surface no matter how you tune Iterations / Bailout / Max steps / Epsilon. Switch DE mode to Non-escaping. It replaces the escape test with a stability clamp and accumulates a distance bound from the running derivative dr:

de = ∞;  dr = 1
for n in 0 … Iterations:
    z  = Step(z, c, n, p)           # your step map
    dr = DrBody(z, c, n, dr, …)     # your dr recurrence (or auto tangent)
    de = min(de, 1 / dr)            # tightest bound so far
    if |z.axis| > StabilityLimit:   # CLAMP, not escape
        break
return DEMultiplier · de

Three settings appear (only in this mode):

Setting What it does Amoser start
DEMultiplier Pure gain on the final distance. Lower → smaller raymarch steps → crisper but slower; too high → over-stepped, holes. 0.5
Stability axis Which component (x/y/z) the clamp watches — pick the axis the map grows along. y (cosh/sinh axis)
Stability limit Clamp threshold on that axis. Larger → more interior detail, noisier; smaller → smoother, eats fine structure. 8

6.2 The DE body editor — write your own dr

In Non-escaping mode a DE body editor appears. You write one scalar DSL expression that returns the next dr. The engine owns everything else: it computes de = min(de, 1/dr) each iteration and multiplies by DEMultiplier. You write only the derivative growth.

In scope inside the DE body:

Name Meaning
z the pre-step position this iteration (Vec3)
c the constant / Julia c (Vec3)
n iteration index (scalar)
t animation time (scalar)
params every named param from the Param Bank
dr the previous iteration's dr (scalar)
de the previous iteration's de (scalar)

Rules:

  • The body is scalar — return a number, not a vec.
  • Classic shape is dr = stretch · dr + offset, where stretch is the local magnitude of the map's derivative and offset is usually 1.
  • If the body fails to parse it does not break your step: the engine falls back to a two-trajectory numerical tangent and shows the error, so the render still appears.
  • Leaving the body blank is valid — Non-escaping mode then uses the auto tangent (works for any non-escaping map, softer, ~2× the cost of a good analytic body).

Examples

# 1 — constant growth (teaching): de = 1/2^Iterations
dr*2

# 2 — Amoser complex-sine stretch (the shipped preset)
#    sqrt(0.5·(e^2z + e^-2z)) = sqrt(cosh(2·z.z)) is the cosh stretch magnitude
let ez = exp(z.z) in
let s  = max(StretchMax, StretchScale*sqrt(0.5*(ez*ez + 1/(ez*ez)))) in
drScale*s*dr + drOffset
#    params: StretchScale 0.81, StretchMax 1.04, drScale 1.0, drOffset 1.0

# 3 — generic non-escaping starter (length-based stretch)
max(1.0, length(z)) * dr + 1.0

# 4 — folded / periodic map: stretch = largest per-factor magnitude
let g = max(abs(cos(z.x)), abs(cosh(z.y))) in
Scale*g*dr + 1.0

One click: load the saved preset "Amoser complex-sine (non-escaping DE)" — it wires the step, the Example-2 dr body, the four params, and the Non-escaping settings together. Edit from there.

Camera note: the User Bulb camera hard-codes world-up +Y, so the reference Fragmentarium framing (Up = (0,0,1)) is not literally reproducible — the preset sets a sensible default distance instead.


7. Backends (CPU vs GPU)

Backend Coverage Speed
CPU Every map, both compilers, both algebra modes, chains, KIFS. Baseline
GPU Depends on the Compiler (see below). Falls back to CPU for anything it can't translate. 5–20× faster when matched

What the GPU can render now depends on which compiler is active (§19). There are three GPU routes; the engine picks the widest one that fits and silently falls back to CPU on any miss:

Route Compiler What it covers
Sandbox — Quat mode Sandbox Full quaternion bodies. Runs the analytic power-DE when an analytic pattern is detected and Julia is off; otherwise a 5-trajectory numerical-Jacobian DE. Julia mode is supported (holds c at the Julia constant).
Sandbox — Vec mode Sandbox Analytic-power bodies (triplex(z, K) + c and the square map). Vec Julia / vec numerical on GPU is out of scope for now — those stay on CPU.
Legacy Roslyn Roslyn The pre-baked triplex spherical power-N kernel only: Vec3.Pow(z, N) + c with literal integer N, vec-only, non-Julia.

Chains compile on the GPU too under the Sandbox compiler (each step body is emitted and inlined). Scalar-KIFS distance fields are CPU-only — a body whose DE needs the KIFS scale falls back regardless of compiler.

Tip

The big change from earlier builds: quaternion fractals now render on the GPU, but only through the Sandbox compiler. If you wrote a quat body in Roslyn and it feels CPU-slow, retype it in the Sandbox DSL (§19) — the q* functions map straight onto device-safe Quat.* kernels.

To check whether GPU translation succeeded: render at a known-fast resolution; if the frame time matches CPU at the same resolution, you fell back. The error label also surfaces the last GPU compile/JIT error when a fallback happens.


8. Camera + Lighting

Camera (sphere around origin)

Knob Range Default
Distance 0.5 – 100 3.0
Theta (azimuth) 0 – 360° 45°
Phi (elevation) 1 – 179° 63°
Reset cam restores canonical view

Lighting (key direction)

Knob Range Default
Light theta 0 – 360° 45°
Light phi 1 – 179° 60°

L1 / L2 / L3 intensity sliders weight three directional contributions (key / fill / rim).

Mouse

Input Action
Mouse wheel Zoom (smaller/larger Distance)
Left-click drag Pan in screen space
Right-click drag X Orbit Theta
Right-click drag Y Orbit Phi (inverted)
Middle-drag Marquee zoom — hold the middle button, drag a box, release to recentre + zoom into it. Same outline-to-zoom as the 2D right-drag, on a different button so right-drag stays camera orbit. Esc cancels before release.

9. Render Knobs

Knob Sane range Notes
Iterations 4 – 16 DE inner-loop count
Max steps 48 – 256 Raymarch step cap
Bailout 2 – 16 `
Epsilon 0.0005 – 0.005 Surface hit threshold
Jac h 1e-5 – 1e-3 Numerical-DE perturbation
Cull r 1.5 – 8 Bounding-sphere radius

Cull radius gates ray entry — rays missing the bounding sphere render the sky color directly (zero march cost). Default 2.0 fits canonical Mandelbulb; Mandelbox needs 4–8.


10. Param Bank

Add arbitrary scalar params from the Params panel. Each row: Name, Value, Min, Max, X (remove).

In source, reference by name — the wrapper exposes each as a local double <name>:

// Params: k = 2.0, twist = 0.3, freq = 4.0
return Vec3.Pow(z, k) + c + Vec3.Sin(z * freq) * twist;

Changing a value re-renders (no recompile). Changing a NAME / adding / removing triggers a recompile.


11. Animation (global t)

The Animation bar plays a continuously increasing clock t (seconds × Speed).

Reference as a bare local in your source:

return Vec3.Pow(z, 4 + 2*Math.Sin(t)) + c;

▶ starts (~30 Hz updates). ▮ pauses. Speed multiplies the per-tick delta. Setting t manually fires a render.


12. Julia Mode

Tick Enable (fix c) in the Julia group to replace the per-pixel c with a single user-supplied constant for EVERY iteration.

Field Vec3 mode Quat mode
c.X active active
c.Y active active
c.Z active active
c.W disabled active

The pixel coordinate still drives the raymarch position; only the iteration's c is overridden. Produces a 3D (or 4D-sliced) Julia set.


13. Color Drivers

Selects what the IColorMap receives as input per pixel.

Driver Input
StepDepth Number of march steps before hit. Highlights silhouette depth. Default.
OrbitTrap Min distance from orbit to user-set trap point (tx, ty, tz). Reveals tendrils.
EscapeAngle atan2 of escape vector projected onto user-chosen axis. Highlights spirals.
FinalMagnitude log(
IterComponent Specific axis of final iterate (X/Y/Z). Anisotropic.
Normal Surface normal mapped to RGB. Pure shading debug; no palette involved.

14. Chain Editor

When the Chain panel has ≥ 1 step, the single-source editor is ignored. Each chain step:

Field Purpose
Output name Identifier added as a local Vec3 (or Quat) available to later steps.
Source A C# body returning Vec3 / Quat (same rules as single editor).

Steps run sequentially per iteration; the LAST step's output becomes the new z. Earlier outputs are visible to later steps.

Step 1   name = pre
         source = Vec3.Rot(z, new Vec3(0,1,0), t)

Step 2   name = sq
         source = Vec3.Pow(pre, 8) + c

Delete every chain row to revert to the single-editor flow.

14.1 Hybrid primitives (Mandelbulber-style)

Two toolbar buttons make composing 3D hybrids one click:

Button Action
+ Primitive ▾ Appends one named fold/power step to the chain. Options: Mandelbox fold (box+sphere+scale), KIFS Menger fold, KIFS Sierpinski tetra fold, Mandelbulb power.
Hybrid ▾ Replaces the chain with a worked-example two-step hybrid: Mandelbox + Mandelbulb, or Menger + Mandelbulb.

Each primitive is plain Vec3 source you can edit after dropping it in — change the box-fold limit, the sphere-fold radii, the Mandelbulb power, or the KIFS scale to taste. Output names auto-uniquify on insertion so duplicate primitives compose cleanly.

The two built-in hybrid examples (Hybrid: Mandelbox + Mandelbulb and Hybrid: Menger + Mandelbulb) are also seeded in the saved-equation dropdown, so you can recall them by name later.


15. Save / Load / Promote

Saved bulbs persist to %APPDATA%\FracturingFog\userbulbs.json.

Button Action
Save… Stores the current editor text under the typed name. Overwrite confirmation if name exists (v0.6.2+).
Delete Removes the selected saved entry.
Import… Reads a single-entry .fbulb JSON file. Renames on name collision.
Export… Writes the selected entry to a .fbulb JSON.
Promote to fractal list When ticked, the saved bulb appears in the main Type combo as a first-class option.

10 default presets ship pre-seeded on first run. Delete / edit / re-save freely.


16. Mesh Export (OBJ / STL)

Click Export mesh (OBJ)… to sample the DE field on a uniform N³ grid inside a cube of side 2·Range centred at origin and extract the surface with marching cubes (interpolated triangles, not blocky voxels). Choose .obj (ASCII, smooth per-vertex normals) or .stl (binary, faceted) in the save dialog.

The export runs on a background thread — the app stays responsive. A status-bar chip reads "Exporting mesh…" with a Cancel button; cancelling aborts the run and leaves any existing file untouched (no half-written stub). The export + Auto buttons disable while a run is in flight, so you can't launch two at once.

16.1 Export knobs

The knobs sit on their own row beneath the export button:

Knob Range Default What it does
Grid 16 – 512 96 Marching-cubes resolution per axis. Cost ~N³ DE evaluations (sampling is parallelised across CPU cores). Finer grid = more detail and smaller cells, which also tightens the fraction-mode Iso band. 256–384 for a detailed mesh.
Range 0.25 – 64 2.0 Object-space half-extent of the sampled cube. Must enclose the fractal — too small clips it; too large wastes resolution and can leave the mesh open where the surface exits the cube face.
Auto Probes the DE along 64 rays from the centre, finds the fractal's extent, and sets Range to enclose it with a 20% margin. Use it first — removes the guess-and-clip loop and prevents boundary holes.
Iso 0.005 – 2 0.5 Iso-surface level. With abs off it is a fraction of the cell size (iso = step·Iso); the default 0.5 sits a half-cell outside the true surface, so at coarse grids thin filaments inflate into fat tubes and gaps fuse into a ball. Lower toward 0.1–0.25 to hug the surface and keep filament detail; raise it to bridge gaps if the mesh comes out shattered.
abs on/off off When on, Iso is an absolute object-space distance (grid-independent). Set the surface level once and change Grid freely without re-tuning Iso.
SS 1 – 4 1 Supersampling. Box-averages an s×s×s stencil of DE samples per grid corner, antialiasing sub-cell filaments into continuous arms instead of broken tubes/dots. Cost is ~s³×, so keep it 1 at Grid ≥ 256 and reserve 2 for Grid ≤ 192.
Crease° 5 – 180 180 Normal-smoothing threshold. 180 smooths everything (rounds off Mandelbox-style facets). Lower it (~30) to keep hard edges crisp — faces meeting at a sharper angle than this split into separate normals, so facets stay sharp while curved bulb arms still smooth.

DE quality (Iterations, Jac h, DE mode) comes from the Render knobs above — the export DE is the same kernel as the render. All export knobs (Grid, Range, Iso, abs, SS, Crease°) persist with a saved bulb, along with the Render Iterations / Jac h.

16.2 Getting a detailed mesh (not a blob)

The exported mesh is only as good as the distance estimate:

  • DE mode → Analytic (or Auto). The analytic running-derivative DE is exact and meshes crisply. The numerical Jacobian is an approximation — it renders acceptably but marching-cubes turns it into a soft blob. Analytic engages for recognised power maps (z*z + c, Vec3.Pow(z, N) + c, and the quaternion square in Quat mode).
  • Quaternion fractals → Axis Mode = Quat + Julia mode. The Quaternion Julia saved preset sets these automatically; a z*z + c in Quat mode meshes like the built-in Quaternion Julia fractal type.
  • KIFS folds (Menger / Sierpinski / Mandelbox / kaleidoscopic) → set KIFS Scale (3 for Menger, 2 for Sierpinski/Mandelbox). Inserting a fold primitive or loading a fold hybrid now sets this for you. Without it the numerical DE cannot cross the fold discontinuities and export yields zero triangles.
  • Raise Iterations for geometry — the render default (8) is low; try 14–16 for export.
  • Drop Iso — the single biggest lever against the "ball with tubes" look. Pair it with a higher Grid.

16.3 Recommended recipes

Goal Auto Grid Iso SS Crease° Also
Fast preview 160 0.15 1 180
Detailed organic bulb 320–384 0.12 1 180 Iter 14–16, Jac h 1e-5, DE Analytic
Filament close-up 160–192 0.12 2 180 SS antialiases thin arms
Faceted hybrid (Mandelbox + bulb) 256 0.15 1 30 keeps box facets crisp
3-D print (solid) 192–256 0.30+ 1 60 higher Iso fuses thin filaments into a printable solid

Reality check. User Bulb is a general interpreter — for an arbitrary equation it can only estimate the DE numerically, so its mesh is inherently softer than a hand-written analytic calculator. For a faithful mesh of a known fractal (Quaternion Julia/Mandelbrot, Mandelbulb, Mandelbox, KIFS), prefer that concrete Fractal Type, whose exact DE meshes at full detail.

16.4 Mesh export troubleshooting

Symptom Cause Fix
Ball with tubes / cones sticking out the ends Iso too high for the grid — the half-cell offset inflates filaments and fuses gaps Lower Iso to 0.1–0.2 and raise Grid; keep SS 1
Blobby / soft, no fine detail Numerical DE + low iterations smooth the field DE mode → Analytic, Iterations 14–16, Jac h 1e-5
Broken filaments / dotty arms Sub-cell aliasing — arms thinner than a cell get point-sampled away Raise SS to 2 (drop Grid to keep cost sane), or raise Grid
Box facets look rounded Crease smoothing averages across the hard edges Set Crease° ~30
0 triangles exported Range doesn't enclose the set, or a fold needs its scale Click Auto; for folds set KIFS Scale (3 Menger, 2 Sierpinski/Mandelbox)
Mesh has holes / not watertight The surface reaches the cube face and marching cubes leaves it open Click Auto (adds margin), or raise Range
Shattered / disconnected shell Iso so low the surface falls between grid cells Raise Iso slightly, or raise Grid; turn abs on for a grid-independent level
Export hangs / very slow Grid×SS is huge (millions of DE evals × s³) Lower Grid or SS, or hit Cancel; use SS 1 at Grid ≥ 256
File enormous High Grid (and Crease° < 180 splits extra vertices) Lower Grid; export .stl if you don't need smooth normals

17. Example Gallery

Each example: source body + suggested config block. Paste into the editor (or save).

1. Square Triplex (default — fast 3D Mandelbrot)

return new Vec3(
    z.X*z.X - z.Y*z.Y - z.Z*z.Z,
    2*z.X*z.Y,
    2*z.X*z.Z) + c;

Config: Vec3 / Auto DE / Iter 8 / Bailout 4 / Steps 96 / Eps 0.0015 / Cull 2.0.

2. Mandelbulb p=8 (GPU-friendly)

return Vec3.Pow(z, 8) + c;

Config: Vec3 / GPU / Auto DE / Iter 8 / Bailout 2 / Eps 0.0008 / Cull 1.5.

3. Power-12 Ridged Bulb

return Vec3.Pow(z, 12) + c;

Config: Iter 10 / Steps 160 / Eps 0.0006 / AO 4 / Fog 0.08.

4. Animated Breathing Bulb

return Vec3.Pow(z, 4 + 2*Math.Sin(t)) + c;

Animation ▶, Speed 0.5. Falls to Numerical (animated power = no analytic). SS = 1, Iter 6 / Steps 64 for live playback.

5. Quartic + Sin Perturbation

return Vec3.Pow(z, 4) + Vec3.Sin(z) * 0.5 + c;

Power escapes + bounded sin adds texture. Color driver OrbitTrap (0,0,0) highlights folds.

6. Abs-Bulb p=8 (Burning-Ship 3D)

return Vec3.Pow(Vec3.Abs(z), 8) + c;

Flat-top + sharp ridges.

7. Mandelbox

var v = Vec3.SphereFold(Vec3.BoxFold(z, 1.0), 0.5, 1.0);
return v * 2.0 + c;

Iter 12 / Bailout 16 / Eps 0.0006 / Jac h 1e-3 / Cull 6.0 / Distance 8 / AO 6 / Fog 0.15.

8. Quaternion Julia

return z * z + c;

Algebra Quat / Slice W 0.3 / Julia ON / c=(-0.2, 0.4, -0.4, 0.0).

Swap z * z for any quaternion-algebra call from §4 to explore relatives: Quat.Pow(z, 3) (cubic), Quat.Sin(z * z) (transcendental), Quat.Exp(z) * 0.5 (exponential map). For the same maps on the GPU, retype the body in the Sandbox DSL (§19.10) — that is the quat-capable GPU path.

9. Vec3 Julia (3D triplex with fixed c)

return new Vec3(
    z.X*z.X - z.Y*z.Y - z.Z*z.Z,
    2*z.X*z.Y,
    2*z.X*z.Z) + c;

Julia ON / c=(0.30, 0.50, -0.20).

10. Rotated-Triplex Helix

var sq = new Vec3(
    z.X*z.X - z.Y*z.Y - z.Z*z.Z,
    2*z.X*z.Y,
    2*z.X*z.Z);
return Vec3.Rot(sq, new Vec3(0, 1, 0), t * 0.3) + c;

Animation ▶ Speed 0.4.

11. Periodic Kaleido

var p = Vec3.Mod(z, 2.0);
return Vec3.Pow(p, 8) + c;

Distance 6 / FOV 80°. Infinite lattice of mini-bulbs.

12. Cross-Product Ribbons

var sq = new Vec3(
    z.X*z.X - z.Y*z.Y - z.Z*z.Z,
    2*z.X*z.Y,
    2*z.X*z.Z);
return sq + c + Vec3.Cross(z, c) * 0.5;

Color driver EscapeAngle / axis Y.

13. Smooth-Min Blend (Chain editor)

Step 1   name = a    source = Vec3.Pow(z, 8) + c
Step 2   name = b    source = Vec3.Pow(z, 4) + c

Last step's output is new z. Swap powers (8/3, 12/6) to morph.

14. Parametric Twist-Bulb (named params)

Params: p=8 / k=0.3 / freq=4.

var v = Vec3.Pow(z, p);
var twist = Vec3.Sin(z * freq) * k;
return v + c + twist;

Drag sliders to morph live (no recompile).


18. Pitfalls + Troubleshooting

No-escape maps look blank. Pure sin / cos / Vec3.Sinh that orbits forever never crosses bailout → DE meaningless → flat sphere or blank. Use Color driver = OrbitTrap or FinalMagnitude to visualise bounded maps.

z₀ = 0 fixed points. Any f with f(0,0) = 0 gives all-in-set when c=0 is centered. Enable Julia mode (fixes c) or rephrase the source so z₀ = 0 isn't a fixed point.

Exploding maps. z^z, double-exp grow to ∞ in 1–2 iterations. Drop Iterations to 2–4 / raise Bailout to 1e6 / rescale inputs.

NaN / Inf. Math.Log of negatives, Math.Atan2(0,0), division by zero. Guard with + 1e-6 in denominators and Math.Max(r, 1e-12) before Log.

DE mode mismatch. Analytic DE on non-triplex gives wrong surfaces. Use Auto (engine falls back to Numerical for unknown shapes) or pick Numerical explicitly.

Tight Jac h on discontinuous maps. Mandelbox-style folds have piecewise derivatives. Raise Jac h to 1e-3 for folds.

Cull r too small. Mandelbox extends beyond Cull r → bounding sphere clips silhouettes. Use 4–8 for Mandelbox; canonical bulbs are happy with 2.

GPU backend fallback. Anything beyond plain Vec3.Pow(z, INT) + c silently falls back to CPU. Check perf — if GPU was expected and you don't see a speedup, the body wasn't translatable.

Perf. Roslyn delegate ~40 ns per call. Heavy Math.Pow / Atan2 multiplies that. Prefer x*x*x over Math.Pow(x, 3); hoist invariants out of the body.

Black screen, no shape. Check the error label (green ✓ vs red error). Bump Bailout to 16. Drop Iterations to 4. Spin Camera Theta. Raise Cull r.

Speckled normals / noisy shading. Raise Jac h from 1e-4 → 1e-3. Drop Epsilon to 0.0005.

""Melted"" soft edges. Lower Epsilon to 0.0008. Raise Max steps to 192.

Banding / tile boundaries. DE mode may be wrong — force Numerical. Toggle re-compile (edit source and revert) to flush the temporal cache.

Unbearably slow render. Drop Iterations to 4, Max steps to 48 for exploration. Switch Backend → GPU for plain Vec3.Pow bodies. Set SS back to 1. Resize window smaller.


19. Sandbox DSL Compiler

The Compiler combobox (Render group) toggles between two source compilers:

Mode Source language Algebra Backend Speed Safety
Roslyn (default) full C# Vec3 + Quat CPU + GPU (vec triplex-power only) fastest per-iter trusts source
Sandbox small DSL Vec3 + Quat CPU + GPU (vec and quat) ~10–15× slower per-iter on CPU parse-only, no BCL

Important

Two things changed since earlier builds and you may still see them described the old way elsewhere: (1) the Sandbox DSL is no longer Vec3-only — it has a full quaternion surface (§19.5), and the algebra combobox no longer forces back to Vec3 when Sandbox is selected. (2) Sandbox now has a GPU backend, and it is in fact the only path that renders quaternion fractals on the GPU (§7).

Pick Roslyn for the fastest per-iteration CPU speed or when you want the full BCL (Math.Atan2, Math.Truncate, etc.). Pick Sandbox when the source comes from untrusted input, when you want the editor to detect closed-form DE patterns on the AST, when you want a tighter grammar that fails fast on typos, or when you want quaternion fractals on the GPU.

19.1 Source signature (Sandbox)

The Sandbox compiler does NOT wrap your source in a Step method. Write an expression that evaluates to Vec3:

// Mandelbulb N=8
triplex(z, 8) + c
// Square triplex (explicit)
vec(z.x*z.x - z.y*z.y - z.z*z.z, 2*z.x*z.y, 2*z.x*z.z) + c
// Let-binding for clarity
let p = triplex(z, 8) in
let q = z * 2 in
p + q + c

There is no return keyword and no semicolon. The whole source is one expression. Use let NAME = EXPR in EXPR for intermediate variables.

19.2 Identifiers

Name Type Description
z Vec3 Previous iterate. Vec3.Zero on iter 0.
c Vec3 Per-pixel constant. Replaced with Julia c in Julia mode.
n Real 0-based iteration index.
t Real Global animation time (always available).
<param-name> Real Each Params row adds an identifier.
pi, e Real Constants.

In Quat mode the same z and c identifiers carry all four components, and .w reads the real part.

Member access:

  • On a Vec3: .x, .y, .z.
  • On a Quat: .x, .y, .z, and .w (the real part).
  • On a scalar: .x/.y/.z broadcast (e.g. n.x == n); .w on a non-quat is 0.

Note

A DSL value is one of three kinds at runtime — Real, Vec (3-vector), or Quat. You never declare the kind; it flows from what you build. vec(...) produces a Vec, qvec(...) and every q* function produce a Quat, arithmetic promotes to the widest operand, and the final result is projected to Vec3 (Vec3 mode) or Quat (Quat mode) automatically.

19.3 Grammar

expr     := let_expr
let_expr := 'let' IDENT '=' expr 'in' expr | ternary
ternary  := or ('?' expr ':' expr)?
or       := and ('||' and)*
and      := not ('&&' not)*
not      := '!' not | cmp
cmp      := add (('<'|'>'|'<='|'>='|'=='|'!=') add)?
add      := mul (('+'|'-') mul)*
mul      := pow (('*'|'/') pow)*
pow      := unary ('^' pow)?           ; right-assoc
unary    := '-' unary | primary
primary  := NUMBER | IDENT (member|call)* | '(' expr ')' member*
member   := '.' ('x'|'y'|'z'|'w')      ; '.w' reads a Quat's real part
call     := '(' (expr (',' expr)*)? ')'

19.4 Operators

Op Vec / Vec Vec / Real Real / Real Quat operand
+, - componentwise broadcast scalar componentwise (result is Quat)
* Hadamard (componentwise) broadcast scalar Quat × Quat = Hamilton product; Quat × Real = broadcast scale
/ componentwise broadcast scalar Quat / Real = broadcast scale
^ triplex Mandelbulb power scalar Math.Pow scalar Math.Pow Quat ^ Real = Quat.Pow (§4.1)
unary - componentwise scalar componentwise
&&, ||, !, comparisons reduce to real (1.0 / 0.0) scalar operands reduced by magnitude

Two operator rules worth memorising:

  • ^ is triplex power when the LHS is a Vec — z ^ 8 is shorthand for triplex(z, 8). When the LHS is a Quat it is Quat.Pow, and when it is a Real it is Math.Pow.
  • * between two Quat values is the non-commutative Hamilton product (same as qmul(a, b)), not a component-wise multiply. a * b ≠ b * a in general — this is exactly the behaviour that makes z * z + c a real quaternion Julia set.

19.5 Functions

Single-arg (scalar or componentwise on Vec): sin, cos, tan, sinh, cosh, tanh, exp, log, sqrt, abs

Vec3 specific:

  • vec(x, y, z) — construct Vec3 from three reals.
  • length(v) — Vec → real.
  • dot(a, b), cross(a, b), normalize(v)
  • triplex(v, n) — Mandelbulb spherical power.
  • rot(v, axis, angle) — Rodrigues rotation.
  • boxfold(v, limit) — Mandelbox box-fold.
  • spherefold(v, rmin, rmax) — Mandelbox sphere-fold.
  • absx(v), absy(v), absz(v) — fold a single axis.
  • mod(v, period) — periodic space.

Scalar-only:

  • pow(a, b), floor(s), sign(s), min(a, b), max(a, b), clamp(x, lo, hi), smin(a, b, k).

Quaternion-algebra (produce and consume Quat values — see §4):

  • qvec(x, y, z, w) — construct a quaternion from four reals (note the order: imaginary x, y, z first, real w last).
  • qmul(a, b) — Hamilton product (same as a * b on two quats).
  • qconj(q) — conjugate (w, -x, -y, -z).
  • qinv(q) — inverse q⁻¹.
  • qpow(q, s)Quat.Pow: exact self-multiply for non-negative integer s, analytic exp(s·log q) otherwise.
  • qexp(q), qlog(q), qsqrt(q) — quaternion exp / log / square-root.
  • qsin qcos qtan, qsinh qcosh qtanh — quaternion trig + hyperbolic.
  • qasin qacos qatan, qasinh qacosh qatanh — inverse trig + inverse hyperbolic.
  • qcsc qsec qcot, qcsch qsech qcoth — the six reciprocals.

Important

The q* functions treat their argument as a quaternion (a rotation-carrying algebra element), which is different from applying the plain sin/cos/… element-wise to four numbers. Applying a plain transcendental such as sin, cos, exp, log, or sqrt to a Quat is rejected at eval time ("not defined componentwise on Quat") — project to a component first (sin(q.x)) or use the quaternion version (qsin(q)). The one exception is abs, which is a legitimate per-axis fold and is allowed on a Quat.

19.6 Chains in Sandbox

The Chain editor works identically to Roslyn mode. Each step is its own Sandbox expression. Prior step output names become Vec3 identifiers in later steps.

step0 (output: folded)
    vec(abs(z.x), abs(z.y), abs(z.z))

step1 (output: out)
    triplex(folded, 8) + c

The above is a "Burning Bulb" variant — abs-fold each axis before the triplex power.

19.7 Analytic DE detection

When the Sandbox compiles, the AST is walked to look for closed-form patterns the engine can render with a single trajectory instead of a four-trajectory numerical Jacobian. The detected pattern is shown in the DE detect badge (green when engaged, grey when numerical).

Currently recognised patterns:

Pattern Source shape DE algorithm Speedup
MandelbulbN triplex(z, K) + c Hubbard-Douady power-N ~3-4×
Square vec(z.x*z.x - z.y*z.y - z.z*z.z, 2*z.x*z.y, 2*z.x*z.z) + c Hubbard-Douady N=2 ~3-4×

Chains never engage analytic DE — they always run numerical.

19.8 Limitations

  • No BCLMath.Atan2, Math.Round, Quaternion.*, etc. are not in scope. Use the built-in functions table above (including the q* quaternion family for 4-D work).
  • No plain transcendentals on a Quatsin/cos/exp/log/sqrt are rejected on a quaternion value (they are only defined component-wise, which is geometrically meaningless for a quaternion). Use qsin/qcos/… instead, or project to a component first. abs is the lone exception.
  • CPU interpreter is slower per-iter — interpreter dispatch is roughly 10–15× slower than Roslyn-compiled. Mitigated when an analytic-DE pattern is detected, and side-stepped entirely on the GPU route (the DSL is emitted to a compiled device kernel).
  • GPU coverage is not total — the Sandbox GPU path renders quat bodies (analytic or numerical, Julia included) and vec analytic-power bodies, but not vec-Julia, vec-numerical, or scalar-KIFS DE — those fall back to CPU (see §7).

Note

Earlier editions of this guide listed "No Quat mode" and "No GPU backend" here. Both are obsolete: the Sandbox DSL gained the full quaternion surface and a GPU emitter. The Compiler combobox item may still read "Vec3 only, CPU" in some builds — that label is stale; the algebra and backend comboboxes are the source of truth.

19.9 When to use Sandbox vs Roslyn

Decide for Choose
Maximum framerate at any cost Roslyn
GPU acceleration Roslyn (Sandbox CPU-only)
Untrusted source / shared presets Sandbox
Want compile-time error spans Sandbox (parser positions are surfaced)
Pre-recognised Mandelbulb N=K either (Roslyn faster on iter, Sandbox detects pattern too)
4-D / quaternion fractals on CPU either — Roslyn (Quat.*) or Sandbox (q*)
4-D / quaternion fractals on GPU Sandbox (the only quat-capable GPU path)

19.10 Quaternion cookbook (Sandbox DSL)

Set Algebra → Quat (4D) and Compiler → Sandbox, then paste any of these into the editor. Tick Backend → GPU for the speed-up; all of them are GPU-translatable. Use Slice W to slide through the 4-D set and Julia → Enable (fix c) where noted.

// 1. Classic quaternion Julia — the "hello world" of 4-D fractals.
//    z * z is the Hamilton product; z^2 would mean the same thing here.
//    Julia ON, c = (-0.2, 0.4, -0.4, 0.0), Slice W ≈ 0.3.
z * z + c
// 2. Cubic quaternion Mandelbrot — integer power uses the exact fast path.
qpow(z, 3) + c
// 3. Transcendental quaternion — sine of the square. qsin is true quaternion
//    sine, NOT sin applied to four numbers. Read bounded maps like this with
//    Color driver = FinalMagnitude or OrbitTrap.
qsin(z * z) + c
// 4. Fractional power — analytic exp(s·log q) branch. Non-integer exponent.
qpow(z, 2.5) + c
// 5. Build a quaternion by hand and fold it. qvec order is (x, y, z, w).
let q = qvec(z.x, z.y, z.z, z.w * 0.5) in
qmul(q, q) + c
// 6. Exponential map, damped so it stays in frame.
qexp(z) * 0.5 + c

Tip

z * z and qmul(z, z) compile to the same kernel — pick whichever reads better. When you mix reals and quaternions (qpow(z, 3), qexp(z) * 0.5) the real operands broadcast automatically, so you rarely need qvec unless you are assembling a quaternion from separate scalar parts.


User Bulb 3D Guide · Fracturing Fog · © 2026

Clone this wiki locally