Skip to content
Fundamental · Debugging & performanceIntermediate

Cost & precision

What makes a shader slow, what makes it wrong, and how to read the cost line on every card.

How it works

A fragment shader runs once per pixel it covers, every frame. A full-screen effect at 1920×1080 runs about two million times a frame; at 3840×2160, more than eight million. Anything you add to it is multiplied by that number, which is why a few extra lines can cost a millisecond on a laptop and much more on a phone. This page covers the usual sources of cost, and the precision problems that make a shader wrong rather than slow.

What costs what

  • Arithmetic is the cheapest work: additions, multiplications, mix, dot, clamp. GPUs are built to do a lot of it in parallel. Functions like sin, exp, pow and sqrt cost a few times more, and are still cheap compared with memory.
  • Texture reads fetch memory. The GPU hides much of the wait by working on other pixels, but every read costs bandwidth, and many reads per pixel (a wide blur, a large kernel) is the most common reason an effect is slow. A dependent read, whose coordinates come from an earlier read or from heavy math, can’t start early, and is slower on some mobile GPUs.
  • Branches are cheap when neighboring pixels take the same side. GPUs run pixels in groups that execute in lockstep, so when the pixels of a group disagree, the group runs both sides and keeps the results it needs. An if on a uniform is effectively free; an if on noise may cost both branches.
  • Loops cost every iteration. A loop of a fixed count is easy for the compiler; a loop that runs for a value computed per pixel behaves like a divergent branch.

Pixels and passes

How many pixels you shade often matters more than how you shade them.

  • Overdraw. Each layer of transparent geometry runs its fragment shader again on the same pixels. Ten overlapping particles cost ten times one. Opaque geometry drawn front to back lets the depth test skip hidden pixels before shading, but discard and writing gl_FragDepth can disable that early test.
  • Passes. A post-processing pass reads and writes a full-screen image. Each extra pass adds that bandwidth, even if its shader is trivial. Rendering a pass at half resolution cuts its pixel count to a quarter.
  • Separable filters. A blur that weights a 9×9 neighborhood reads 81 texels per pixel. Splitting it into a horizontal pass of 9 and a vertical pass of 9 reads 18, at the price of one more pass. For anything wider than a few texels the split wins, which is why the Separable Gaussian blur uses two passes.

Measure, don’t guess

Compilers rearrange code, and GPUs differ, so reasoning about cost only gets you so far. A quick test: shrink the canvas or the render resolution. If the frame time drops with it, the fragment shader is the bottleneck. Engines have profilers (Godot’s Visual Profiler shows GPU time per stage), and WebGL 2 can time GPU work with the EXT_disjoint_timer_query_webgl2 extension where the browser exposes it.

Each card on this site has a cost line: math operations, texture reads and passes. They are counted from the effect code, not timed, so read them as a comparison between cards, not as milliseconds. Texture reads and passes weigh more than math operations.

Precision

GLSL floats come in three precisions. highp is a 32-bit float on most hardware. mediump only guarantees a relative precision of about 1 part in 1000 and a range of about ±16,000. Desktop GPUs compute everything at full precision whatever you declare, so a mediump problem often appears only on phones. Declare precision highp float; unless you have measured a reason not to; GLSL ES 3.00 requires fragment shaders to support it.

Even highp runs out. A 32-bit float has 24 bits of mantissa, so the gap between neighboring values grows with the size of the number. A time uniform counting seconds is fine for minutes, but after a day (86,400 s) the gap is about 0.008 s, and sin(time * 10.0) visibly steps. Large world coordinates have the same problem in space. Wrap time on the CPU into a range your animation repeats over, and keep coordinates near the origin.

NaN and infinity

Some operations have no real answer, and the GPU returns NaN (not a number) or infinity instead of failing:

  • sqrt(x) and log(x) of a negative number, pow(x, y) with a negative x
  • division by zero, including normalize of a zero vector
  • asin and acos outside -1..1

NaN spreads through every operation it touches. In a single pixel it shows as a black or white speck; feed that pixel into a blur or a bloom and it grows into a block. Clamp inputs where they can go out of range, for example sqrt(max(x, 0.0)) and pow(max(x, 0.0), k), and add a small epsilon before dividing by something that can reach zero. To find where one starts, paint it a visible color (see output values as color).

Further reading