Why is my shader slow? A practical guide to shader performance
A fragment shader runs once for every pixel. On a 1920 by 1080 screen that is more than two million runs per frame, and a game draws sixty or more frames a second. Anything you add to a shader is multiplied by all of that, which is why a line that looks harmless can cost real frames. This guide covers what usually makes a shader slow, the simple fixes that matter most, and how to find out what is actually happening.
First: measure, do not guess
Performance advice is full of folklore, and the real cost of an instruction changes from one GPU to the next. The reliable approach is to measure on the hardware you care about. On ShaderBattle, the results screen reports your shader's GPU time against the target's. In Unity, the Profiler and the Frame Debugger show where frame time goes; on other platforms RenderDoc and the vendor tools (such as Xcode's GPU capture and NVIDIA or AMD profilers) do the same. Change one thing, measure again, and keep the change only if it helped.
Cost one: how many pixels
The most effective optimisation is to run the shader on fewer pixels. Rendering at a lower resolution, restricting the effect to the part of the screen that needs it, or skipping pixels you know will be covered all cut the cost directly. A shader that is twice as expensive but runs on a quarter of the pixels is a big win. This is also why full-screen effects (blur, fog, post-processing) are the usual suspects when a game suddenly slows down.
Cost two: loops
Every loop iteration costs a full set of operations, for every pixel. A procedural noise function with five octaves does five times the work of one octave. A ray-marching loop with 100 steps does up to 100 distance evaluations per pixel. When a shader is slow, loops are the first place to look:
- Lower the iteration count and see if the picture still looks fine. Often 4 octaves look the same as 6.
- Stop early. In a ray march, leave the loop the moment a surface is hit or the ray has gone too far.
- Use loops with small, fixed bounds so the compiler can unroll them.
Cost three: expensive operations
Some operations cost more than others, and the gap depends on the GPU. As a rule of thumb, the ones to be careful with are pow, exp, log, sin, cos, atan, normalize, sqrt and divisions. Cheap, common rewrites include:
float a = x * x; // instead of pow(x, 2.0) float b = x * x * x; // instead of pow(x, 3.0) float inv = 1.0 / w; // work out a division once... float c = value * inv; // ...then multiply by it many times float len2 = dot(v, v); // compare squared lengths instead of using length() when you only need to compare
Do not do this everywhere and mangle readable code; do it inside loops and in the hot paths that you have measured.
Cost four: texture reads
Sampling a texture costs memory bandwidth, and bandwidth is often the real limit on a GPU rather than arithmetic. Reading several textures per pixel, or reading in an order that jumps around memory, adds up. Use smaller or compressed textures, avoid repeated reads of the same texel (store it in a variable), and use mipmaps so distant surfaces read from smaller versions.
Cost five: branches
A GPU runs pixels in lockstep in small groups (often 32 or 64 at a time, called warps or waves). If one pixel in a group takes the if branch and its neighbour takes the else, the hardware generally has to run both paths for the whole group. A branch is cheap when whole groups of neighbouring pixels agree, and expensive when they split pixel by pixel. Sometimes it is faster to compute both sides and blend them with lerp and step, which has no divergence at all:
float3 result = lerp(colorB, colorA, step(0.5, mask)); // no branch
That only wins when both sides are cheap. If one side is a big expensive calculation, skipping it with a real if that most pixels agree on is still the better choice.
Cost six: precision, especially on phones
Mobile GPUs can often do half-precision maths much faster and with less power. In Unity's HLSL you can declare half, half3 and half4 for values that do not need full precision, such as colors in the 0 to 1 range. Keep full precision (float) for positions, UVs and anything with a large range, or you will see banding and swimming artifacts.
Move work out of the pixel shader
If a value is the same for every pixel, do not compute it in the pixel shader. Work it out once on the CPU and pass it in, or compute it per vertex. A vertex shader runs per vertex, not per pixel, and a big triangle covers thousands of pixels, so anything that varies slowly across a surface (a simple gradient, a light direction) is far cheaper to compute there and let the hardware interpolate it.
A tuning order that works
- Measure, and find out what the shader is limited by.
- Reduce the number of pixels it runs on.
- Cut loop counts and add early exits.
- Replace expensive functions in the hot path with cheap equivalents.
- Reduce texture reads.
- Consider lower precision on mobile.
- Re-measure after every change.
See the cost of loops for yourself
The fractal noise lesson adds octave after octave, and you can watch how the GPU time rises with each one. The ray-marching lessons, starting with sphere tracing, show why smart step sizes matter: taking big steps when you are far from a surface is the difference between a smooth frame rate and a slideshow.
Keep reading
- What is a fragment shader? A plain explanation with code
- HLSL vs GLSL vs WGSL vs Metal: shader languages compared
- How to learn shader programming: a step-by-step roadmap
- What is ray marching? Distance fields explained simply
- Noise in shaders: value noise, fBm and what they are for
- How to make an ocean shader: waves, Fresnel and foam
- Why is my circle an oval? UV coordinates and aspect ratio in shaders
- step, lerp and smoothstep: the three functions behind most shader effects
- Signed distance functions explained: drawing shapes with math
- Porting a Shadertoy shader to Unity (GLSL to HLSL): a conversion guide
- How to debug a shader when you cannot print anything