Game Shaders and Visual Effects
In This Guide
What Shaders Do in Games
Every frame of a modern game passes through shader programs before anything appears on screen. When a game engine submits geometry to the GPU, shaders determine where each triangle ends up on screen, what color each pixel receives, and how lighting, shadows, and textures combine into the final image. Without shaders, the GPU would have no instructions for turning raw mesh data into the visuals players actually see.
Early 3D graphics hardware used a fixed-function pipeline where developers could only toggle predefined rendering options. You could enable fog, choose a blending mode, or set a light direction, but you could not write your own rendering logic. The introduction of programmable shaders in the early 2000s changed everything. Developers could now write arbitrary programs that ran on each vertex and each pixel, unlocking effects that the fixed pipeline never anticipated.
In web game development, shaders matter just as much as they do in native engines. WebGL exposes the same programmable pipeline that desktop OpenGL provides, and the newer WebGPU API extends that capability with compute shaders and modern GPU features. Engines like Three.js and Babylon.js handle the boilerplate of compiling and linking shader programs, but they still let developers write custom GLSL (or WGSL for WebGPU) when the built-in materials are not enough.
The practical impact is significant. A water surface that looks flat and lifeless with a basic material can become a convincing ocean with a custom vertex shader that displaces the mesh over time and a fragment shader that blends reflections, refractions, and foam. A character model that looks like plastic under default lighting can gain depth and personality with a cel shader that quantizes light into discrete bands. These are not cosmetic luxuries. In competitive markets, visual quality directly affects player retention, and shaders are the tool that delivers it.
Shaders also extend beyond visuals. Compute shaders can run physics simulations, particle updates, or pathfinding calculations entirely on the GPU. While WebGL does not natively support compute shaders, developers can use transform feedback or render-to-texture tricks to offload parallel workloads. WebGPU brings first-class compute shader support to the browser, opening new possibilities for GPU-accelerated game logic.
How the GPU Rendering Pipeline Works
Understanding the rendering pipeline is essential before writing shader code, because each shader stage has a specific role and operates on specific data. The pipeline processes geometry in a series of stages, each transforming the data before passing it to the next.
The pipeline begins with vertex input, where the CPU sends vertex data to the GPU. Each vertex typically carries a position (a vec3 in 3D space), a normal vector (the direction the surface faces at that point), texture coordinates (UV values that map the surface to a 2D image), and possibly additional attributes like vertex colors, tangent vectors, or bone weights for skeletal animation. This data is stored in GPU buffers that the shader reads during execution.
The vertex shader runs once per vertex. Its primary job is to transform the vertex position from object space (local to the mesh) into clip space (the coordinate system the GPU uses for rasterization). This transformation typically involves multiplying the position by a model matrix (object to world), a view matrix (world to camera), and a projection matrix (camera to clip). The vertex shader can also calculate per-vertex lighting, pass interpolated values to later stages, or animate vertices procedurally by modifying their positions based on time or other uniforms.
After the vertex shader, the GPU performs primitive assembly and rasterization. It groups vertices into triangles (or lines, or points), clips any geometry that falls outside the view frustum, and then determines which screen pixels each triangle covers. For each covered pixel, the rasterizer interpolates the output values from the three vertices of that triangle, producing smooth gradients of normals, UVs, colors, and any other data the vertex shader passed along.
The fragment shader (also called the pixel shader in DirectX terminology) runs once per fragment, where a fragment is a candidate pixel generated by rasterization. It receives the interpolated values from the vertex shader and uses them to compute the final color of that pixel. This is where texture sampling, lighting calculations, normal mapping, and most visual effects happen. The fragment shader outputs a color (and optionally a depth value), which then passes through blending and depth testing before being written to the framebuffer.
The final stages involve depth testing (discarding fragments that are behind already-rendered geometry), stencil testing (masking regions of the screen), and blending (combining the new fragment color with whatever is already in the framebuffer, used for transparency and additive effects). The result is a completed frame in the framebuffer, which the display then shows to the player.
In WebGL, you only write the vertex and fragment shaders. The rasterization, depth testing, and blending stages are configured through API calls but are not programmable. WebGPU adds compute shaders as a separate pipeline that does not follow this geometry-based flow, instead running arbitrary parallel computations on GPU data.
Vertex and Fragment Shaders
Vertex shaders and fragment shaders are the two programmable stages that web game developers work with directly. Each has a distinct purpose, specific inputs and outputs, and different performance characteristics.
A vertex shader processes one vertex at a time. It receives attribute data (position, normal, UV) and uniform data (matrices, time values, global parameters shared across all vertices). Its mandatory output is gl_Position, a vec4 in clip space that tells the GPU where this vertex should appear on screen. Beyond that, the vertex shader can declare varying outputs (called "out" variables in GLSL ES 3.0) that carry data to the fragment shader. Common varyings include the transformed world-space position, the surface normal in world space, and the texture coordinates.
Vertex shaders run far fewer times than fragment shaders because a scene typically has thousands or tens of thousands of vertices, while each triangle might cover hundreds or thousands of pixels. This makes vertex shaders a good place for expensive calculations that can be interpolated across the triangle surface without visible artifacts. Per-vertex lighting (Gouraud shading) is one example, though per-pixel lighting (Phong shading in the fragment shader) produces smoother results at higher cost.
Procedural animation is another powerful use of vertex shaders. Grass swaying in the wind, ocean waves rippling across a surface, and flag cloth fluttering can all be achieved by displacing vertex positions in the vertex shader based on sine waves, noise functions, and time uniforms. This approach is vastly more efficient than updating vertex buffers from JavaScript every frame, since the GPU handles the math in parallel across all vertices simultaneously.
A fragment shader processes one fragment at a time, receiving the interpolated varyings from the vertex shader. It outputs a color, typically as a vec4 (red, green, blue, alpha). The fragment shader is where the bulk of visual complexity lives. Texture sampling, lighting equations, shadow mapping, normal mapping, environment reflections, and color grading all happen here.
Because fragment shaders run once per pixel per triangle, they dominate GPU workload in most scenes. A 1920x1080 screen has over two million pixels, and with overdraw (multiple triangles covering the same pixel), the fragment shader might execute tens of millions of times per frame. This is why fragment shader optimization matters so much, and why moving calculations from the fragment shader to the vertex shader (when interpolation artifacts are acceptable) is a common optimization technique.
Communication between the two shader stages flows in one direction. The vertex shader writes to varying outputs, the rasterizer interpolates them, and the fragment shader reads them as inputs. Uniforms are shared across both stages, meaning a time value or camera position set once by JavaScript is accessible in both the vertex and fragment shaders without any per-vertex or per-pixel cost.
GLSL for Web Game Development
GLSL (OpenGL Shading Language) is the shader language used by WebGL. The version available in WebGL 1.0 is GLSL ES 1.0, based on OpenGL ES 2.0, while WebGL 2.0 uses GLSL ES 3.0 with significant improvements. Most modern browsers support WebGL 2.0, making GLSL ES 3.0 the practical target for new web game projects.
GLSL is a C-like language with specialized types for graphics programming. The fundamental types include float, int, and bool, along with vector types (vec2, vec3, vec4 for floats, ivec2/3/4 for integers) and matrix types (mat2, mat3, mat4). Vector swizzling lets you access and rearrange components freely: color.rgb, position.xz, normal.yyy are all valid expressions. This compact syntax makes common graphics math readable and concise.
Texture sampling uses the sampler2D type (and samplerCube for cubemaps). You pass a texture as a uniform and sample it with the texture() function (or texture2D() in GLSL ES 1.0), providing UV coordinates to look up the color at that point on the texture. Textures are not limited to color data. They can store normal maps, height maps, noise patterns, lookup tables, or any other 2D data that the shader needs.
GLSL provides a rich set of built-in functions optimized for GPU execution. mix(a, b, t) performs linear interpolation between two values. clamp(x, min, max) restricts a value to a range. smoothstep(edge0, edge1, x) produces a smooth Hermite interpolation, invaluable for soft transitions. dot(a, b) computes the dot product, which is the foundation of lighting calculations. normalize(v) returns a unit-length vector. reflect(I, N) computes the reflection direction for environment mapping. These functions are hardware-accelerated and should always be preferred over manual implementations.
Variables in GLSL are qualified by their role. In GLSL ES 3.0, in marks inputs to a shader stage (attributes in the vertex shader, varyings in the fragment shader). out marks outputs (varyings from the vertex shader, color output from the fragment shader). uniform marks values set from JavaScript that remain constant across an entire draw call. Understanding these qualifiers is critical because they define how data flows through the pipeline and where it can be modified.
Precision qualifiers (lowp, mediump, highp) control the numerical precision of variables and affect both accuracy and performance. Mobile GPUs in particular benefit from lower precision where full float accuracy is unnecessary. Color values, for example, rarely need highp since they only span the 0 to 1 range. Position calculations, on the other hand, typically require highp to avoid visible jittering artifacts. GLSL ES requires you to declare a default precision for floats in the fragment shader, and choosing mediump as the default while selectively using highp where needed is a common mobile optimization strategy.
Shaders in Three.js and Babylon.js
Writing raw WebGL shader code involves significant boilerplate: creating shader objects, compiling source strings, linking programs, querying attribute and uniform locations, and binding buffers. Game engines like Three.js and Babylon.js abstract all of this away, letting developers focus on the shader logic itself while the engine handles program management, uniform updates, and pipeline state.
In Three.js, the primary tool for custom shaders is ShaderMaterial. You provide a vertex shader string, a fragment shader string, and a uniforms object. Three.js automatically injects declarations for common attributes (position, normal, uv) and uniforms (modelViewMatrix, projectionMatrix, cameraPosition), so your shader code can reference these without declaring them. The uniforms object uses a specific format where each uniform has a value property that you update from JavaScript, and Three.js pushes the values to the GPU before each draw call.
Three.js also provides RawShaderMaterial for developers who want full control. Unlike ShaderMaterial, RawShaderMaterial does not inject any automatic declarations, so you must declare all attributes, uniforms, and version directives yourself. This is useful when you need to target a specific GLSL version or avoid conflicts with the engine's injected code.
As of 2026, Three.js has introduced TSL (Three Shader Language), a JavaScript-native node graph system that compiles to GLSL for WebGL and WGSL for WebGPU from a single source. TSL lets developers define shader logic using JavaScript function calls and operators rather than writing raw shader strings, with the framework generating the correct low-level code for whichever backend is active. This approach is particularly valuable for projects that want to support both WebGL and WebGPU without maintaining two separate shader codebases. Classic ShaderMaterial remains fully supported for developers who prefer writing GLSL directly or who are porting existing shader code.
In Babylon.js, custom shaders use the ShaderMaterial class, which works similarly to Three.js but with different API conventions. You provide shader source code (either as inline strings, as references to DOM script elements, or as file paths), specify which attributes and uniforms your shader expects, and Babylon.js handles compilation, linking, and state management. The engine provides its own set of automatic uniforms for world, view, and projection matrices.
Babylon.js also offers NodeMaterial, a powerful visual shader editor that lets you build complex materials without writing any GLSL. The Node Material Editor (NME) is a browser-based tool where you connect processing blocks visually, creating a node graph that represents shader logic. Each block performs a specific operation (texture sample, math operation, lighting calculation), and connections between blocks define data flow. The resulting material can be exported as JSON and loaded at runtime. NodeMaterial is especially effective for artists and designers who need to iterate on visual effects without modifying code.
Both engines support injecting custom shader chunks into their standard materials. Three.js lets you override specific shader chunks using onBeforeCompile, modifying portions of the built-in Phong, Lambert, or Standard material shaders while keeping the rest of the engine's lighting and shadow code intact. Babylon.js offers a similar capability through its plugin system and custom material extensions. This approach is ideal when you want to add a single custom effect (like vertex displacement or a special texture blend) without rewriting the entire material from scratch.
Common Visual Effects Built with Shaders
Shaders enable a wide range of visual effects that would be impossible or impractical to achieve through geometry or texture manipulation alone. Understanding the techniques behind common effects helps developers implement them efficiently and adapt them to their specific needs.
Water and ocean surfaces are among the most visually impressive shader effects. The vertex shader displaces a flat plane using layered sine waves or Gerstner waves, creating the appearance of rolling swells and choppy surface detail. The fragment shader combines Fresnel reflections (the surface reflects more at grazing angles), refraction through a distorted view of the scene below, foam generation at wave peaks, and animated normal maps for fine surface detail. Achieving convincing water requires balancing these components carefully, as each contributes to the overall realism.
Normal mapping adds surface detail without increasing polygon count. A normal map is a texture where each pixel encodes a surface direction rather than a color. The fragment shader reads the normal map, transforms the encoded normal into world space using a TBN (tangent, bitangent, normal) matrix, and uses this modified normal for lighting calculations. The result is that a flat surface appears to have bumps, grooves, scratches, or fabric texture, with lighting responding correctly to the apparent detail. This technique is essential for web games where polygon budgets are tight.
Cel shading (or toon shading) creates a stylized, cartoon-like appearance by quantizing the lighting calculation into discrete bands. Instead of smooth diffuse shading, the dot product between the surface normal and light direction is passed through a step function that snaps values to two or three distinct levels. The result is hard-edged shadow boundaries reminiscent of hand-drawn animation. Some implementations add an outline pass using either a separate render with inverted face culling and scaled vertices, or a screen-space edge detection filter.
Dissolve effects make objects appear to burn away or disintegrate. The fragment shader samples a noise texture and compares each pixel's noise value against a threshold uniform that increases over time. Pixels where the noise exceeds the threshold are discarded using the discard keyword. Adding an emissive edge glow at the dissolve boundary (where the noise value is close to the threshold) creates the appearance of burning edges. This technique is popular for death animations, teleportation effects, and scene transitions.
Parallax mapping extends normal mapping by creating an illusion of depth. The fragment shader offsets the texture coordinates based on the viewing angle and a height map, making raised areas appear to shift position as the camera moves. Steep parallax mapping and parallax occlusion mapping (POM) use ray marching through the height field for more accurate results, though at higher computational cost. These techniques can make brick walls, cobblestone paths, and rocky terrain look genuinely three-dimensional without adding any geometry.
Emissive and glow effects make surfaces appear to emit light. The simplest approach adds an emissive color to the fragment output regardless of lighting, but this alone does not create the bloom halo that real glowing objects produce. True glow requires a post-processing step where bright pixels are isolated, blurred, and composited back onto the scene. The combination of an emissive material and a bloom post-process creates convincing neon signs, magic effects, lava, and UI highlights.
Post-Processing and Screen-Space Effects
Post-processing effects operate on the fully rendered scene image rather than on individual objects. The scene is first rendered to a framebuffer texture (called a render target), and then one or more full-screen quad passes apply shader effects to that texture before displaying the final result. This approach enables effects that require knowledge of the entire scene, not just a single surface.
Bloom is the most common post-processing effect in games. It simulates the way bright light bleeds into surrounding areas in a camera lens. The implementation extracts pixels brighter than a threshold, applies a multi-pass Gaussian blur (often using progressive downsampling for efficiency), and adds the blurred result back onto the original image. The threshold, intensity, and blur radius control how dramatic the bloom appears. Subtle bloom adds cinematic polish, while heavy bloom creates a dreamy or overexposed aesthetic.
Color grading transforms the colors of the entire scene to establish mood and visual identity. This can range from simple brightness, contrast, and saturation adjustments to full lookup table (LUT) transforms where a 3D color cube remaps every input color to an output color. LUT-based grading is powerful because it can replicate any color transformation that a film colorist might apply, and the lookup is a single texture sample per pixel, making it extremely fast.
Screen-space ambient occlusion (SSAO) approximates how ambient light is blocked in crevices, corners, and areas where surfaces are close together. The shader samples the depth buffer at multiple points around each pixel, comparing depth values to estimate how occluded that point is. Occluded areas receive darker ambient lighting, adding subtle depth and grounding that makes scenes look more physically plausible. SSAO is an approximation (true ambient occlusion requires global illumination), but it is effective and widely used because it works with any geometry without precomputation.
Depth of field simulates camera lens focus by blurring areas of the scene that are far from a focal plane. The shader reads the depth buffer to determine each pixel's distance from the camera, compares it to the focus distance, and applies blur proportional to how far the pixel is from the focal plane. Circle-of-confusion calculations control the blur radius, and a high-quality implementation uses a bokeh-shaped blur kernel rather than a simple Gaussian to replicate the characteristic shapes of out-of-focus highlights.
Motion blur conveys speed and smooth motion by stretching pixels in the direction of movement. Per-object motion blur uses velocity buffers that store each pixel's screen-space velocity (computed from the current and previous frame's transformation matrices). The fragment shader samples along the velocity vector, averaging colors to produce a directional streak. Camera motion blur is simpler, deriving velocity from the camera's movement between frames, but it blurs the entire scene uniformly rather than reflecting per-object speeds.
In Three.js, post-processing is managed through the EffectComposer class, which chains together render passes. Each pass applies a shader to the previous pass's output, building up the final image through sequential processing. The RenderPass renders the scene, UnrealBloomPass adds bloom, ShaderPass applies custom shaders, and so on. Babylon.js uses a PostProcess class that attaches directly to cameras, with a rich library of built-in effects and straightforward support for custom post-process shaders.
Performance and Mobile Considerations
Shader performance determines whether a game runs at a smooth 60 frames per second or stutters into unplayable territory. Because shaders execute millions of times per frame, even small inefficiencies multiply into significant frame time costs. Web games face additional constraints because they run in a browser sandbox and must support a wide range of hardware, from desktop GPUs to mobile phone chipsets.
Fragment shader cost is the primary bottleneck in most games. Every pixel covered by geometry runs the fragment shader, and with transparency, overlapping UI elements, and particle effects, many pixels run the shader multiple times per frame (a problem called overdraw). Reducing fragment shader complexity and minimizing overdraw are the two most effective optimizations for shader-heavy scenes.
Branching in shaders (if/else statements) performs differently than on CPUs. GPUs process fragments in groups (called warps or wavefronts), and all fragments in a group must execute both branches if any fragment in the group takes a different path. This means that branches where different pixels take different paths are nearly as expensive as executing both sides unconditionally. Where possible, replace branches with mathematical alternatives: mix(a, b, step(threshold, value)) selects between two values without branching, and smoothstep provides a soft transition.
Texture sampling is expensive, especially when the shader needs to read from large textures or sample the same texture many times (as blur effects do). Each texture sample involves memory lookups that can stall the GPU if the texture is not in cache. Using mipmaps reduces sampling cost for distant surfaces. Packing multiple data channels into a single texture (normal map in RGB, height in alpha) reduces the number of texture binds. Keeping textures at the smallest resolution that maintains visual quality saves both memory bandwidth and cache pressure.
Precision qualifiers matter on mobile. Mobile GPUs (Adreno, Mali, Apple GPU) execute lowp and mediump operations significantly faster than highp in many cases. Using mediump as the default float precision in fragment shaders and only promoting to highp for calculations that genuinely need it (world-space positions, depth values, large coordinate ranges) can yield measurable frame rate improvements on phones and tablets. Desktop GPUs typically process all precisions at the same speed, so this optimization is primarily a mobile concern.
Draw calls and state changes also affect shader performance indirectly. Each draw call requires the GPU to potentially switch shader programs, rebind textures, and update uniforms. Reducing the number of distinct shader programs and batching objects that share the same material minimizes this overhead. Engines like Three.js and Babylon.js perform automatic batching and sorting, but developers should be aware that using many unique ShaderMaterial instances with different shader source code can defeat these optimizations.
Profiling tools are essential for shader optimization. Chrome's DevTools Performance panel shows frame timing. The Spector.js browser extension captures WebGL calls and lets you inspect shader programs, textures, and GPU state for each draw call. Babylon.js includes a built-in inspector that displays frame statistics, draw call counts, and material details. Three.js developers can use the renderer.info object to monitor draw calls, triangles, and texture memory usage. Testing on actual mobile hardware (not just desktop with a throttled CPU) is critical because mobile GPU architectures differ fundamentally from desktop GPUs in how they handle tile-based rendering, cache hierarchies, and precision.