Shader Programming

A shader is a small program that runs on the GPU, once per vertex or once per pixel, millions of times per frame, with essentially no ability to communicate between invocations. That constraint is the whole discipline. You can't loop over neighboring pixels, you can't accumulate a total across the image, and you can't branch cheaply — but you get thousands of cores executing in lockstep, which is why real-time graphics is possible at all.

Practically, shader work is where a game's visual identity lives: water, fire, dissolve effects, outlines, stylized lighting, screen-space post-processing. Modern engines let you build most of it in node graphs (Unity's Shader Graph, Unreal's Material Editor, Godot's visual shaders), but the graphs compile to the same code, and reading that code is what lets you debug and optimize the result.

TL;DR

Quick Example

A complete fragment shader — a scrolling, glowing dissolve effect — in GLSL:

And the vertex shader that feeds it:

Everything between the two stages — vUV, vNormal — is interpolated across the triangle by the rasterizer. That interpolation is free hardware you get to exploit, and it's the mechanism behind most shader tricks.

Core Concepts

The graphics pipeline

A 1080p screen is 2 million pixels. A model may have 5,000 vertices. That ratio — roughly 400:1 — is why fragment shader cost dominates, and why "move work to the vertex shader" is a standard optimization.

Spaces and transforms

Most vertex shader bugs are a transform applied in the wrong space or the wrong order. Normals in particular need the inverse-transpose of the model matrix, not the model matrix, or non-uniform scaling skews your lighting.

UV coordinates

UVs are per-vertex texture coordinates in the range 0–1, interpolated per fragment. Nearly every 2D effect is a function of UV:

fract, length, step, and smoothstep compose into a surprising amount of visual variety with no textures at all.

Data flow into a shader

GLSL vs. HLSL

The syntax differs; the mental model doesn't. WGSL (WebGPU) is a third dialect with the same structure. Engines add their own layer — Unity's ShaderLab wraps HLSL, Godot has a GLSL-like language — but learning one transfers almost entirely.

Lighting

Diffuse and specular

The dot product between the surface normal and the light direction is the entire basis of diffuse lighting, and it appears in some form in every lighting model.

Physically based rendering

PBR replaces ad-hoc shininess with parameters that describe a real material: albedo, metallic, roughness, normal map, and ambient occlusion. The advantage isn't realism for its own sake — it's that a material authored once looks correct under every lighting condition, so artists stop re-tuning assets per scene. Modern engines implement PBR by default; you mostly author the texture inputs rather than the BRDF.

Normal mapping

A normal map stores per-pixel surface normals in a texture, letting a flat polygon light as though it had geometric detail. It's the cheapest large visual win in real-time rendering: bake a million-triangle sculpt into a normal map, apply it to a 2,000-triangle mesh, and the silhouette is the only thing you lose.

Compute Shaders

Compute shaders drop the graphics pipeline entirely — no vertices, no fragments, just a grid of threads over arbitrary buffers.

Used for particle systems, GPU culling, physics, procedural generation, image processing, and increasingly ML inference. The key benefit is that data can stay on the GPU — a particle simulation that computes and renders without ever crossing the PCIe bus is dramatically faster than one that round-trips through the CPU.

Performance

Where the time goes

Branch divergence

GPUs execute threads in groups (warps of 32, wavefronts of 64) in lockstep. If threads in a group take different branches, the hardware runs both branches and masks the results — so a 50/50 branch costs the sum, not the average.

Branching on a uniform is fine — every thread takes the same path. Branching on interpolated or sampled data is what costs you.

Mipmaps

Sampling a 2048×2048 texture on a 4-pixel-wide object thrashes the texture cache and aliases badly. Mipmaps are precomputed downscales; the GPU picks the right level automatically. Enabling them is nearly always a win in both quality and speed, and forgetting them on a distant object is a common source of shimmer.

⚠️ Warning: discard (and alpha testing) disables early-Z on many GPUs, because the hardware can no longer know a fragment's depth before running the shader. On mobile especially, a discard in a shader used across large geometry can cost far more than the branch it saves.

Best Practices

Move work to the vertex shader when the result interpolates acceptably

Lighting computed per vertex on dense geometry can look nearly identical to per-pixel at a fraction of the cost. The ratio of fragments to vertices means this is often the largest single win available.

Prefer math to texture lookups — but measure

A texture sample costs bandwidth and can stall; arithmetic is often free by comparison on modern GPUs. Procedural noise, gradients, and simple patterns are frequently cheaper computed than sampled. "Frequently" is not "always" — profile with a GPU tool such as RenderDoc, Nsight, or your engine's frame debugger.

Keep precision as low as you can get away with

On mobile, mediump/half is roughly twice the throughput of highp/float for color and UV math. Reserve full precision for world-space positions and anything where banding would show.

Pack data into unused channels

A single RGBA texture can carry roughness, metallic, ambient occlusion, and a mask. Four one-channel textures cost four samples and four times the memory; one packed texture costs one sample.

Author in a node graph, then read the generated code

Shader Graph and the Material Editor are genuinely faster for iteration and for artist collaboration. When performance matters, inspect what they generated — graphs happily produce redundant sampling and unnecessary precision.

Use smoothstep for edges rather than step

step produces a hard, aliased boundary. smoothstep gives you a controllable soft edge for almost no extra cost, and it's the difference between a jagged outline and a clean one.

Test on the weakest target hardware

Mobile GPUs have dramatically less bandwidth, tile-based deferred architectures with different cost characteristics, and thermal throttling. A shader that costs nothing on a desktop card can halve frame rate on a phone, particularly if it uses discard or heavy texture sampling.

Common Mistakes

Forgetting to normalize interpolated vectors

Transforming normals with the model matrix

Sampling a texture inside a divergent branch

Doing per-pixel work that's constant per draw

Ignoring overdraw on transparency

Assuming a shader that runs is a shader that's correct

Shader bugs frequently show up as "slightly wrong color" rather than an error. Use a frame capture tool to inspect intermediate values, and output debug quantities directly to the screen (fragColor = vec4(N * 0.5 + 0.5, 1.0)) to verify normals, UVs, and depth visually.

FAQ

Do I need to write shader code, or can I use a node graph?

Node graphs cover most production work and are better for collaboration and iteration. Learning the underlying code still pays for itself: you'll understand why a graph is slow, be able to read the thousands of published GLSL/HLSL snippets that exist, and be able to write the effects that graphs handle badly (custom lighting, complex loops, compute). Start with graphs; read the generated code when something is wrong.

Where should I learn shaders?

Write fragment shaders in isolation before wiring them into an engine. Shadertoy and similar environments give you a fragment shader over a full-screen quad with a live edit loop, which is the fastest way to build intuition for UV math, smoothstep, noise, and signed distance fields. Then port the ideas into your engine's material system.

Why does my shader look right on desktop and wrong on mobile?

Precision, most often. Mobile GPUs use lower default precision, so a highp calculation you relied on may be running at mediump and banding or losing range. Other frequent causes: unsupported texture formats, a lack of derivative support in some contexts, and tile-based renderers penalizing discard and framebuffer reads far more heavily than desktop GPUs do.

What are signed distance fields used for?

An SDF stores the distance to the nearest surface, which lets you get crisp edges at any scale (text rendering), soft shadows and outlines almost free, and shape-based effects with cheap math. SDF text rendering in particular is the standard technique for UI text in engines, because one small texture stays sharp at any size.

How do shaders relate to WebGPU?

WebGPU is a modern browser graphics API with its own shading language, WGSL. The pipeline stages, the vertex/fragment split, and the performance characteristics are the same — WGSL is a different syntax over the same hardware model, plus first-class compute shader support that WebGL lacked.

What's the difference between forward and deferred rendering?

Forward rendering shades each object against each light as it draws — simple, handles transparency and MSAA well, and costs scale with objects × lights. Deferred rendering writes material properties to a G-buffer first, then shades once per pixel per light — it handles many lights well, and struggles with transparency and anti-aliasing. Most modern engines use a hybrid (forward+ / clustered) that keeps forward's flexibility with deferred's light-count scaling.

Related Topics

References