Custom Shaders in Three.js
How Shaders Work in Three.js
The GPU renders each frame by running two programs for every object: a vertex shader and a fragment shader. The vertex shader processes each vertex of the geometry, transforming it from model space to screen space and passing data to the fragment shader. The fragment shader runs once per pixel (fragment) covered by the geometry and outputs the final color for that pixel. Together, these two programs control the complete visual appearance of every surface in the scene.
Three.js's built-in materials (MeshStandardMaterial, MeshPhysicalMaterial, MeshBasicMaterial) are pre-written shader programs that handle common rendering scenarios. When you set properties like color, roughness, and metalness, Three.js injects those values as uniforms into its shader templates. Custom shaders replace or extend these templates, giving you direct control over the vertex and fragment programs.
Shaders in Three.js (when using the WebGL backend) are written in GLSL (OpenGL Shading Language), a C-like language that runs natively on the GPU. With the WebGPU backend, Three.js translates GLSL to WGSL (WebGPU Shading Language) automatically in most cases, or you can use the TSL (Three Shading Language) node-based system that targets both backends. For maximum compatibility today, writing GLSL with the WebGL backend remains the most documented and supported approach.
ShaderMaterial vs RawShaderMaterial
ShaderMaterial provides Three.js's built-in uniforms and attributes automatically. Your shader receives the model, view, and projection matrices, lighting data, fog parameters, and other standard values without you having to declare them. This is convenient because you can access things like camera position, time, and resolution immediately, and your shader participates in Three.js's lighting system if you choose to use it.
RawShaderMaterial gives you a completely blank slate. You must declare every uniform, attribute, and varying yourself, including the projection and modelview matrices. This is useful when you want full control or are porting a shader from another platform and do not want Three.js's automatic declarations interfering with your code. RawShaderMaterial is also cleaner for effects that do not need Three.js's lighting, fog, or other built-in features.
For most game effects, ShaderMaterial is the better starting point. You get Three.js's standard vertex transformation for free, and you can focus on the fragment shader where the visual effect happens. Switch to RawShaderMaterial when you need complete control over the vertex shader or when Three.js's injected code causes conflicts.
Uniforms: Passing Data to the GPU
Uniforms are values you pass from JavaScript to the shader each frame. They are constant across all vertices and fragments in a single draw call. Common uniforms for game effects include time (for animation), color values, texture samplers, player position, and effect parameters like intensity or threshold values.
Define uniforms as an object when creating the material, with each entry specifying a type and value. Update them in the game loop by setting the .value property. For example, a dissolve effect might have a uniform for the dissolve threshold that increases over time, progressively hiding more of the surface. A shield effect might pass the world-space position of an impact point and the time since impact, allowing the shader to animate a ripple outward from the hit location.
Textures are uniforms of type sampler2D. Pass a Three.js Texture object as the value, and sample it in the shader using texture2D(sampler, uv). Noise textures are particularly useful for game effects: they drive dissolve patterns, distortion effects, terrain blending, and pseudo-random variation. A single noise texture can power dozens of different effects depending on how the shader interprets its values.
Common Game Shader Effects
Dissolve effect: Compare a noise texture value at each fragment's UV coordinate against a threshold uniform. When the noise value is below the threshold, discard the fragment with discard. Increase the threshold over time and the object appears to burn away. Add an emissive glow at the dissolve edge by coloring fragments where the noise value is just above the threshold with a bright color.
Water surface: Animate the vertex positions with sine waves of different frequencies and amplitudes to create surface motion. In the fragment shader, calculate a Fresnel effect (more reflective at glancing angles) and blend between a water color and an environment reflection. Add normal map perturbation for small-scale ripples, and sample a scene render target below the water surface for refraction. Water is one of the most complex effects but produces dramatic visual results.
Energy shield: Render a sphere or fitted mesh around the object with a transparent material that uses a Fresnel effect for the base glow (brighter at edges). On impact, pass the hit position and time as uniforms. The shader calculates the distance from each fragment to the hit point and draws a ring pattern that expands over time, creating the ripple effect seen in sci-fi games.
Toon shading: Quantize the lighting calculation into discrete steps rather than using smooth gradients. Calculate the dot product between the surface normal and light direction (the standard diffuse term), then map it to 2-4 discrete values using step functions. This produces the flat-shaded look of cel-shaded games. Add an outline pass using either inverted hull expansion (render back faces scaled outward with a black material) or a post-processing edge detection filter.
Hologram effect: Use a vertical scanline pattern (based on world-space Y position and time) combined with a Fresnel glow and transparency. Add random glitch offsets by occasionally shifting UV coordinates based on a noise function and time. The result is the flickering, translucent hologram projection seen in science fiction interfaces.
Post-Processing Shaders
Post-processing shaders operate on the rendered frame as a 2D image, applying screen-wide effects. Three.js provides an EffectComposer that chains render passes together. Each pass renders a fullscreen quad with a shader that reads the previous pass's output as a texture and writes its transformed result.
Built-in passes include bloom (bright areas bleed light), SSAO (screen-space ambient occlusion adds shadows in crevices), depth of field (distance-based blur), chromatic aberration (color channel offset), and film grain. These passes stack: render the scene, then apply SSAO, then bloom, then color grading, then output to screen.
Custom post-processing passes follow the same pattern. Write a fragment shader that reads the input texture, applies your effect, and outputs the result. You can create effects like screen-space reflections, motion blur, heat distortion, rain on camera, and CRT monitor simulation. Each effect is a ShaderPass added to the EffectComposer chain.
Performance is a key consideration for post-processing. Each pass renders a fullscreen quad, which means the fragment shader runs for every pixel on screen. At 1920x1080, that is over 2 million fragment shader invocations per pass. Complex passes or long chains can exceed the GPU budget, especially on mobile. Profile each pass individually and provide quality settings that let players disable expensive effects.
Shader Development Workflow
Developing shaders is iterative. Use lil-gui or a similar tool to expose uniform values as sliders and color pickers in your running game, so you can tweak parameters in real time without reloading. Three.js's hot module replacement (via Vite) reloads shader files on save, providing rapid feedback.
Debug shaders by outputting intermediate values as colors. If you want to see the noise texture values, set the fragment color to vec4(noiseValue, noiseValue, noiseValue, 1.0) temporarily. If you want to visualize normals, output them as RGB. GPU debuggers like Spector.js capture individual draw calls and let you inspect shader inputs, outputs, and uniform values for each object in the scene.
The Three.js examples repository contains dozens of shader implementations that serve as starting points. Study the water, fire, sky, and fresnel examples to understand common patterns. The Shadertoy community provides thousands of fragment shader experiments that can be adapted for Three.js with minor modifications (mainly changing texture sampling functions and uniform names).
Custom shaders give you direct control over the GPU, enabling visual effects that no built-in material can produce. Start with ShaderMaterial for access to Three.js's standard uniforms, use noise textures and time-based animation for dynamic effects, and profile post-processing chains to maintain frame rate on target hardware.