Listen up. If you're building modern applications, understanding Shaders in Three.js 3D WebGL is non-negotiable. This is where simple logic turns into intelligent behavior.
1Shaders: Programming the GPU Directly
Welcome to the most advanced topic in WebGL: Shaders. If you master shaders, you unlock infinite graphical power β everything Three.js's built-in materials do (MeshStandardMaterial, MeshBasicMaterial) is itself implemented as a shader under the hood.
Writing custom shaders means stepping below the abstraction Three.js normally provides, giving you full control over exactly how each vertex is positioned and each pixel is colored, at the cost of needing to handle the low-level math yourself.
// π§ββοΈ Welcome to the dark arts3D Scene rendered. Objects: 4, Draw Calls: Optimized.
2Why GLSL Runs So Fast
Shaders are small programs written in GLSL (OpenGL Shading Language) that run directly on the GPU. They are insanely fast because they run in parallel β a GPU executes the same shader program simultaneously across thousands of vertices or pixels at once, unlike a CPU processing them one at a time.
This massively parallel execution model is exactly why GPUs excel at graphics: a single fragment shader might run millions of times per frame (once per pixel), and the GPU's architecture is purpose-built to handle that scale efficiently.
// GLSL looks a lot like C++
void main() {
gl_Position = vec4(1.0);
}3D Scene rendered. Objects: 4, Draw Calls: Optimized.
3GLSL: The Shading Language
Shaders for WebGL/Three.js are written in GLSL β a C-like language specifically designed for graphics programming, with built-in types for vectors (vec2, vec3, vec4) and matrices that make 3D math operations concise.
GLSL code is passed to Three.js as plain strings (often written in template literals), which get compiled by the browser's WebGL implementation at runtime β a syntax error in your GLSL string won't be caught by TypeScript, only surfaced as a WebGL compilation error in the browser console.
const shaderCode = `... ??? code ...`;3D Scene rendered. Objects: 4, Draw Calls: Optimized.
4The Two-Stage Shader Pipeline
There are two types of shaders that work together: The Vertex Shader (which positions every vertex) and the Fragment Shader (which colors every pixel) β every custom-shaded object needs both, since one handles shape and the other handles appearance.
The vertex shader runs first, once per vertex, transforming 3D positions into screen space. The fragment shader then runs once per pixel covered by the resulting triangles, deciding the final color. This two-stage pipeline mirrors the geometry/material split you learned earlier β vertex shaders relate to shape, fragment shaders relate to surface appearance.
// 1. Vertex Shader (Shape)
// 2. Fragment Shader (Color)3D Scene rendered. Objects: 4, Draw Calls: Optimized.
5Wiring GLSL into Three.js with ShaderMaterial
To use shaders in Three.js, you use ShaderMaterial. You pass your GLSL code as strings to the vertexShader and fragmentShader properties β this is the bridge connecting your custom GLSL programs to the normal Three.js mesh/material system.
Once wrapped in a ShaderMaterial, your custom shader behaves like any other material: attach it to a mesh's material slot, and it participates in the render pipeline exactly like MeshStandardMaterial or MeshBasicMaterial would, just with fully custom rendering logic underneath.
const material = new THREE.ShaderMaterial({
vertexShader: `...`,
fragmentShader: `...`
});3D Scene rendered. Objects: 4, Draw Calls: Optimized.
6Anatomy of a Basic Fragment Shader
Let's look at a basic Fragment Shader. gl_FragColor is a built-in variable that expects a vec4 (Red, Green, Blue, Alpha) β every fragment shader's entire job boils down to assigning a value to this one variable for each pixel.
Color channels in GLSL run from 0.0 to 1.0 rather than the 0-255 range used in CSS β vec4(1.0, 0.0, 0.0, 1.0) is pure red at full opacity, which is why converting familiar hex colors to GLSL requires dividing each channel by 255 first.
const frag = `
void main() {
// Red color! (R=1, G=0, B=0, Alpha=1)
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`;3D Scene rendered. Objects: 4, Draw Calls: Optimized.
7Uniforms: Passing Data from JS to GLSL
How do we pass data from JavaScript to GLSL? We use 'Uniforms'. Uniforms are variables that stay the same for every vertex/pixel in a single frame (like time or mouse position) β they're the only bridge for feeding dynamic JavaScript state into the otherwise self-contained GLSL program.
A uniform is declared as an object with a value property on the JavaScript side, and referenced by matching name and type inside the GLSL shader code itself β changing uniforms.u_time.value from JavaScript each frame is how you animate a shader without recompiling it.
const uniforms = {
u_time: { value: 0.0 }
};
<shaderMaterial uniforms={uniforms} />3D Scene rendered. Objects: 4, Draw Calls: Optimized.
8Uniforms vs Varyings: Knowing the Difference
The variables passed from JavaScript (CPU) to the GLSL Shaders (GPU) that remain constant across all vertices/pixels for a given frame are called Uniforms β the name reflects that the value is 'uniform' (identical) across every invocation of the shader in that frame.
This is distinct from a 'varying', which instead carries data from the vertex shader to the fragment shader and interpolates smoothly between vertices (used for passing UV coordinates in the earlier example) β uniforms don't vary per-vertex, varyings do.
const ??? = { u_color: { value: new THREE.Color('red') } };3D Scene rendered. Objects: 4, Draw Calls: Optimized.
9A Complete Animated Shader Example
Let's see a Shader in action! Below is a plane using a custom Fragment Shader. It uses a sine wave based on the UV coordinates to create a hypnotic pattern β this example ties together everything covered so far: a vertex shader passing UVs as a varying, a fragment shader using those UVs plus a u_time uniform, and useFrame updating that uniform every frame.
The vUv varying carries each pixel's UV coordinate (0-1 across the plane's surface) from the vertex shader into the fragment shader, where sin(vUv.x * 10.0 + u_time) generates a shifting wave pattern that animates smoothly as u_time increases.
import { useRef } from 'react';
import { useFrame } from '@react-three/fiber';
const ShaderPlane = () => {
const materialRef = useRef();
useFrame(({ clock }) => {
if (materialRef.current) {
materialRef.current.uniforms.u_time.value = clock.getElapsedTime();
}
});
const vertexShader = `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const fragmentShader = `
uniform float u_time;
varying vec2 vUv;
void main() {
float r = abs(sin(vUv.x * 10.0 + u_time));
float g = abs(sin(vUv.y * 10.0 + u_time));
gl_FragColor = vec4(r, g, 1.0, 1.0);
}
`;
return (
<mesh>
<planeGeometry args={[4, 4]} />
<shaderMaterial
ref={materialRef}
vertexShader={vertexShader}
fragmentShader={fragmentShader}
uniforms={{ u_time: { value: 0 } }}
/>
</mesh>
);
};
const App = () => (
<Canvas camera={{ position: [0, 0, 3] }}>
<ShaderPlane />
</Canvas>
);
render(<App />);3D Scene rendered. Objects: 4, Draw Calls: Optimized.
10Driving Shader Animation with useFrame
Notice the useFrame hook? We are constantly updating the u_time uniform with the clock's elapsed time. The GPU then uses that time to animate the sine wave instantly β this is the standard pattern for any time-based shader animation in React Three Fiber.
Accessing materialRef.current.uniforms directly (rather than through React state) matters for performance here: updating a uniform value doesn't trigger a React re-render, letting the animation run at full frame rate without the overhead of React's reconciliation on every frame.
useFrame(({ clock }) => {
matRef.current.uniforms.u_time.value = clock.getElapsedTime();
});3D Scene rendered. Objects: 4, Draw Calls: Optimized.
11Where Shader Mastery Leads
Shaders are extremely complex, requiring strong math skills (Trigonometry, Vectors). But they are the secret to mind-blowing Awwwards websites β the fluid, generative visual effects on award-winning portfolio and product sites are almost always hand-written GLSL, not off-the-shelf materials.
Most developers don't need to write shaders from scratch for everyday work β libraries like drei and shader-collections provide pre-built effects β but understanding uniforms, varyings, and the vertex/fragment split makes it possible to customize or debug those pre-built shaders when the need arises.
// π§ββοΈ You are now a WebGL Wizard3D Scene rendered. Objects: 4, Draw Calls: Optimized.
12Step-by-Step Breakdown
Welcome to the most advanced topic in WebGL: Shaders. If you master shaders, you unlock infinite graphical power.
Shaders are small programs written in GLSL (OpenGL Shading Language) that run directly on the GPU. They are insanely fast because they run in parallel.
What language are Shaders written in for WebGL/Three.js?
- βJavaScript
- βGLSL
- βPython
There are two types of shaders that work together: The Vertex Shader (which positions every vertex) and the Fragment Shader (which colors every pixel).
To use shaders in Three.js, you use ShaderMaterial. You pass your GLSL code as strings to the vertexShader and fragmentShader properties.
Which of the two shaders is responsible for determining the final COLOR of every single pixel on the screen?
- βVertex Shader
- βFragment Shader
- βColor Shader
Let's look at a basic Fragment Shader. gl_FragColor is a built-in variable that expects a vec4 (Red, Green, Blue, Alpha).
How do we pass data from JavaScript to GLSL? We use 'Uniforms'. Uniforms are variables that stay the same for every vertex/pixel in a single frame (like time or mouse position).
What do we call variables that are passed from JavaScript (CPU) to the GLSL Shaders (GPU) that remain constant across all vertices/pixels for a given frame?
- βVaryings
- βAttributes
- βUniforms
Let's see a Shader in action! Below is a plane using a custom Fragment Shader. It uses a sine wave based on the UV coordinates to create a hypnotic pattern.
Notice the useFrame hook? We are constantly updating the u_time uniform with the clock's elapsed time. The GPU then uses that time to animate the sine wave instantly.
Shaders are extremely complex, requiring strong math skills (Trigonometry, Vectors). But they are the secret to mind-blowing Awwwards websites.
Level Up π
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Custom Shader Effects Should Never Be the Only Carrier of Meaning
A hypnotic animated shader pattern is purely decorative to assistive technology β if a shader effect signals something functionally important (like a 'processing' or 'error' state), pair it with a text or ARIA-live announcement rather than relying on the visual effect alone.
<div aria-live="polite">{status === 'processing' ? 'Processingβ¦' : ''}</div>SEO Implications
- 1
GLSL Source Code Is Invisible to Search Crawlers
Shader strings execute entirely inside the WebGL context and are never part of the rendered DOM or accessibility tree β this page's SEO value comes from explaining vertex/fragment shader concepts and uniforms in prose, not from any specific GLSL snippet.
Best Practices
Update Uniforms Directly via Refs Instead of React State
Driving per-frame uniform updates (like u_time) through React state and re-renders adds unnecessary overhead. Access materialRef.current.uniforms directly inside useFrame so animation runs at full frame rate without triggering React's reconciliation.
Keep Expensive Math Out of the Fragment Shader When Possible
Fragment shaders run once per pixel β potentially millions of times per frame β so expensive calculations there cost far more than the same calculation done once in the vertex shader (which runs only once per vertex) or precomputed on the CPU and passed as a uniform.
Frequent Bugs
A ShaderMaterial renders as a black or blank mesh with no visible GLSL compilation error surfaced in the app.
GLSL syntax errors are only reported in the browser's developer console (Sources/Console tab), not as a JavaScript exception TypeScript or React would catch β always check the browser console first when a custom shader material renders unexpectedly blank.
Real-World Examples
Animated Hero Backgrounds on Award-Winning Sites
Agency and product landing pages that feature flowing, generative gradient or noise backgrounds are almost always a single full-screen plane with a custom ShaderMaterial, animated via a u_time uniform updated in useFrame β computationally far cheaper than an equivalent video or animated GIF background.
const fragmentShader = `
uniform float u_time;
varying vec2 vUv;
void main() {
float noise = sin(vUv.x * 8.0 + u_time) * cos(vUv.y * 8.0 + u_time);
gl_FragColor = vec4(vec3(noise * 0.5 + 0.5), 1.0);
}
`;