Day 1: Introduction
1.1Overview
A shader is a function from coordinates to colors. You write code that takes a point
A family of elliptic curves
The key idea: to draw a curve
There are subtleties, of course. The naive approach gives curves of uneven thickness (thin where
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.2What is a Shader?
We want to draw images on a screen.
Mathematically, an image is a function from a region
To realize this on a computer, we discretize. A screen is a grid of pixels:
Colors are represented as RGB triples: red, green, and blue intensities, each in
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
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.
1.2.1Parallelism
A 1920×1080 display has over two million pixels. How do we evaluate
The answer is parallelism. A GPU contains thousands of cores, and it evaluates
The tradeoff: each pixel's computation must be independent. Pixel
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.
1.2.2Why 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.3First Shaders: Colors and Syntax
1.3.1The 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.
1.3.2Hello 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.
1.3.3GLSL 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.0
For 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.
1.3.4Uniforms: 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.
1.3.5Dividing 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.4Coordinate Systems
1.4.1Pixel 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 upwardRanges from
to( 0 , 0 ) where( 𝑋 , 𝑌 ) 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.
1.4.2Step 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
Since both coordinates are in
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).
1.4.3Step 2: Center the Origin
Subtract
uv = uv - vec2(0.5, 0.5);
Now uv ranges from
1.4.4Step 3: Aspect Ratio Correction
We've mapped a rectangle of pixels (
To fix this, we scale the
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.)
1.4.5Step 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
1.4.6The 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; // scale
From here on, p is our mathematical coordinate, centered at the origin, aspect-corrected, with a reasonable range.
1.5Drawing 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
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
1.5.1Circles
Now consider the function
To draw a filled disk of radius
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
vec2 center = vec2(1.0, 0.5);
float d = length(p - center);
1.5.2Drawing a Ring
Our function
But what if we want just the boundary—a ring of some thickness? We want to color one way when
So we look at
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.6Animation and Interaction
So far our shaders are static images. Let's make them move and respond to input.
1.6.1Animation 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.
1.6.2The 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.
1.6.3Dragging 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.
1.6.4Writing 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.
1.6.5Combining 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.7Implicit Curves
We've drawn circles using the distance function
1.7.1A First Example: The Parabola
Consider
To draw the curve itself, we color pixels where
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);
1.7.2More Examples
An ellipse:
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:
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:
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);
1.7.3The 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
1.7.4Why Circles Worked
For the circle, we used
This is a fact from differential geometry:
1.7.5Gradient Correction
We can fix the non-uniform thickness by dividing by the gradient magnitude. Instead of thresholding
This approximates the signed distance to the curve. The intuition:
For the lemniscate, we compute the gradient analytically:
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.
1.7.6Softer 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.
1.7.7A 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.8Putting 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.
1.8.1Mouse 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
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.
1.8.2Example: Elliptic Curves
Remember the opening demo? We can now build it ourselves.
An elliptic curve (over the reals) in Weierstrass form is defined by
Over
The discriminant
: one connected componentΔ > 0 : two componentsΔ < 0 : singular (the curve has a cusp or node)Δ = 0
As an implicit curve, we write
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
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