1 Day 1: Introduction
1.1 Overview
A shader is a function from coordinates to colors. You write code that takes a point \((x, y)\) and returns an RGB value; the GPU evaluates your function at every pixel, in parallel, and the result is an image. Here’s what we’ll make by the end of today:
A family of elliptic curves \(y^2 = x^3 + ax + b\). Drag the mouse to explore parameter space: \(x\) controls \(a\), \(y\) controls the center of the family. Gold curves have one connected component; blue curves split into two pieces. Red marks the singular curves along the discriminant locus \(4a^3 + 27b^2 = 0\)—the boundary between topological types. The whole thing runs in real time, recomputed sixty times per second.
The key idea: to draw a curve \(F(x,y) = 0\), color a pixel based on whether \(|F|\) is small. The entire machinery of implicit curves becomes accessible in a few lines of code.
There are subtleties, of course. The naive approach gives curves of uneven thickness (thin where \(|\nabla F|\) is large, bloated where it’s small), and we’ll need a little differential geometry to fix that. We’ll also need to think about coordinate systems, since the raw pixel grid isn’t a natural home for mathematics. But these are pleasant problems, the kind where the fix teaches you something.
By the end of today you’ll be able to render any implicit curve, animate it, and control it with the mouse. Tomorrow we’ll use these tools to draw fractals.
1.2 What is a Shader?
We want to draw images on a screen.
Mathematically, an image is a function from a region \(S \subset \mathbb{R}^2\) to the space of visible colors \(\mathcal{C}\). This color space is three-dimensional, spanned by the responses of the three types of cone cells in our eyes. A convenient basis, roughly aligned with these responses, is red, green, and blue.
To realize this on a computer, we discretize. A screen is a grid of pixels: \(X\) pixels wide, \(Y\) pixels tall. Each pixel is a point in the integer lattice \[\{0, 1, \ldots, X-1\} \times \{0, 1, \ldots, Y-1\}.\]
Colors are represented as RGB triples: red, green, and blue intensities, each in \([0,1]\). The constraint to \([0,1]\) reflects physical reality—a pixel has a maximum brightness it can display. (We can’t draw the sun.) So an image is a function \[f\colon \{0,\ldots,X-1\} \times \{0,\ldots,Y-1\} \to [0,1]^3\] \[(i,j) \mapsto (r,g,b).\]
In practice, we add a fourth component: alpha, representing transparency. This matters when compositing multiple layers (we won’t use it in this course, but the machinery expects it). So our shader computes \[f\colon (i,j) \mapsto (r,g,b,1).\]
This is what a shader is. You write a function that takes pixel coordinates and returns an RGBA color. The GPU evaluates your function at every pixel to produce the image.
Parallelism
A 1920×1080 display has over two million pixels. How do we evaluate \(f\) at all of them fast enough to animate at 60 frames per second?
The answer is parallelism. A GPU contains thousands of cores, and it evaluates \(f\) at all pixels simultaneously. There’s no loop over pixels in your code—you write \(f\), and the hardware handles the rest.
The tradeoff: each pixel’s computation must be independent. Pixel \((100, 200)\) cannot ask what color pixel \((100, 199)\) received. Every pixel sees the same global inputs—coordinates, time, mouse position—and must determine its color from those alone. Learning to think within this constraint is what shader programming is about.
The name comes from 3D graphics, where these programs computed shading—how light interacts with surfaces. It stuck even though we now use shaders for fractals, simulations, and mathematical visualization.
Why Shadertoy?
Shader programming normally requires substantial setup: OpenGL contexts, buffer management, compilation, render loops. Shadertoy abstracts all of this—you write one function, press play, and see results. We’ll use it throughout the course.
1.3 First Shaders: Colors and Syntax
The mainImage Function
In Shadertoy, your shader is a function called mainImage:
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
// your code here
}This function is called once per pixel, every frame. The inputs and outputs:
fragCoord— the pixel coordinates, passed in to your functionfragColor— the RGBA color, which you write out
The in and out keywords are explicit about data flow: fragCoord is read-only input, fragColor is where you write your result. The function returns void because the output goes through fragColor, not a return value.
Hello World: A Solid Color
The simplest shader: make every pixel red.
The vec4(1.0, 0.0, 0.0, 1.0) constructs a 4-component vector: red=1, green=0, blue=0, alpha=1. Every pixel receives the same color, so the screen fills with red.
GLSL Syntax Essentials
GLSL (OpenGL Shading Language) looks like C. Here’s a quick orientation—skim this now and refer back as needed.
Semicolons are required at the end of each statement.
Floats must include a decimal point. Write 1.0, not 1. The integer 1 and the float 1.0 are different types, and GLSL is strict about this.
Vector types are built in: vec2, vec3, vec4 for 2, 3, and 4 component vectors. Construct them with:
vec2 p = vec2(3.0, 4.0);
vec3 color = vec3(1.0, 0.5, 0.0);
vec4 rgba = vec4(1.0, 0.0, 0.0, 1.0);Arithmetic is component-wise. Adding two vectors adds their components:
vec2(1.0, 2.0) + vec2(3.0, 4.0) // = vec2(4.0, 6.0)Scalar-vector operations apply the scalar to each component:
2.0 * vec2(1.0, 3.0) // = vec2(2.0, 6.0)Accessing components uses .x, .y, .z, .w:
vec2 p = vec2(3.0, 4.0);
float a = p.x; // 3.0
float b = p.y; // 4.0For colors, .r, .g, .b, .a are synonyms—color.r is the same as color.x.
Common math functions are available: sin, cos, abs, min, max, sqrt, pow. These operate on floats, and apply component-wise to vectors:
sin(vec2(0.0, 3.14159)) // = vec2(0.0, ~0.0)For loops have C-style syntax:
for (int i = 0; i < 5; i++) {
// body executes with i = 0, 1, 2, 3, 4
}The loop variable is an int. Note that some older GPUs require the loop bounds to be constants known at compile time—you can’t always loop up to a variable. We’ll use loops extensively starting tomorrow.
Uniforms: Global Inputs
Shadertoy provides uniforms—global values that are constant across all pixels. Unlike fragCoord, which takes a different value at each pixel, a uniform has the same value everywhere. They’re how external information (time, screen size, mouse position) gets into your shader.
| Uniform | Type | Description |
|---|---|---|
iResolution |
vec3 |
Viewport size: (width, height, pixel_aspect_ratio) |
iTime |
float |
Seconds since the shader started |
iMouse |
vec4 |
Mouse position and click state |
We’ll use iResolution constantly (for coordinate transforms) and iTime for animation—but let’s hold off on animation until we have something interesting to animate.
Dividing the Screen
With just fragCoord and iResolution, we can already divide the screen into regions. Let’s color the left half red and the right half blue:
vec3 color;
if (fragCoord.x < iResolution.x / 2.0) {
color = vec3(1.0, 0.0, 0.0); // red on left
} else {
color = vec3(0.0, 0.0, 1.0); // blue on right
}
fragColor = vec4(color, 1.0);This works, but the code is awkward—we’re comparing against half the resolution in pixels. What if we want to divide along a diagonal? Or animate the dividing line? We’d be doing messy arithmetic with pixel counts everywhere.
What we really want is a proper coordinate system.
1.4 Coordinate Systems
Pixel Coordinates
The input fragCoord gives the pixel coordinates of the current pixel. The coordinate system:
- Origin at the bottom-left corner
fragCoord.xincreases to the rightfragCoord.yincreases upward- Ranges from \((0, 0)\) to \((X, Y)\) where \(X \times Y\) is the screen resolution
This is workable, but inconvenient for mathematics. We’d prefer coordinates centered at the origin with a reasonable scale. Let’s build up a transformation step by step.
Step 1: Normalize to \([0,1]^2\)
Divide by the resolution to map pixel coordinates to the unit square:
vec2 uv = fragCoord / iResolution.xy;Now uv ranges from \((0,0)\) at bottom-left to \((1,1)\) at top-right.
Since both coordinates are in \([0,1]\), we can visualize them directly as color:
void mainImage(out vec4 fragColor, in vec2 fragCoord)
{
vec2 uv = fragCoord / iResolution.xy;
fragColor = vec4(uv.x, uv.y, 0.0, 1.0);
}Black at bottom-left (0,0), red at bottom-right (1,0), green at top-left (0,1), yellow at top-right (1,1).
Step 2: Center the Origin
Subtract \((0.5, 0.5)\) to center the origin:
uv = uv - vec2(0.5, 0.5);Now uv ranges from \((-0.5, -0.5)\) to \((0.5, 0.5)\), with \((0,0)\) at the screen center.
Step 3: Aspect Ratio Correction
We’ve mapped a rectangle of pixels (\(X \times Y\)) to the square \([-0.5, 0.5]^2\). This is an affine transformation, not a similarity—it distorts shapes. A circle in our coordinates would render as an ellipse on screen.
To fix this, we scale the \(x\)-coordinate by the aspect ratio:
uv.x *= iResolution.x / iResolution.y;Now a circle in our coordinates appears as a circle on screen. (When we draw shapes later, try commenting out this line to see the distortion.)
Step 4: Scale to a Useful Range
Finally, scale to a convenient window:
vec2 p = uv * 4.0;With a scale factor of 4, our coordinates range roughly from \(-2\) to \(2\)—a good default for visualizing mathematical objects.
The Standard Boilerplate
Putting it together, here’s the coordinate setup we’ll use throughout the course:
vec2 uv = fragCoord / iResolution.xy; // normalize to [0,1]
uv = uv - vec2(0.5, 0.5); // center origin
uv.x *= iResolution.x / iResolution.y; // aspect correction
vec2 p = uv * 4.0; // scaleFrom here on, p is our mathematical coordinate, centered at the origin, aspect-corrected, with a reasonable range.
1.5 Drawing with Distance
We now have a coordinate system: each pixel knows its location p in the plane. The next question is what to do with it.
Remember how we divided the screen earlier with fragCoord.x < iResolution.x / 2.0? With our new coordinates, the same thing is just p.x < 0.0. More generally, a line \(ax + by + c = 0\) divides the plane into two half-planes: where \(ax + by + c < 0\) and where \(ax + by + c > 0\). But lines are just the beginning.
The real power of this coordinate system is drawing shapes. Throughout this course, the pattern will almost always be: compute a distance, then threshold it. A filled circle is “the set of points within distance \(r\) of the origin.” A curve is “the set of points at distance zero from the curve.” Compute a distance, decide a color.
Circles
Now consider the function \(d(p) = |p|\), the distance from the origin. Geometrically, the graph of this function is a cone—zero at the origin, increasing linearly in all directions.
To draw a filled disk of radius \(r\), we could threshold on \(d < r\) versus \(d \geq r\). But it’s cleaner to define \(f(p) = |p| - r\). This function is negative inside the circle (where \(d < r\)) and positive outside (where \(d > r\)). The circle itself is the level set \(f = 0\).
float d = length(p);
float r = 1.0;
float f = d - r;
vec3 color;
if (f < 0.0) {
color = vec3(1.0, 1.0, 0.0); // yellow inside
} else {
color = vec3(0.1, 0.1, 0.3); // dark blue outside
}
fragColor = vec4(color, 1.0);Try commenting out the aspect ratio correction (uv.x *= ...) to see the distortion—the circle becomes an ellipse.
To center the circle at a point \(c\) instead of the origin, compute distance from \(c\):
vec2 center = vec2(1.0, 0.5);
float d = length(p - center);Drawing a Ring
Our function \(f = d - r\) is negative inside the circle and positive outside. To draw a filled disk, we colored based on the sign of \(f\).
But what if we want just the boundary—a ring of some thickness? We want to color one way when \(f\) is small in absolute value (near the circle), and a different way when \(|f|\) is large (far from the circle).
So we look at \(|f| = |d - r|\) and ask: is this less than some threshold \(\varepsilon\), or greater? Equivalently, is \(|d - r| - \varepsilon\) negative or positive?
float d = length(p);
float r = 1.0;
float eps = 0.1;
float f = abs(d - r) - eps;
vec3 color;
if (f < 0.0) {
color = vec3(1.0, 1.0, 1.0); // white ring
} else {
color = vec3(0.1, 0.1, 0.3); // dark background
}
fragColor = vec4(color, 1.0);1.6 Animation and Interaction
So far our shaders are static images. Let’s make them move and respond to input.
Animation with iTime
The uniform iTime gives the number of seconds since the shader started. Since sin(iTime) oscillates between -1 and 1, the expression 1.0 + 0.5 * sin(iTime) oscillates between 0.5 and 1.5. Use this as the radius:
float d = length(p);
float r = 1.0 + 0.5 * sin(iTime); // radius pulses between 0.5 and 1.5
float f = d - r;
vec3 color;
if (f < 0.0) {
color = vec3(1.0, 1.0, 0.0); // yellow inside
} else {
color = vec3(0.1, 0.1, 0.3); // dark background
}
fragColor = vec4(color, 1.0);This is our first animated shader. The same principle applies anywhere: any parameter can depend on iTime.
The iMouse Uniform
iMouse is a vec4:
iMouse.xy— current mouse position (in pixels)iMouse.zw— position where the mouse was last clicked
For now we’ll focus on iMouse.xy.
Dragging a Circle
Let’s draw a circle centered at the mouse position. Since iMouse.xy is in pixel coordinates, we need to normalize it the same way we normalize fragCoord:
// Normalize fragment coordinate
vec2 uv = fragCoord / iResolution.xy;
uv = uv - vec2(0.5, 0.5);
uv.x *= iResolution.x / iResolution.y;
vec2 p = uv * 4.0;
// Normalize mouse coordinate the same way
vec2 mouse = iMouse.xy / iResolution.xy;
mouse = mouse - vec2(0.5, 0.5);
mouse.x *= iResolution.x / iResolution.y;
mouse = mouse * 4.0;
// Circle centered at mouse
float d = length(p - mouse);
float r = 0.5;
vec3 color;
if (d < r) {
color = vec3(1.0, 0.9, 0.2); // yellow
} else {
color = vec3(0.1, 0.1, 0.3);
}
fragColor = vec4(color, 1.0);Click and drag to move the circle.
Writing a Helper Function
We just wrote the same four lines of coordinate normalization twice. This is a sign we should write a function.
A GLSL function declares its return type, then the function name, then its parameters with their types:
vec2 normalize_coord(vec2 coord) {
vec2 uv = coord / iResolution.xy;
uv = uv - vec2(0.5, 0.5);
uv.x *= iResolution.x / iResolution.y;
return uv * 4.0;
}Functions must be defined before they’re used, so they go above mainImage. Here’s the overall structure:
vec2 normalize_coord(vec2 coord) {
// normalization logic here
}
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 p = normalize_coord(fragCoord);
vec2 mouse = normalize_coord(iMouse.xy);
// code using p and mouse
}Now our shader is cleaner, and we won’t make mistakes copying the normalization code.
Combining iMouse and iTime: Sun and Earth
Let’s make a circle orbit around the mouse position:
vec2 p = normalize_coord(fragCoord);
vec2 sun = normalize_coord(iMouse.xy);
// Earth orbits the sun
float orbit_radius = 0.8;
vec2 earth = sun + orbit_radius * vec2(cos(iTime), sin(iTime));
// Draw sun (larger, yellow)
float d_sun = length(p - sun);
// Draw earth (smaller, blue)
float d_earth = length(p - earth);
vec3 color = vec3(0.02, 0.02, 0.05); // dark background
if (d_sun < 0.3) {
color = vec3(1.0, 0.9, 0.2); // yellow sun
}
if (d_earth < 0.15) {
color = vec3(0.2, 0.5, 1.0); // blue earth
}
fragColor = vec4(color, 1.0);Drag to move the sun; the earth follows in orbit. (Exercise: add a moon orbiting the earth!)
1.7 Implicit Curves
We’ve drawn circles using the distance function \(|p| - r\). But circles are just one example of curves defined by an equation. Any equation \(F(x,y) = 0\) defines a curve—the set of points satisfying that equation. We can draw it the same way: threshold on \(|F|\).
A First Example: The Parabola
Consider \(F(x,y) = y - x^2\). The curve \(F = 0\) is the parabola \(y = x^2\). Points where \(F < 0\) lie below the parabola; points where \(F > 0\) lie above.
To draw the curve itself, we color pixels where \(|F|\) is small:
float F = p.y - p.x * p.x;
float eps = 0.1;
vec3 color;
if (abs(F) < eps) {
color = vec3(1.0, 1.0, 0.0); // yellow curve
} else {
color = vec3(0.1, 0.1, 0.3); // dark background
}
fragColor = vec4(color, 1.0);More Examples
An ellipse: \(F(x,y) = \frac{x^2}{a^2} + \frac{y^2}{b^2} - 1\)
float a = 2.0, b = 1.0;
float F = (p.x*p.x)/(a*a) + (p.y*p.y)/(b*b) - 1.0;A hyperbola: \(F(x,y) = \frac{x^2}{a^2} - \frac{y^2}{b^2} - 1\)
float a = 1.0, b = 1.0;
float F = (p.x*p.x)/(a*a) - (p.y*p.y)/(b*b) - 1.0;The lemniscate of Bernoulli: \((x^2 + y^2)^2 = a^2(x^2 - y^2)\), or \(F = (x^2+y^2)^2 - a^2(x^2 - y^2)\)
float a = 1.5;
float r2 = dot(p, p); // x² + y²
float F = r2 * r2 - a * a * (p.x * p.x - p.y * p.y);The Thickness Problem
Look carefully at the parabola. The rendered thickness isn’t uniform—it’s thinner where the curve is steep, thicker where it’s flat. The problem gets worse with more complicated curves, especially those with singularities. Here’s the lemniscate:
Notice how the thickness blows up near the origin, where the curve crosses itself.
Why does this happen? The set \(|F| < \varepsilon\) contains all points within \(\varepsilon\) of zero in the \(F\) direction. But \(F\) doesn’t measure distance to the curve—it’s just some function that happens to be zero on the curve. Where \(|\nabla F|\) is large, \(F\) changes rapidly, so the band \(|F| < \varepsilon\) is narrow. Where \(|\nabla F|\) is small, \(F\) changes slowly, so the band is wide. At the singular point, \(\nabla F = 0\), and the band becomes infinitely wide.
Why Circles Worked
For the circle, we used \(f(p) = |p| - r\). This is the signed distance function: it measures actual geometric distance to the curve. The gradient of a distance function has magnitude 1 everywhere (it points toward or away from the curve at unit rate). So \(|f| < \varepsilon\) really does capture points within distance \(\varepsilon\), giving uniform thickness.
This is a fact from differential geometry: \(|\nabla d| = 1\) for a distance function \(d\). When we use an arbitrary implicit equation \(F = 0\), we lose this property.
Gradient Correction
We can fix the non-uniform thickness by dividing by the gradient magnitude. Instead of thresholding \(|F| < \varepsilon\), we threshold \[\frac{|F|}{|\nabla F|} < \varepsilon.\]
This approximates the signed distance to the curve. The intuition: \(|F|/|\nabla F|\) estimates how far you’d need to travel (in the direction \(F\) changes fastest) to reach the curve.
For the lemniscate, we compute the gradient analytically: \[\nabla F = \bigl(4x(x^2+y^2) - 2a^2 x,\; 4y(x^2+y^2) + 2a^2 y\bigr)\]
float a = 1.5;
float r2 = dot(p, p);
float F = r2 * r2 - a * a * (p.x * p.x - p.y * p.y);
vec2 grad = vec2(
4.0 * p.x * r2 - 2.0 * a * a * p.x,
4.0 * p.y * r2 + 2.0 * a * a * p.y
);
float dist = abs(F) / max(length(grad), 0.01); // avoid division by zero
float eps = 0.05;
vec3 color;
if (dist < eps) {
color = vec3(1.0, 1.0, 0.0);
} else {
color = vec3(0.1, 0.1, 0.3);
}
fragColor = vec4(color, 1.0);Compare with the naive version above to see the difference in thickness uniformity.
Softer Edges
So far our shaders have been binary: inside or outside, yellow or blue. The distance to a curve is a continuous quantity, but we throw away most of that information when we threshold it.
GLSL provides two functions that let us use distance more gracefully.
The function mix(a, b, t) linearly interpolates between a and b: when t = 0 it returns a, when t = 1 it returns b, and for values in between it blends. So mix(vec3(1,0,0), vec3(0,0,1), 0.5) gives purple, halfway between red and blue.
The function smoothstep(edge0, edge1, x) returns 0 when x < edge0, returns 1 when x > edge1, and smoothly interpolates for x in between. Unlike a linear ramp, smoothstep has zero derivative at both ends—the transition is gentle, not abrupt.
The function clamp(x, lo, hi) restricts x to the range [lo, hi]—returning lo if x < lo, hi if x > hi, and x otherwise. It’s useful whenever you need to keep a value in bounds, and you’ll see it constantly in shader code.
Combining these, we can anti-alias a circle:
float d = length(p);
float r = 1.0;
float f = d - r;
float t = smoothstep(-0.05, 0.05, f);
vec3 color = mix(vec3(1.0, 1.0, 0.0), vec3(0.1, 0.1, 0.3), t);
fragColor = vec4(color, 1.0);When f < -0.05 (well inside the circle), t = 0, so we get yellow. When f > 0.05 (well outside), t = 1, so we get dark blue. In the thin band where f passes through zero, the color blends smoothly. The hard jagged edge becomes a soft transition.
The same idea works for curves. Replace the hard threshold on distance with a smoothstep, and your implicit curves get smooth anti-aliased edges instead of stairstepped pixels.
A Note on Color
So far we’ve used simple RGB colors like vec3(1.0, 1.0, 0.0) for yellow. For more sophisticated coloring—rainbows, smooth gradients, escape-time fractal coloring—the Color Appendix provides copy-and-paste solutions including HSV conversion and cosine palettes.
1.8 Putting It All Together
We now have all the tools: coordinate systems, distance-based rendering, implicit curves with gradient correction, animation, and mouse interaction. Let’s combine them.
Mouse as Parameter
The mouse doesn’t have to control position—it can control any parameter. A useful pattern: map iMouse.x to a parameter range and drag across the screen to explore a family of curves.
The folium of Descartes is the curve \(x^3 + y^3 = 3axy\). We can explore its level sets by drawing \(x^3 + y^3 - 3axy = c\) for different values of \(c\):
vec2 p = normalize_coord(fragCoord);
// Fixed parameter a
float a = 1.5;
// Map mouse x to level set value c in [-2, 2]
float c = mix(-2.0, 2.0, iMouse.x / iResolution.x);
// Folium of Descartes: x³ + y³ - 3axy = c
float F = p.x*p.x*p.x + p.y*p.y*p.y - 3.0*a*p.x*p.y - c;
// Gradient: ∇F = (3x² - 3ay, 3y² - 3ax)
vec2 grad = vec2(3.0*p.x*p.x - 3.0*a*p.y, 3.0*p.y*p.y - 3.0*a*p.x);
float dist = abs(F) / max(length(grad), 0.01);
vec3 color;
if (dist < 0.05) {
color = vec3(1.0, 1.0, 0.0);
} else {
color = vec3(0.1, 0.1, 0.3);
}
fragColor = vec4(color, 1.0);Drag left and right to sweep through the level sets and watch the curve topology change.
Example: Elliptic Curves
Remember the opening demo? We can now build it ourselves.
An elliptic curve (over the reals) in Weierstrass form is defined by \(y^2 = x^3 + ax + b\). These curves appear throughout mathematics: in number theory, cryptography, and the proof of Fermat’s Last Theorem. For us, they’re a nice example because their topology varies with the parameters.
Over \(\mathbb{R}\), an elliptic curve is either a single connected component (one smooth loop extending to infinity) or two components (a bounded “egg” plus an unbounded piece). The transition between these cases happens at singular curves, where the curve develops a cusp or crosses itself.
The discriminant \(\Delta = 4a^3 + 27b^2\) detects these cases:
- \(\Delta > 0\): one connected component
- \(\Delta < 0\): two components
- \(\Delta = 0\): singular (the curve has a cusp or node)
As an implicit curve, we write \(F(x,y) = y^2 - x^3 - ax - b = 0\). The gradient is \(\nabla F = (-3x^2 - a, \, 2y)\).
Let’s start with a single curve at fixed parameter values:
vec2 p = normalize_coord(fragCoord);
float a = -1.0;
float b = 0.5;
float F = p.y * p.y - p.x * p.x * p.x - a * p.x - b;
vec2 grad = vec2(-3.0 * p.x * p.x - a, 2.0 * p.y);
float dist = abs(F) / max(length(grad), 0.01);
vec3 color = vec3(0.05, 0.05, 0.1);
if (dist < 0.05) {
color = vec3(1.0, 0.85, 0.3);
}
fragColor = vec4(color, 1.0);Now let’s color by topology. We compute the discriminant and choose the color accordingly:
float disc = 4.0 * a * a * a + 27.0 * b * b;
if (dist < 0.05) {
if (abs(disc) < 0.1) {
color = vec3(1.0, 0.2, 0.2); // red: singular
} else if (disc > 0.0) {
color = vec3(1.0, 0.85, 0.3); // gold: one component
} else {
color = vec3(0.3, 0.5, 0.8); // blue: two components
}
}Finally, we let the mouse control the parameters, turning this into an explorer for the \((a, b)\) parameter space:
vec2 p = normalize_coord(fragCoord);
// Mouse controls (a, b)
float a = mix(-2.0, 1.0, iMouse.x / iResolution.x);
float b = mix(-2.0, 2.0, iMouse.y / iResolution.y);
// Discriminant determines topology
float disc = 4.0 * a * a * a + 27.0 * b * b;
// Elliptic curve
float F = p.y * p.y - p.x * p.x * p.x - a * p.x - b;
vec2 grad = vec2(-3.0 * p.x * p.x - a, 2.0 * p.y);
float dist = abs(F) / max(length(grad), 0.01);
vec3 color = vec3(0.05, 0.05, 0.1);
if (dist < 0.05) {
if (abs(disc) < 0.3) {
color = vec3(1.0, 0.2, 0.2); // red: singular
} else if (disc > 0.0) {
color = vec3(1.0, 0.85, 0.3); // gold: one component
} else {
color = vec3(0.3, 0.5, 0.8); // blue: two components
}
}
fragColor = vec4(color, 1.0);Drag to explore the parameter space. Gold curves have one component; blue curves split into two pieces (an “egg” and an infinite branch). Red marks the singular curves where \(\Delta = 4a^3 + 27b^2 = 0\)—the boundary between topological types.