Understanding the Concepts
Shaders are often taught as random magic. Here, we break them down into their component logic: the GPU model, the math of light, and the art of procedural complexity.
What Shaders Really Are
The first thing that makes shader learning feel messy is that most resources mix together three different layers of knowledge without saying so: the GPU model itself, the math and image-making tricks, and the artistic taste/judgment layer.
The Three Layers
- Platform layer: What WebGL2 / GLSL ES 3.00 actually lets you do.
- Rendering technique layer: Rasterization, ray marching, post-processing, bloom, fog, etc.
- Material and beauty layer: Color, composition, lighting response, detail hierarchy.
WebGL2
WebGL2 isn’t a shader art tool. It’s a browser API for the GPU. At its core, you send in geometry, it gets processed in a vertex stage, and then fragments are shaded to produce the final image.
“WebGL is just a rasterization engine… it draws points, lines, and triangles.” - https://webgl2fundamentals.org/webgl/lessons/webgl-2d-vs-3d-library.html
Shader Disciplines
The field becomes much easier once you group almost everything into four disciplines.
- 2D / UV Space: Working directly with coordinates - gradients, shapes, masks, noise.
- 3D Lighting: Traditional rendering - meshes, normals, lighting, shadows.
- SDF Worlds: Scenes built from math - distance fields and ray marching.
- Final Pass: Everything after rendering - bloom, tone mapping, colour grading.
Techniques
The cleanest way to stop shader knowledge feeling scattered is to organize techniques by what problem they solve visually.
1. Shape generation
How you create recognizable structure. Clarity comes from simple fields first, not from noise. If the large scale form is weak, adding detail only makes the image busier.
- UV space construction: Normalize coordinates, center them, and build masks from distance comparisons.
- Signed Distance Functions (SDF): A function that returns how far a point is from a surface. Ideal for crisp shapes, outlines, and smooth union blending.
2. Detail generation
Once the major forms exist, the next problem is richness. Use noise for natural imperfection.
- fBm (Fractal Brownian Motion): Combines multiple octaves of noise to create the impression of richness across scales.
- Domain Warping: Distorting coordinates before evaluating another function. Turns plain patterns into fluid, folded, or turbulent ones.
3. Lighting response
Convincing surface behavior often matters more than raw geometric complexity.
- Fresnel: Grazing angle brightening. This is one of the highest value cheats in all of rendering to make edges feel "premium."
- PBR Principles: Using albedo, metallic, and roughness values to create consistent material behavior across lighting conditions.
4. Depth and space cues
A scene looks flat when it lacks depth cues. Use fog, ambient occlusion, and shadows to ground your objects in space.
Those are only a few of the techniques
There are many more techniques to explore, but these are some of the most important ones to learn.
How do I start?
The easiest way to learn shaders is step by step. Each lesson gives you one new piece of control over the image, and builds on the last.
Coordinate Thinking
Everything starts here. A fragment shader is just a function of position. The first thing you do is convert screen pixels into a coordinate space you can actually work with.
// Convert pixel position to 0–1 UV space
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
// Remap to -1–1 so the center of the screen is (0, 0)
vec2 centeredUV = uv * 2.0 - 1.0;
// Correct for aspect ratio so shapes stay proportional
centeredUV.x *= u_resolution.x / u_resolution.y;
This gives you a stable coordinate system where everything behaves predictably.
Mask Algebra
Once you have coordinates, the next step is control. You need ways to turn continuous values into shapes and regions you can work with.
smoothstep is the main tool here. It lets you create soft edges instead of hard, brittle ones.
// Create a soft band between two ranges
float bandMask =
smoothstep(edgeStartA, edgeEndA, value) -
smoothstep(edgeStartB, edgeEndB, value);
// Create a pulse centered around a position
float distanceFromCenter = abs(value - center);
float pulseMask = smoothstep(width, 0.0, distanceFromCenter);
Most 2D shader work comes down to building and combining masks like this.
Multi-scale Structure
This is where things stop looking flat. If a shader feels “dead,” it’s usually because everything exists at a single scale.
Good results come from layering detail at different scales — large shapes, medium variation, and fine noise working together.
Multi-scale structure beats single-scale detail.
This is what techniques like fBm are doing under the hood: stacking frequencies to create richer, more believable images.
GLSL Functions
This is the set of small, reusable formulas you end up using over and over. They show up in shader art, 2D procedural work, post effects, lighting tricks, and general real time rendering. You do not need to memorise all of them at once, but you do want to get comfortable recognising what each one is for. At the end of the day, its all just math. Variables are just containers for values, and functions are just operations on those values. For example, you can assign a value to a variable like this:
float value = 1.0;
And you can perform operations on values like this:
float result = value + 1.0;
Congratulations, you now know how to assign a value to a variable and perform operations on it. You are now a GLSL programmer.
Remapping and Value Shaping
These are the utility functions that help you move values from one range to another, clamp them, and shape motion or transitions so they feel more controlled.
// Remap a value from one range into another
float remap(float value, float inputMin, float inputMax, float outputMin, float outputMax) {
return outputMin + (value - inputMin) * (outputMax - outputMin) / (inputMax - inputMin);
}
// Remap and clamp the result to 0.0-1.0
float remapClamped(float value, float inputMin, float inputMax) {
return clamp((value - inputMin) / (inputMax - inputMin), 0.0, 1.0);
}
// Standard smoothstep curve
float smoothCurve(float value) {
return value * value * (3.0 - 2.0 * value);
}
// Sharper ease curve
float smootherCurve(float value) {
return value * value * value * (value * (value * 6.0 - 15.0) + 10.0);
}
// Simple pulse that peaks at the center and fades outward
float centeredPulse(float value, float center, float halfWidth) {
float distanceToCenter = abs(value - center);
return smoothstep(halfWidth, 0.0, distanceToCenter);
}
Coordinate Helpers
Before you can draw anything, you usually need a clean coordinate space. These helpers make it easier to centre, scale, rotate, and repeat patterns without rewriting the same setup every time.
// Convert screen coordinates into centred coordinates with aspect correction
vec2 getCenteredUV(vec2 fragmentCoord, vec2 resolution) {
vec2 uv = fragmentCoord / resolution;
vec2 centeredUV = uv * 2.0 - 1.0;
centeredUV.x *= resolution.x / resolution.y;
return centeredUV;
}
// Rotate a 2D point around the origin
vec2 rotate2D(vec2 point, float angleRadians) {
float sineValue = sin(angleRadians);
float cosineValue = cos(angleRadians);
return vec2(
point.x * cosineValue - point.y * sineValue,
point.x * sineValue + point.y * cosineValue
);
}
// Repeat space into a tiled grid
vec2 repeatTile(vec2 point, vec2 cellSize) {
return mod(point + 0.5 * cellSize, cellSize) - 0.5 * cellSize;
}
2D Distance Primitives
Signed distance functions are one of the cleanest ways to describe shapes. Instead of asking "is this pixel inside the shape?", you ask "how far is this pixel from the shape?" That gives you much more control over edges, outlines, blends, and effects.
// Signed distance to a circle
float sdCircle(vec2 point, float radius) {
return length(point) - radius;
}
// Signed distance to an axis-aligned box
float sdBox(vec2 point, vec2 halfSize) {
vec2 distanceFromBox = abs(point) - halfSize;
float outsideDistance = length(max(distanceFromBox, 0.0));
float insideDistance = min(max(distanceFromBox.x, distanceFromBox.y), 0.0);
return outsideDistance + insideDistance;
}
// Signed distance to a line segment
float sdLineSegment(vec2 point, vec2 lineStart, vec2 lineEnd) {
vec2 pointToStart = point - lineStart;
vec2 lineVector = lineEnd - lineStart;
float projection = clamp(dot(pointToStart, lineVector) / dot(lineVector, lineVector), 0.0, 1.0);
vec2 closestPoint = lineStart + lineVector * projection;
return length(point - closestPoint);
}
// Signed distance to a ring
float sdRing(vec2 point, float radius, float thickness) {
float circleDistance = abs(length(point) - radius);
return circleDistance - thickness;
}
Mask Building
Once you have a distance value, you can turn it into a mask. This is where shapes become fill, outlines, borders, glows, and soft transitions.
// Soft fill mask from a signed distance field
float fillMask(float signedDistance, float blurAmount) {
return 1.0 - smoothstep(0.0, blurAmount, signedDistance);
}
// Soft outline mask from a signed distance field
float outlineMask(float signedDistance, float outlineWidth, float blurAmount) {
float outerEdge = smoothstep(outlineWidth + blurAmount, outlineWidth, abs(signedDistance));
return outerEdge;
}
// Band mask between two thresholds
float bandMask(float value, float bandStart, float bandEnd, float blurAmount) {
float startMask = smoothstep(bandStart - blurAmount, bandStart + blurAmount, value);
float endMask = smoothstep(bandEnd - blurAmount, bandEnd + blurAmount, value);
return startMask - endMask;
}
Procedural Detail Helpers
This is the kind of stuff you use when an image needs variation, break-up, texture, or motion. These helpers are the starting point for noise, grain, cell patterns, and all the little imperfections that stop a shader from looking flat.
// Basic 2D hash
float hash21(vec2 point) {
point = fract(point * vec2(123.34, 456.21));
point += dot(point, point + 45.32);
return fract(point.x * point.y);
}
// Basic 2D value noise
float noise2D(vec2 point) {
vec2 cell = floor(point);
vec2 localUV = fract(point);
vec2 smoothUV = localUV * localUV * (3.0 - 2.0 * localUV);
float bottomLeft = hash21(cell);
float bottomRight = hash21(cell + vec2(1.0, 0.0));
float topLeft = hash21(cell + vec2(0.0, 1.0));
float topRight = hash21(cell + vec2(1.0, 1.0));
float bottomMix = mix(bottomLeft, bottomRight, smoothUV.x);
float topMix = mix(topLeft, topRight, smoothUV.x);
return mix(bottomMix, topMix, smoothUV.y);
}
// Fractal Brownian Motion using layered noise
float fbm(vec2 point) {
float total = 0.0;
float amplitude = 0.5;
float frequency = 1.0;
for (int octave = 0; octave < 5; octave++) {
total += noise2D(point * frequency) * amplitude;
frequency *= 2.0;
amplitude *= 0.5;
}
return total;
}
Colour Helpers
Good shaders are not just about shapes. A lot of the final look comes from how you build and control colour. These helpers make gradients and palette driven colour work much easier.
// Linear gradient between two colours
vec3 blendColor(vec3 colorA, vec3 colorB, float amount) {
return mix(colorA, colorB, amount);
}
// Palette generator popular in procedural shader work
vec3 palette(float value, vec3 baseColor, vec3 amplitude, vec3 frequency, vec3 phase) {
return baseColor + amplitude * cos(6.28318 * (frequency * value + phase));
}
// Cheap brightness and contrast adjustment
vec3 adjustContrast(vec3 color, float contrastAmount) {
return (color - 0.5) * contrastAmount + 0.5;
}
Compositing Helpers
Once you have masks and colours, you need to combine them. These helpers are simple, but they are doing a lot of the real work in layered shaders.
// Apply a mask to blend one colour over another
vec3 applyMask(vec3 backgroundColor, vec3 foregroundColor, float maskValue) {
return mix(backgroundColor, foregroundColor, maskValue);
}
// Additive blend
vec3 addColor(vec3 baseColor, vec3 addedColor, float intensity) {
return baseColor + addedColor * intensity;
}
// Simple glow contribution
float glowFalloff(float signedDistance, float glowRadius) {
return glowRadius / max(abs(signedDistance), 0.0001);
}
You are not expected to use all of these at once. The real point of a function bank is to stop reinventing the same maths every time you want a soft edge, a repeated pattern, a circle, some noise, or a colour ramp. Once these become familiar, building shaders gets much faster.
Beauty & Complexity
Good shaders are not built by stacking more math. They are built by making better decisions.
Most beginners assume complexity comes from harder formulas. It does not. It comes from layering simple ideas in the right order, at the right scale, with the right intent.
Complexity is not “more math.” It is layered intention.
The 7 Pillars: Form, Value Control, Material Response, Depth Cues, Detail Hierarchy, Motion, and Image Shaping.
These show up in almost every shader that feels “finished.” If something looks off, one of these is usually missing.
1. Form
This is the base structure of your image. If the silhouette or composition is weak, nothing else will save it.
Clear shapes, readable spacing, and intentional layout matter more than detail.
2. Value Control
Most shaders fail here. If everything is mid-grey, the image feels flat no matter how complex it is.
You need contrast — areas of dark, areas of light, and controlled transitions between them.
3. Material Response
This is how surfaces react to light. Even simple shaders feel “expensive” when materials behave correctly.
- Specular highlights
- Roughness variation
- Fresnel edge response
This is what makes something feel like metal, plastic, glass, or energy instead of just colour.
4. Depth Cues
Depth is rarely about actual 3D. It is about hints that tell your eye how far things are.
- Ambient occlusion (darkening where things meet)
- Soft shadows
- Fog or atmospheric fade
Even in 2D shaders, adding depth cues makes everything feel grounded.
5. Detail Hierarchy
Everything should not exist at the same scale.
Strong images have:
- Large forms (readable from far away)
- Medium variation (breaks up the surface)
- Fine detail (adds richness up close)
Multi-scale structure is one of the biggest jumps in quality you can make.
6. Motion
Even subtle movement brings shaders to life.
- Slow drifting noise
- Pulsing values
- Small time-based distortions
Static shaders often feel dead, even if they are technically correct.
7. Image Shaping
This is the final pass, the part that makes everything feel cohesive.
- Tone mapping
- Bloom
- Vignetting
- Colour grading
This is where a “working shader” becomes a finished image.
The "Path Tracing" Illusion
You do not need real path tracing to get high-end visuals. What you actually need are the visual cues that path tracing produces.
If you reproduce those cues, your shader will feel far more realistic than it actually is.
- Soft energy rolloff: Light fades smoothly instead of cutting off linearly.
- Fresnel edges: Surfaces become more reflective at grazing angles.
- Ambient occlusion: Contact areas darken naturally.
- Bloom: Bright areas bleed into surrounding pixels.
- Specular response: Highlights are sharp or soft depending on material.
- Colour bleed (fake): Subtle colour influence between nearby surfaces.
Most “wow” shaders are not physically correct — they are just very good at faking these signals.
Common Failure Cases
If your shader looks wrong, it is usually one of these:
- Everything is the same brightness (no value control)
- Edges are too sharp or too harsh (no shaping)
- No scale variation (flat detail)
- No grounding (missing depth cues)
- Colours feel random (no palette or grading)
What to Focus On First
If you want the biggest visual improvement quickly, focus on this order:
- Fix your values (contrast first)
- Add soft transitions (smoothstep everywhere)
- Introduce multi-scale detail
- Add a simple bloom or glow
You will get further doing this than adding more complex math.
Shader Recipes
This is where everything comes together.
Most strong shaders are not random. They follow a consistent build order:
Form → Detail → Light → Depth → Colour → Post → Stability
If something looks wrong, it is usually because one of these steps is missing or weak.
Recipe: Glossy Chrome Sphere
This builds a convincing “chrome” look using simple lighting tricks — no real ray tracing.
// --- Setup ---
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec2 p = uv * 2.0 - 1.0;
p.x *= u_resolution.x / u_resolution.y;
// --- 1. Form (sphere shape) ---
float sphereRadius = 0.6;
float dist = length(p) - sphereRadius;
// Mask for the sphere
float sphereMask = smoothstep(0.01, 0.0, dist);
// --- 2. Normal (fake 3D surface) ---
vec3 normal = normalize(vec3(p, sqrt(1.0 - dot(p, p))));
// View and light direction
vec3 viewDir = normalize(vec3(0.0, 0.0, 1.0));
vec3 lightDir = normalize(vec3(-0.5, 0.8, 0.6));
// --- 3. Specular (sharp highlight) ---
vec3 halfVector = normalize(lightDir + viewDir);
float specular = pow(max(dot(normal, halfVector), 0.0), 64.0);
// --- 4. Fresnel (edge reflection boost) ---
float fresnel = pow(1.0 - max(dot(normal, viewDir), 0.0), 5.0);
// --- 5. Fake environment reflection ---
vec3 reflection = vec3(0.2, 0.4, 0.8) * normal.y * 0.5 + 0.5;
// --- 6. Combine ---
vec3 color = reflection + specular + fresnel * 0.5;
// --- 7. Apply mask ---
color *= sphereMask;
// --- 8. Post (simple tone shaping) ---
color = pow(color, vec3(0.4545)); // gamma correction
gl_FragColor = vec4(color, 1.0);
This works because it hits the important signals:
- Clear form (sphere)
- Sharp specular highlight
- Fresnel edge boost
- Environment-like reflection
Recipe: Domain-Warped Liquid
This builds flowing, organic motion using layered noise and coordinate warping.
// --- Setup ---
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
vec2 p = uv * 3.0;
// --- Time ---
float t = u_time * 0.2;
// --- 1. Base noise ---
float noiseA = fbm(p + vec2(0.0, t));
float noiseB = fbm(p + vec2(5.2, t));
// --- 2. Domain warp ---
vec2 warp = vec2(noiseA, noiseB);
// --- 3. Evaluate warped noise ---
float warpedNoise = fbm(p + 4.0 * warp);
// --- 4. Shape the values ---
float shaped = smoothstep(0.2, 0.8, warpedNoise);
// --- 5. Colour palette ---
vec3 baseColor = vec3(0.2, 0.3, 0.6);
vec3 accentColor = vec3(0.8, 0.9, 1.0);
vec3 color = mix(baseColor, accentColor, shaped);
// --- 6. Add glow ---
color += shaped * 0.3;
// --- 7. Post ---
color = pow(color, vec3(0.4545));
gl_FragColor = vec4(color, 1.0);
This works because:
- Domain warping breaks up uniform noise
- Multiple noise layers create structure
- Smooth shaping avoids harsh transitions
- Colour is driven by the structure, not random
How to Build Your Own Recipes
If you want to create your own shaders instead of copying, follow this process every time:
- Start with form (circle, gradient, field)
- Add structure (noise, repetition, warping)
- Introduce light cues (specular, fresnel, glow)
- Shape your values (smoothstep, remapping)
- Apply colour last (palette or gradients)
- Finish with post (bloom, tone, contrast)
Try it for yourself
At this point, you do not need more formulas. You need to start building. The difference between knowing shaders and actually making good ones comes from applying these ideas repeatedly until they become instinct. Focus on clear form, controlled values, and intentional layering. Keep your code readable, build in stages, and fix what looks wrong instead of adding more complexity. If you follow the stack and stay deliberate, even simple shaders can produce results that feel polished and complete.