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:

 

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

VariableTypeMeaning
pvec3The 3D point being evaluated
x, y, zfloatShortcuts for p.x, p.y, p.z
t / iTimefloatTime in seconds (enables animation automatically)
A, B, C, D, E, FfloatThe constant sliders of the Equations dock
S / sfloatReserved 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
Independent clocks. The surface geometry and the texture (and displacement) each run on their own animation clock. So an animated texture keeps moving even on a static surface, and a moving surface can carry a still texture — using 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:

 

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);
One constant, one role. Each slider 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.

 

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.

Note: the cutout belongs to the parametric mode. It is a section of the surface script (Script dock), not of a texture script, and it is saved with the preset like the rest of the code. A script with no cutout section pays nothing for the feature: the test is not even compiled into the shader.

 

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);
In a procedural texture the constants work, but are best left alone. The seven sliders are a single set shared by the whole scene: the same 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:

  1. with the scope on All, load the image (Textures > Images);
  2. switch to Mesh, pick a grid and apply one of the Animated Images scripts.

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.

Two rules of the Shadertoy branch, which these scripts follow and yours should too: the sliders 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.

DirectiveEffect
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
Rules: end each directive with a semicolon and use := (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 writeIt becomesNotes
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^Apow(abs(x), exp)abs() avoids NaN for negative bases
a % bmod(a, b)Works with floats
pi, tau3.14159..., 6.28318...Any letter case
e (lowercase, standalone)2.71828...Never name a variable e!
ln, log10log, log/ln(10)Natural and base-10 logarithm
cot, sec, csc1/tan, 1/cos, 1/sinTrigonometric synonyms
2 (integer number)2.0Every standalone integer becomes a float — see warning below
Integer warning: because every integer literal is converted to a float, code that genuinely needs GLSL integers breaks: 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:

FunctionDescription
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):

They are usually injected automatically by single-clicking a sound in the Library.

 

9. Tips