Shaders: Balancing Quality and Cost

In real-time 3D graphics, shaders are where most of the visual magic happens. They control how geometry is transformed, how lights interact with surfaces and finally, what every pixel on the screen is colored. At the same time, shaders are also one of the easiest ways to accidentally destroy performance. Writing efficient shaders for WebGL or WebGPU means understanding what work is done where, and the effect of small choices, when they are scaled up across millions of pixels.

How do they work?

There are two core shader stages, that matter most in web-based 3D, the vertex shader and the fragment shader, also called the pixel shader. The vertex shader runs once per vertex, transforming positions from object space into clip space and preparing any per-vertex data that needs to be passed along, such as normals or texture coordinates. The fragment shader runs for every pixel that is covered by a single triangle and is responsible for computing the final image that can be seen on the screen. It does this by combining textures, lighting and material properties. Because fragment shaders execute once for each visible pixel, they usually need to run for almost every pixel every frame. This is often about 100 times more than vertex shaders. For this reason, most of the work is pushed onto the vertex stage and letting the GPU interpolate the values instead of computing heavy operations for each pixel.

Shader complexity matters, as every extra operation (at least in fragment shaders) multiplies across every single visible pixel. Per-pixel lighting calculations, multiple texture look ups and mathematical expressions add up and decrease performance. Techniques like physically based shading, soft shadows and screen-space effects can be visually impressive, but implemented incorrectly, they can easily overwhelm weaker GPUs. Thus keeping fragment shaders simple and avoiding unnecessary work is crucial to achieve high framerates on the web.

Light Baking

One of the most effective strategies to balance visual quality and performance is to implement baked lighting whenever possible. Instead of computing complex lighting setups or dynamic lights in the shader at runtime, lighting can be pre-computed into lightmaps or baked textures using special lightmap bakers. These lightmaps are then sampled in relatively simple fragment shaders, giving the appearance of detailed and realistic lighting without the cost of real-time light rendering. This approach is especially useful for static environments, where lights and geometry do not change.

Dynamic Lighting

Dynamic lighting is often used when objects or lights need to move or respond to interaction. However, every dynamic light contributes to per-pixel shading further increasing the workload of the fragment shaders. Many real-time engines impose limits on how many lights can affect a single object, or use approximations like clustered shading to keep the process simple. In web-based 3D, a baked base lighting solution and a small number of carefully chosen dynamic lights are often combined to save on performance. This still enables the users to get real time light changes through the dynamic lights for specific use cases such as moving objects or user feedback, and decrease the load on the GPU.

Branching

Another factor that can significantly affect performance is branching. Branching is the use of if/else statements inside the code. The GPU always calculates every single branch possibility and then only discards the unused results. Doing this for every single pixel can be draining on the performance even though modern hardware handles some branching pretty effectively. For example, branches based on uniforms, where every pixel makes the same choice, tend to be much cheaper than branches that are based on per-pixel values. Because of this, it is often better to replace branches with cheaper operations whenever possible. For example, instead of using a complex if tree that selects many shading modes, separate shader variants can be used. In some cases, using mathematical blends can approximate conditional behavior without loosing performance due to branching.

SOURCES

1: https://webglfundamentals.org/webgl/lessons/webgl-fundamentals.html
2: https://web.dev/articles/webgl-fundamentals
3: https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices
4: https://star.global/posts/introduction-to-webgl/

Leave a Reply

Your email address will not be published. Required fields are marked *