Visualizing Functions
This section is the core toolkit of the workshop: shaders for visualizing functions.
If there's one thing a fragment shader does naturally, it's evaluating a function at every point in a domain and turning the result into a picture.
Given
We build shaders for three settings:
2D real-valued functions
Complex functions
3D scalar fields
2.0.1Level Sets of a Scalar Function
The most basic thing you might want to visualize is a function
There are two things to show: the value of
The pseudocode:
for each pixel (x, y):
compute val = f(x, y)
map val to a color via some color ramp
if val is close to a multiple of some spacing:
darken the color (this is a level curve)
output the color
The only question is what "close to a multiple" means.
The naive approach: compute fract(val / spacing) and check whether it's near 0 or 1.
This works, but the lines have uneven width — they're thin where
The fix is to measure distance in the image, not in the range of
Level Sets
To get level curves with consistent screen-space width, we need
The idea: instead of thresholding fract(val / spacing) directly, we divide by
First, estimate the gradient numerically:
float eps = VIEW_RADIUS * 2.0 / iResolution.y; // one pixel in world coords
float gradX = (f(uv + vec2(eps, 0.0)) - f(uv - vec2(eps, 0.0))) / (2.0 * eps);
float gradY = (f(uv + vec2(0.0, eps)) - f(uv - vec2(0.0, eps))) / (2.0 * eps);
float gradMag = length(vec2(gradX, gradY));
Then the level curve detection becomes:
float valInCell = fract(val / SPACING);
float distToLine = min(valInCell, 1.0 - valInCell) * SPACING; // distance in value
float pixelDist = distToLine / (gradMag * eps); // distance in pixels
float line = smoothstep(0.0, LINE_PX, pixelDist);
Now LINE_PX is an actual pixel width — set it to 1.5 and you get clean, even curves everywhere regardless of how the function behaves.
Without the gradient correction, lines get thinner where
Try replacing f with your favorite function:
Logarithmic Contours
(Based on Ricky Reusser's blended contour shader)
The level set shader draws contours at evenly spaced values:
The fix is to draw contours that are evenly spaced multiplicatively — that is, contour
The shader takes this a step further with multi-octave blending. Instead of one set of contour lines, it draws several octaves simultaneously — like major and minor gridlines on logarithmic graph paper — and smoothly fades between them based on the local gradient. As the function gets steeper, finer subdivisions appear; in flat regions, only the coarsest contours survive. The result is scale-invariant and visually clean everywhere.
The core of the algorithm works in log-space.
Given the function magnitude
float screenSpaceLogGrad = hypot(gradient) / f;
This tells us how fast
float localOctave = log2(screenSpaceLogGrad * minSpacing) / log2(divisions);
float contourSpacing = pow(divisions, ceil(localOctave));
float plotVar = log2(f) / contourSpacing;
float widthScale = 0.5 * contourSpacing / screenSpaceLogGrad;
One nice trick: the screen-space gradient is computed using dFdx and dFdy, built-in GLSL functions that give you derivatives for free — the GPU computes them from neighboring pixel values.
No finite differences needed.
The function goes at the top as usual — return a vec2 for complex-valued functions, or vec2(realValue, 0.0) for real-valued ones.
All the contour machinery lives below the edit line.
Colored Contour Regions
(Based on Aaron Fennig's contour shader)
The previous two shaders draw dark lines on a continuous color ramp.
This one does something different: it fills the bands between level sets with distinct colors, giving you a topographic-map look.
The transition between neighboring bands is antialiased properly using the error function — instead of the ad hoc smoothstep, we convolve the sharp stripe edge with a Gaussian pixel kernel.
This is the mathematically correct way to antialias, and it looks noticeably cleaner, especially when the stripes are wide.
The original shader by Aaron Fennig uses automatic differentiation (tracking 1-jets of maps jet2 type.
For a "type in any function" tool, we use finite differences for the gradient instead.
The antialiasing works like this.
At each pixel, we compute mod(f, period) to find where we are in the stripe cycle, giving us the index of the current stripe and the distance to the nearest edge.
Then we divide by
float screen_dist = abs(disp) / (gradMag * pixelRadius);
float overflow = 0.5 * erfc_appx(screen_dist / pixelRadius);
color = mix(thisStripeColor, neighborColor, overflow);
The erfc gives a softer, more physically correct blend than smoothstep — the difference is visible when you compare them side by side.
The color palette is defined at the top alongside the function. The default uses four alternating colors, but you can use any number.
2.0.2Domain Coloring
A function
The solution is the pullback idea in its purest form: design a coloring of the plane — a pattern that encodes position as color — and then pull it back through
The standard choice: encode a complex number
The key function is complexToColor, which implements this pattern:
// The "target pattern" for domain coloring:
// phase via cubehelix rainbow, brightness from modulus
vec3 complexToColor(vec2 w) {
float arg = atan(w.y, w.x); // -pi to pi
float mag = length(w);
// Phase coloring via rainbow colormap
float phase = arg / (2.0 * PI) + 0.5;
vec3 col = rainbow(phase);
// Brightness: periodic in log|w| to show modulus contours
float rings = 0.5 + 0.5 * sin(2.0 * PI * log2(mag));
col *= 0.7 + 0.3 * rings;
return col;
}
The rainbow function is a port of d3's cubehelix rainbow colormap — it cycles through hues with perceptually uniform brightness, so the coloring doesn't have the artificial bright/dark bands you get from naive HSL.
Domain coloring is then just: evaluate
The Common Tab
This is our first shader that benefits from a shared library.
Shadertoy's Common tab holds code that's available in all other tabs.
We use it to define complex arithmetic: cmul, cdiv, cexp, clog, cpow, csin, and so on, along with the cubehelix, rainbow, and complexToColor functions.
This means that in the Image tab, you can write your function naturally:
vec2 f(vec2 z) {
return cdiv(cmul(z, z) - vec2(1, 0), z + vec2(0, 1));
}
instead of manually expanding every multiplication and division. The Common tab holds all the visualization machinery that doesn't change between shaders.
Version 1: Standard Domain Coloring
Hue from phase, brightness modulated by modulus.
The periodic brightness creates concentric rings around zeros and poles, making them easy to count.
To see the raw color pattern without any transformation, set
Version 2: Checkerboard Grid
Same idea, different pattern to pull back: no color at all. Instead, we pull back a black-and-white checkerboard on the integer grid. Dark squares are dark, light squares are light, and within each square a finer subdivision grid gives you local scale information. Major grid lines at integers are drawn nearly black.
This makes geometric distortion immediately visible: you can see area changes (squares stretching or compressing), angle changes (squares shearing), and conformality (squares staying square, just rotated and scaled). It's essentially a visual Jacobian.
Version 3: Adaptive Grid
(Based on Ricky Reusser's rectangular domain coloring)
The previous two versions have a fixed grid scale, which means near a pole or zero — where
The idea is the same multi-octave trick we saw in the logarithmic contour shader: compute how fast
The hardware derivatives (dFdx, dFdy) are computed with hypot rather than length to avoid floating-point overflow near poles — a detail that matters in practice.
Version 4: The Riemann Sphere
Everything so far has been in the plane
This shader renders a lit 3D sphere and domain-colors it via stereographic projection.
Each visible point on the sphere maps to a point complexToColor.
The south pole itself maps to
The default function is two steps of the Doyle–McMullen iteration for
The stereographic projection is straightforward:
given a point
2.0.33D Scalar Functions: Slice Planes
We now move from 2D to 3D: given a function
Each slice is a standard 2D level-set visualization — the same gradient-corrected contour lines from our earlier shaders, now embedded in 3D space.
You can see exactly where
Structure
The shader draws four planes inside a bounding box:
Three reference planes at
One active slice sweeps back and forth along a chosen axis (configurable via SLICE_AXIS).
This uses the full diverging colormap with dense, crisp contour lines and a highlighted
The bounding box faces show a subtle grid for spatial reference, with axis lines highlighted.
Contour lines in 3D
The contour line technique is essentially identical to our 2D gradient-corrected lines, but with one twist: the gradient of
where
The
Diverging colormap
The colormap maps negative values to blue and positive to red, with white at zero.
The COLOR_SCALE parameter controls how quickly the colors saturate — a small value shows structure across a wide range of REF_COLOR_SAT keeps the coloring very subtle so the active slice dominates visually.
2.0.43D Scalar Functions: Implicit Surfaces
Slice planes show one cross-section at a time.
To see the full surface
A scalar function
For a true signed distance function (where
Scene setup
All three shaders share the same scene structure.
The function lives inside a bounding box that sits on a ground plane at
The bounding box serves a dual purpose.
First, it makes the marcher efficient: we analytically intersect the ray with the box and only march between the entry and exit points — no wasted steps in empty space.
Second, the box faces become a canvas for drawing isolines of
The box intersection (from Inigo Quilez) returns both the near and far intersection distances, plus the face normals at each.
We need these normals to draw isolines correctly — the gradient of
The gradient itself uses forward differences rather than centered differences — one evaluation instead of two per axis:
Less accurate than centered differences, but for normals and isoline widths the savings are worth it — we evaluate
The ground plane contributes ambient occlusion: at each ground hit, we compute the distance to the nearest isosurface (using the projected gradient), and darken the ground when an isosurface is close.
This is extremely cheap — just one evaluation of
Lighting uses a slowly rotating directional light with wrap diffuse (reflect with a high exponent (128).
Back-facing surfaces — where the normal points away from the camera — are lightened (0.4 + 0.6 * color) to distinguish inside from outside.
Single Level Set
The basic tool: type a function
The raymarcher walks in uniform steps between the box entry and exit points.
At each step it evaluates
The default is the Schwarz P surface
Nested Level Sets
Now we want to see an entire family of level sets (1 - accumulated_alpha) * layer_alpha * layer_color.
By default, every level is opaque — the first one hit occludes everything behind it, just like the single-level shader.
But you can set HIGHLIGHT to a level index (0 through NUM_LEVELS - 1) to make that one level opaque while the rest become translucent at GHOST_OPACITY.
The march then continues through the translucent surfaces, revealing the highlighted level even when it's nested deep inside the others.
The levels are generated automatically from three parameters: CENTER_LEVEL, LEVEL_SPACING, and NUM_LEVELS.
Colors follow a diverging blue → white → red ramp, so the central level is white and the extremes are saturated.
On the box walls, all levels get isolines in their respective colors, giving a topographic map of the entire family.
This reveals topology that a single level set hides.
A function might have a sphere-like level set at one value but develop handles or disconnect at another — watching the family evolve as
The default shows the Schwarz P family at five levels centered on
Cross-Section
Nested level sets show the outside of each shell, but the interesting structure is often inside — how the Schwarz P surface partitions space, how nested shells merge or pinch off.
The idea: clip away half the scene with a sweeping plane, exposing the interior. On the cut face we draw colored contour lines for each level value, so the cross-section becomes a topographic map.
The marcher now tracks two things as it walks: the function value HIGHLIGHT and GHOST_OPACITY.
When the ray crosses into the clipped region, the cut face is rendered with contour lines and composited behind any translucent surfaces already accumulated.
The contour lines use the same gradient-projection technique as the box wall isolines: the gradient of
The level configuration (CENTER_LEVEL, LEVEL_SPACING, NUM_LEVELS) and highlight mode work identically to the nested shader.
The clip plane sweeps back and forth with sin(iTime), turning the visualization into something like a CT scan.
The CLIP_NORMAL direction controls which way the plane faces — try vec3(1,0,0) for a vertical slice, vec3(0,1,0) for horizontal, or normalize(vec3(1,1,0)) for a diagonal cut.
2.0.5Algebraic Surfaces
The implicit surface shaders above work for any function
A real algebraic surface is the variety
The shader below is loaded with classic examples from the algebraic geometry literature. Here are some highlights worth trying:
Clebsch diagonal cubic (the default) — the unique cubic surface on which all 27 lines are real. The lines are visible as straight creases on the surface. This was one of the first algebraic surfaces to be physically modeled, by Clebsch and Klein in the 1870s.
Cayley cubic — a cubic with 4 ordinary double points (nodes), the maximum for a cubic. The nodes sit at the vertices of a tetrahedron.
Kummer surface — a quartic with 16 nodes (the maximum for a quartic), with beautiful tetrahedral symmetry.
Barth sextic — a degree-6 surface with 65 nodes and icosahedral symmetry. The golden ratio
appears explicitly in the equation. Barth proved in 1996 that 65 is the maximum number of nodes on a sextic.𝜑 Togliatti quintic — a degree-5 surface with 31 nodes, also with icosahedral symmetry.
Whitney umbrella (
) — the simplest non-isolated singularity: a line of double points along the𝑥 2 = 𝑦 2 𝑧 -axis, with a pinch point at the origin where the surface crosses itself.𝑧 Steiner surface (
) — a self-intersecting surface with three double lines meeting at a triple point. Also called the Roman surface after Steiner's visit to Rome where he discovered it.𝑥 2 𝑦 2 + 𝑥 2 𝑧 2 + 𝑦 2 𝑧 2 = 𝑥 𝑦 𝑧 Fermat surfaces (
) — a family interpolating between the sphere (𝑥 𝑛 + 𝑦 𝑛 + 𝑧 𝑛 = 1 ) and a cube (as𝑛 = 2 ). The quartic and sextic members are already visibly box-like.𝑛 → ∞
2.0.63D Scalar Functions: Volumetric Rendering
Isosurfaces show where a function crosses a threshold — crisp, opaque boundaries. But a scalar field contains information everywhere, not just on surfaces. A probability density, a temperature distribution, an electromagnetic potential — these fill all of space, and the interesting structure is often in how the field varies, not just where it crosses a particular value.
Volume rendering makes the entire field visible at once by treating
Emission-absorption model
The physics is Beer–Lambert: a ray passing through a medium accumulates light (emission) while simultaneously being attenuated (absorption).
For a ray at position
where
We approximate this by marching the ray through the bounding box in uniform steps and compositing front-to-back.
At each step we evaluate
where
Transfer function
The transfer function is the creative heart of volume rendering: it decides what density values look like.
Ours maps positive GLOW_POWER) controls contrast — values above 1 suppress low-density haze and sharpen the bright cores.
The VOLUME_SIGN parameter lets you render only positive, only negative, or both — useful for seeing the separate contributions in a field like
Gradient lighting
A raw emission volume can look flat — just colored fog.
The trick that gives it sculptural depth is gradient-based directional shading: at each sample point, compute
Dark background and tone mapping
Unlike the isosurface shaders (which sit on a bright ground plane), volumes live against a dark background. Emissive rendering adds light, so anything nonzero gets brighter — a bright background would wash everything out.
Since emission can accumulate to very high values (especially in dense cores), we apply Reinhard tone mapping (
Cross-Section
The volume shows global structure beautifully, but sometimes you want to peel it open — watch the density reveal itself slice by slice.
The implementation is as simple as it gets: the march loop is identical to the basic volume shader, but each sample checks dot(p, CLIP_NORMAL) > clipOff and skips if true.
The box, grid, camera — everything else stays exactly the same.
The clip plane sweeps with sin(iTime), progressively revealing or hiding the density.