GLSL Script Writing Guide
Scripts are written in GLSL (the OpenGL Shading Language), but Surface Explorer pre-processes your code with an internal translator that adds math-friendly shortcuts and a few strict rules. This guide covers everything you need to write working scripts.
1. How a Script Is Compiled |
Your code becomes the body of a GLSL function running on the GPU. Before compilation:
:= are treated as UI directives: they are removed from the code and applied to the interface (sliders, limits). See section 5.^, %, pi, integer numbers...) into valid GLSL. See section 6.
2. Ray Marching Scripts (Implicit Surfaces) |
The script must return a float: the value of your implicit function at point p.
Negative inside the object, positive outside, zero on the surface. For best rendering quality, return a
signed distance (SDF) whenever possible.
Variables available |
| Variable | Type | Meaning |
|---|---|---|
p | vec3 | The 3D point being evaluated |
x, y, z | float | Shortcuts for p.x, p.y, p.z |
t / iTime | float | Time in seconds (enables animation automatically) |
A, B, C, D, E, F | float | The constant sliders of the Equations dock |
S / s | float | Reserved in Ray Marching: acts as Step Relax for the ray marcher. Do not use it as a free constant here. |
Minimal example |
steps := 400; A := 1.5; // Sphere of radius A, pulsing over time float d = length(p) - A * (1.0 + 0.1 * sin(t)); return d;
Combining shapes (smooth blend) |
Build composite objects from several SDFs. Use min for a hard union and the built-in
smax (see section 7) for smooth blends. Here two spheres merge and separate over time,
with a soft "metaball" neck:
A := 0.7; // sphere radius B := 0.6; // blend smoothness // Two spheres whose distance oscillates float gap = 0.8 + 0.6 * sin(t); float s1 = length(p - vec3(-gap, 0.0, 0.0)) - A; float s2 = length(p - vec3( gap, 0.0, 0.0)) - A; // Smooth union = -smax(-a, -b, k) return -smax(-s1, -s2, B);
3. Parametric Scripts |
The script must return a vec3 or vec4: the position of the surface point for the
parameters u, v (and w in 4D; the fourth component of a vec4 is the 4D coordinate).
The parameter ranges come from the U/V/W limits in the UI, or from directives.
u_min := 0; u_max := 2*pi;
v_min := 0; v_max := pi;
// Sphere of radius B
return vec3(B * sin(v) * cos(u),
B * sin(v) * sin(u),
B * cos(v));
Animating a parametric surface |
Just like Ray Marching and textures, a parametric script animates as soon as t
(or iTime, in seconds) appears anywhere in it — no extra switch is needed. Here a
torus breathes and ripples over time:
u_min := 0; u_max := 2*pi;
v_min := 0; v_max := 2*pi;
A := 1.0; // main radius
B := 0.4; // tube radius
// Radius pulses; the tube ripples along u as a travelling wave
float R = A + 0.1 * sin(t);
float r = B * (1.0 + 0.15 * sin(4.0*u + 2.0*t));
return vec3((R + r*cos(v)) * cos(u),
(R + r*cos(v)) * sin(u),
r * sin(v));
Animating in 4D |
Return a vec4: the fourth component is the 4D coordinate w, projected to 3D
by the app. Driving it with t produces a wave that sweeps through the fourth dimension —
combine it with a 4D rotation from the Equations dock to see the shape morph:
u_min := -pi; u_max := pi;
v_min := -pi; v_max := pi;
// A sheet whose 4th coordinate is a travelling wave
return vec4(u,
v,
0.3 * sin(u) * cos(v),
0.6 * sin(u + t)); // w-coordinate animated in time
t in one does not force the other to
animate.
3.1 Metric Tensor Scripts (Geodesic Flow) |
Instead of deriving the metric from a parametric surface, you can define the metric tensor
gij(U, V, W) directly: a script run from the Parametric tab that
returns a mat3 (the symmetric matrix of the metric) is treated as a metric script.
The geodesic flow then integrates the intrinsic geodesic equation
(Christoffel symbols computed from gij and its derivatives), with no embedding involved.
Available variables: the coordinates U, V, W (also as lowercase u, v, w),
t / iTime, and the constants A..F, S. UI directives (:=) work as usual.
u_min := 0; u_max := 2*pi;
v_min := -3; v_max := 3;
// Poincaré half-plane: g = diag(1/V^2, 1/V^2, 1)
float f = 1.0 / (V*V);
return mat3(f, 0, 0,
0, f, 0,
0, 0, 1);
How the rest of the UI behaves in this mode:
U :=, V :=, W :=, dU :=, dV :=,
dW :=, Conform := (expressions in the family parameter u,
copied verbatim into the Geodesic Flow fields), or typed in the Equations dock as usual.
All := directives (initial conditions, but also limits, constants and steps) are applied at
every manual Run of the script; when a saved preset is loaded, the state stored in the preset
— including any edits made from the UI after the Run — takes precedence, and the directives
only fill fields that are still empty.
Geodesic Flow is the only active tab: Constraints and Composition
are disabled while the metric script is running.gij) and are
held at the identity chart x=U, y=V, z=W, i.e. the flow is drawn in coordinate space.
(A chart already present in the fields that mentions U, V, W is kept, as a display-only map.)u parametrizes the family of geodesics, v is the integration parameter along them.λ(U,V,W) · gij.0, 0, 1.
Reading the picture (and avoiding artifacts) |
A fully self-contained example: the spatial part of the Kruskal metric in coordinates
(U,V,W) = (r, θ, φ), with a cone of geodesics falling inward from
r = 4 (each value of u shoots in a slightly different angular direction):
// g11 = (32 A^3 / U) e^(-U/(2A)), g22 = U^2, g33 = U^2 sin^2(V)
u_min := 0; u_max := 2*pi;
v_min := 0; v_max := 4;
A := 1; // black-hole mass (metric)
B := 0.1; // angular aperture of the geodesic cone
// Condizioni iniziali: cono di geodetiche in caduta da r = 4
U := 4; V := pi/2; W := 0;
dU := -1; dV := B*cos(u); dW := B*sin(u);
Conform := 1.0;
float g11 = (32.0 * A*A*A / U) * exp(-U / (2.0 * A));
float g22 = U * U;
float g33 = U * U * sin(V) * sin(V);
return mat3(g11, 0.0, 0.0,
0.0, g22, 0.0,
0.0, 0.0, g33);
A..F is a single global variable. Do not
reuse the metric's constant (here A) for the initial conditions: moving its slider would
change the metric and the beam at once — e.g. setting A = 0.1 to narrow the cone
also collapses g11 ∼ A³ and the flow blocks with an "impossible values"
error. Use a separate free constant (above, B for the aperture). The app warns you at Run time
if the same constant appears in both the metric and the initial conditions.V := pi/2; dV := 0;) correctly produces a flat picture: the metric is
symmetric about that plane, so the geodesics never leave it, and the curvature shows up only in how the
lines bend inside the plane. The cone of directions (dV and dW together)
sweeps a genuinely 3D sheet that twists as it falls, with no extra ingredients.
Keep the aperture B in roughly 0–0.15: larger values push the
meridians across the poles V = 0, π, where they focus into a caustic.dU := -1) and v_max := 4. In this metric
g11 decays exponentially, so outgoing rays accelerate without bound in
coordinates and get truncated early (clamped points, ragged rims); infalling ones integrate cleanly.
Keep the angular spread moderate (0.1–0.15): larger values send
trajectories across the poles V = 0, π, where the meridians focus into a caustic.v_min := 0. The flow also integrates backward when
v_min < 0. For a symmetric planar fan with u_max := 2*pi the backward branch
retraces the forward branch of u + pi and every line is drawn twice (coplanar z-fighting);
for this cone it would add the outgoing cone, which truncates early. Forward-only avoids both.U → 0)
or where the coordinate velocity blows up: they simply stop at the last valid point. If the rim looks
ragged, reduce v_max. Families of curves often read best in Wireframe mode.
3.2 Cutout: Discarding Parts of the Surface |
A script may end with an optional //CUTOUT_BEGIN … //CUTOUT_END section that decides,
point by point, which parts of the surface are drawn. The body returns a boolean: true
means discard this point.
//CUTOUT_BEGIN // Drop a quarter of the domain: a wedge is carved out of the surface return (u < pi/2.0 && v < pi/2.0); //CUTOUT_END
The section receives the same u, v used to build the geometry, plus the constants
A..F, s and mesh. It runs per pixel on the GPU, at
display time: the mesh is not rebuilt and no vertex is removed, the discarded fragments are simply never
drawn. Two consequences follow, and they are the whole point of the feature:
What it is really for |
The original purpose is self-intersecting surfaces. Where a tube passes through itself the inner walls are buried inside the solid: they cannot be seen, but they are still drawn, and they produce the stray highlights and hard seams that show up at the crossings. A cutout removes exactly those hidden strips, keeping the visible skin intact. The condition is usually a distance test written in the surface's own parameters, so it follows the shape as the constants move.
3.3 Multi-Mesh Surfaces |
A parametric script can build the surface from several independent grids instead of one.
Each grid is declared with a repeatable //MESH_BEGIN … //MESH_END block giving its own domain
and resolution:
//MESH_BEGIN u: 0, 2*pi, 200 v: 0, 2*pi, 200 //MESH_END //MESH_BEGIN u: 0, 2*pi, 200 v: 0, 2*pi, 100 //MESH_END
The syntax is u: min, max[, steps] (same for v), with pi/tau
and arithmetic allowed. The step count is a proportion, not a ceiling: the Steps slider still
governs the real resolution, and the declared numbers only set the ratios between grids and between u and v.
Inside the script the variable mesh holds the index of the grid being generated
(0, 1, 2 …), so one body can produce every branch:
//MESH_BEGIN
u: 0, 2*pi, 200
v: 0, 2*pi, 200
//MESH_END
//MESH_BEGIN
u: 0, 2*pi, 200
v: 0, 2*pi, 200
//MESH_END
// Two nested tori: the tube radius depends on which grid we are on
float r = 0.15 + 0.25 * mesh;
return vec3((1.0 + r*cos(v)) * cos(u),
(1.0 + r*cos(v)) * sin(u),
r * sin(v));
Why separate grids |
Packing several branches into one grid forces the index generator to join the last row of a branch to the first row of the next, leaving a visible sheet or groove that had to be masked at cutout time. Separate grids make that impossible: no quad can bridge two of them, so the defect is gone at the root. It also removes the closure rescaling and the guard bands those scripts needed.
Turning grids on and off from a slider |
The //MESH_BEGIN blocks are static: they are read when the script runs, so a
constant cannot change how many grids exist. (Their domains, on the other hand, can be edited live from the
Renderer dock — see Per-mesh domain from the interface below.) To drive the count from a slider,
declare the maximum number of grids and discard the extra ones in the cutout section (3.2), which also
receives mesh:
E := int(1,6); // how many branches to show //CUTOUT_BEGIN float Ne = clamp(floor(clamp(E, 1.0, 6.0) + 0.5), 1.0, 6.0); return (mesh > Ne - 0.5); //CUTOUT_END
The geometry is not rebuilt when the slider moves — the discarded grids are simply not drawn — so the slider stays continuous. Their vertices are still generated, though: at high grid counts keep Steps low.
When you do this, add MESH_VISIBLE so the Mesh spin box knows how many grids are
actually on screen:
E := int(1,6); MESH_VISIBLE := E; // how many of the declared grids are visible
The cutout runs on the GPU, so the interface cannot work out by itself how many grids survive it. Without this line the spin box always reaches the declared maximum, and selecting a discarded grid would leave its colour and transparency sliders acting on something that is not drawn. The value is an expression in the constants, re-evaluated as the slider moves. Scripts that do not declare it are unaffected: the spin box keeps covering every declared grid.
Per-mesh appearance |
The Multi Mesh box in the Renderer dock chooses what the appearance controls act on. With All the surface behaves as a single one: colour, transparency, light and Solid/Wireframe apply to every grid at once. With Mesh the spin box picks one grid and the same controls — including its own wireframe line density — act only on it.
Per-mesh settings are suspended, not discarded, while All is active, so they come back untouched on switching to Mesh. A grid that was never configured inherits the global value, which is why existing presets are unaffected. Both the per-mesh appearance and the active scope are saved with the preset.
Per-mesh domain from the interface |
The same box carries four fields — u_a, u_b, v_a,
v_b — that set the domain of a grid without editing the script. In
Mesh they cut the selected grid alone; in All they drive a domain of their
own that applies to every grid and suspends the individual ones, which return untouched when you switch back.
They accept the same expressions as the U/V limits of the Equations dock and apply on Enter, rebuilding the
grid at once.
A domain typed there overrides the one declared here. It survives Master START, both Run
buttons, an Enter in the equation fields and a reload, and it is stored in the preset. The consequence is that
such a grid stops following its own //MESH_BEGIN section — editing the section no longer
moves it, until you type the declared values back into the fields. This mirrors how the Steps
slider already overrides a declared steps :=.
4. Texture Scripts |
Texture scripts compute a color. Return a vec4(r, g, b, a) using the texture coordinates
u, v; t / iTime are available for animated patterns. The result is
mapped onto the surface or the background depending on the active target in the Renderer dock.
// Animated checkerboard float c = mod(floor(u * 10.0) + floor(v * 10.0) + t, 2.0); return vec4(vec3(c), 1.0);
Using the constant sliders |
The sliders A..F and S are the natural way to parametrise a
Ray Marching texture: name one in the code and its slider unlocks by itself, ready to
reshape the surface live. This holds for both fields — Texture Code (the colour) and
3D Variation (the relief).
// Slider A scales the pattern and its speed float w = 0.5 + 0.5 * sin(6.0*A*u + 4.0*A*v + 2.0*A*t); return vec4(mix(u_col1, u_col2, w), 1.0);
A reaches the equations,
the surface texture and the background at once. A procedural texture can be applied to either the
surface or the background — many in the library are used both ways — so a constant it
claims may already mean something else elsewhere in the same scene, and one slider would then drive
two unrelated things. Reach for the 2D panel (zoom, pan, rotation) and the Color 1/2
pickers first, and edit the numbers in the script for anything finer. Ray Marching textures are freer — they belong to the
surface and cannot be a background — but they are not exempt either: the constants they
claim are the same ones the implicit equation uses, so the two can still meet on the same
letter.
Constants are altogether out of reach in the Shadertoy form: a script built around
mainImage() cannot see them, because that function sits outside the scope where the engine
declares them. See the box at the end of this section.
When two modules want the same letter |
The app watches for this. Applying a texture — to the surface or to the background — that claims a constant already used elsewhere in the scene raises a short notice naming the letters, and telling you which ones are still free. Nothing is rejected: sharing a slider can be exactly what you want, so the notice offers Apply anyway and that is the default. To give the texture a slider of its own instead, edit its script to use one of the free letters and load it again.
The presets that ship with the app never clash, so the notice only appears once you start combining a surface and a texture written apart from each other.
Telling the user what a slider does |
A slider that unlocks on its own says nothing about what it changes in that particular texture. When you save a texture the app offers an optional hint: a short message shown over the scene the next time the texture is loaded. One line per slider is the usual form, and the field takes more than one line — press Enter to break it:
Slider F: relief density Slider E: energy scale and speed
Surfaces and records carry a hint of their own; a texture's message is added to the scene's rather than replacing it, so loading a texture never overwrites what the surface had to say. Leave the field empty for no message. The hint is never recorded into exported videos.
Since the sliders belong to Ray Marching textures, that is where the hint earns its place. The procedural textures in the library carry none: what they expose is the 2D panel and the two colour pickers, and the values worth changing are commented in the script itself.
Using the Renderer color pickers |
Two ready-made colors are available as vec3 u_col1 and vec3 u_col2: they mirror
the Color 1 / Color 2 pickers in the Renderer dock. Reference either token in a
texture script and its picker lights up, so you can recolor the texture live without editing code. A texture
that ignores both tokens leaves the pickers disabled. Here an animated wave blends between the two colors:
// Travelling wave that mixes the two Renderer colors float w = 0.5 + 0.5 * sin(6.0*u + 4.0*v + 2.0*t); return vec4(mix(u_col1, u_col2, w), 1.0);
To use an image file instead of code, the directive //IMG: path/to/image.png is injected
automatically when you load an image from the Library.
Images on a single mesh: Animated Images |
An image is a single GPU resource — one sampler slot — not code compiled into the per-mesh dispatcher. So a grid cannot own an image of its own: loading one from the Library always applies it to the whole surface, whatever the Multi Mesh scope says.
The presets in Textures > Procedurals > Animated Images get around this from the other side.
They are ordinary per-mesh texture scripts — so they do land on the selected grid — and they simply
resample the image already loaded, reading it through iChannel0. The workflow is
two steps:
Repeat step 2 on every grid that should show the image: each one gets its own framing and its own motion, while the grids you leave alone stay in flat colour. Still Image is the static case (identity matrix, no animation); Drifting, Rotating, Breathing, Shearing and Squished are the same script with a different time-dependent 2×2 matrix applied to the texture coordinates — so they double as a starting point for writing your own.
A..F and t are not usable inside mainImage()
— unlike every other texture form, where they are — because the engine declares them as
locals of getCustomColor() and mainImage is a separate function, so you would
get 'A' : undeclared identifier; use iTime and plain literals instead. And
declare nothing at module level (no const, no globals, no helper functions). Two meshes running
the same script would put two copies of those declarations in one shader — redefinition,
and the surface disappears. Keep every parameter inside mainImage, where the scope is
per-function and each mesh gets its own copy.
5. UI Directives ( := ) |
A line of the form name := value; is not compiled: it is removed from the
code and applied to the interface. The value may be an expression like 2*pi.
| Directive | Effect |
|---|---|
u_min := 0; u_max := 2*pi; | Sets the U parameter range (same for v_min/v_max, w_min/w_max) |
steps := 400; | Sets the resolution / ray steps slider |
A := 2; ... F := 0.5; | Sets the corresponding constant slider (range expands automatically if needed) |
U := 4; dV := 0.1*cos(u); ... | Metric scripts only: U/V/W, dU/dV/dW, Conform
(case-sensitive) fill the Geodesic Flow initial conditions; the expression is copied as-is, so it may use the family parameter u |
:=
(not plain =, which would stay inside the GLSL code). Several directives may share one line,
but a directive line must contain only directives: the whole line is removed before compiling.
Directives inside comments are ignored, so prose like // A: number of holes is safe
and does not touch the sliders.
6. The Translator: Math Shorthand and Its Rules |
The translator rewrites your whole script before compiling. What you can write:
| You write | It becomes | Notes |
|---|---|---|
x^2, x^3 | (x*x), (x*x*x) | Fast inline expansion |
x^5 (odd integer) | sign(x)*pow(abs(x), 5.0) | Sign is preserved for negative bases |
x^4, x^2.5, x^A | pow(abs(x), exp) | abs() avoids NaN for negative bases |
a % b | mod(a, b) | Works with floats |
pi, tau | 3.14159..., 6.28318... | Any letter case |
e (lowercase, standalone) | 2.71828... | Never name a variable e! |
ln, log10 | log, log/ln(10) | Natural and base-10 logarithm |
cot, sec, csc | 1/tan, 1/cos, 1/sin | Trigonometric synonyms |
2 (integer number) | 2.0 | Every standalone integer becomes a float — see warning below |
for (int k = 0; k < 20; k++)
would become for (int k = 0.0; ...) and fail to compile. Wrap such values in
int(...) to protect them:
for (int k = int(0.0); k < int(20.0); k++) {
if (k == int(0.0)) { ... }
}
The same applies to array indices and any integer comparison.
7. Built-in Helper Functions (Ray Marching) |
These functions are pre-defined and available inside Ray Marching and texture code:
| Function | Description |
|---|---|
smax(a, b, k) | Smooth maximum (soft intersection of two SDFs). For a smooth union use -smax(-a, -b, k). |
safe_pow(x, y) | Sign-preserving power: sign(x)*pow(abs(x), y) |
sys_hash(n) | Pseudo-random value in [0,1] (accepts float, vec2 or vec3) |
sys_noise(x) | Smooth value noise (accepts float, vec2 or vec3) |
NoiseW(x, y, z, octaves, lacunarity, gain) | Fractal (FBM) noise for organic detail |
8. Sound and Music Directives |
Scripts can also drive the audio engine with comment directives (these are real directives, kept in comments on purpose):
//MUSIC: path/to/file.mp3 — streams a background music track.//SYNTH: formula — feeds a formula to the internal synthesizer.They are usually injected automatically by single-clicking a sound in the Library.
9. Tips |
t anywhere in the script to enable the animation system automatically.S (Step Relax) slider or increase steps.A-F are enabled automatically when the letters appear in your script: use them to explore shapes interactively without recompiling.