Writing Shaders in Three.js

Updated June 2026
Three.js provides multiple ways to write custom shaders, from raw GLSL through ShaderMaterial to a JavaScript-native node graph via TSL. Each approach suits different needs, and understanding all of them lets you choose the right tool for each visual effect in your game.

Three.js is the most widely used WebGL framework for 3D web applications and games. Its built-in materials (MeshStandardMaterial, MeshPhongMaterial, MeshBasicMaterial) cover many common rendering needs, but custom shaders unlock effects that no built-in material can produce. The framework handles shader compilation, uniform binding, and GPU state management, letting you focus on the GLSL logic.

Set Up a ShaderMaterial

The ShaderMaterial class is Three.js's primary interface for custom shaders. You pass it three things: a vertexShader string containing GLSL source, a fragmentShader string containing GLSL source, and a uniforms object containing the values your shader needs from JavaScript.

Three.js automatically prepends declarations for common attributes and uniforms to your shader source. In the vertex shader, you get position (vec3), normal (vec3), uv (vec2), modelViewMatrix (mat4), projectionMatrix (mat4), normalMatrix (mat3), and several others without declaring them yourself. This means your vertex shader can immediately use gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); without any boilerplate declarations.

The fragment shader receives automatic uniforms like cameraPosition (vec3 in world space) and any varyings you define. You must declare the GLSL version and precision yourself for the fragment shader, or let Three.js handle it (ShaderMaterial adds a precision statement automatically in WebGL 1.0 mode).

For developers who want complete control, RawShaderMaterial skips all automatic injections. You write everything from the #version directive to attribute declarations yourself. This is useful when targeting a specific GLSL version, debugging injection conflicts, or porting shader code from other frameworks where the variable names differ from Three.js conventions.

Additional material properties on ShaderMaterial control blending, depth testing, face culling, and transparency. Setting transparent: true enables alpha blending. Setting side: THREE.DoubleSide renders both faces of each triangle. Setting depthWrite: false prevents the material from writing to the depth buffer, useful for overlay effects and particles. These properties mirror what the built-in materials offer, and they configure the GPU pipeline state around your custom shader.

Pass and Update Uniforms

The uniforms object uses a specific format where each key is the uniform name and its value is an object with a value property. A simple time uniform looks like { time: { value: 0.0 } }. A texture uniform looks like { diffuseMap: { value: someTexture } }. A color uniform can use a Three.js Color object: { tint: { value: new THREE.Color(0xff0000) } }.

Updating uniforms in the render loop is straightforward: you modify the .value property directly, and Three.js pushes the new value to the GPU before the next draw call. A time-based animation updates the uniform every frame: material.uniforms.time.value = clock.getElapsedTime();. Mouse position tracking uses material.uniforms.mouse.value.set(x, y); with a vec2 uniform.

For textures, assign a THREE.Texture object (loaded via TextureLoader) as the uniform value. The texture must have its needsUpdate flag set to true after loading, which TextureLoader handles automatically. If you update a texture's image data at runtime (for example, drawing to a canvas and using it as a texture), you must set texture.needsUpdate = true manually to trigger a GPU re-upload.

Arrays and structured data can be passed as uniform arrays. A light position array might be { lightPositions: { value: [new THREE.Vector3(1,2,3), new THREE.Vector3(-1,0,5)] } }. In the GLSL shader, you declare it as uniform vec3 lightPositions[2]; and access elements by index. Three.js handles the mapping between JavaScript objects and GLSL types, including Vector2, Vector3, Vector4, Color, Matrix3, and Matrix4.

Extend Built-in Materials with onBeforeCompile

Sometimes you want to add a custom effect to a standard material without losing its lighting, shadow, and environment map support. Three.js's onBeforeCompile callback lets you modify the shader source of any built-in material before it compiles. This is the most practical approach for adding vertex displacement, custom texture blending, or procedural detail to materials that otherwise work perfectly with the engine's rendering pipeline.

The callback receives a shader object with vertexShader and fragmentShader properties (the engine's generated GLSL source code) and a uniforms object. You can inject custom uniform declarations, replace specific shader chunks, or insert new code blocks. For example, to add wind-based vertex displacement to a MeshStandardMaterial, you would add a time uniform, find the vertex position calculation in the vertex shader source, and insert displacement code after it.

Three.js internally uses named shader chunks (like #include <begin_vertex>) that you can target for replacement. Replacing the begin_vertex chunk with custom code that modifies the transformed variable lets you displace vertices while the rest of the standard material pipeline (normals, UVs, shadows) continues to work correctly. This chunk-replacement approach is fragile across Three.js version updates (chunk names or contents may change), so it is best used for effects where the performance and visual integration benefits outweigh the maintenance cost.

The Custom Shader Material (CSM) library provides a cleaner API for this pattern. It lets you write only the custom portions of a shader (your vertex displacement logic, your custom fragment effects) and merges them with the standard material's code automatically. CSM handles the chunk injection and uniform registration behind the scenes, reducing the boilerplate and version-sensitivity of raw onBeforeCompile usage.

Use TSL for Cross-Backend Shaders

Three Shader Language (TSL) is Three.js's JavaScript-native shader authoring system that compiles to GLSL for the WebGL backend and WGSL for the WebGPU backend from a single source. Instead of writing shader code as strings, you express shader logic using JavaScript function calls and operators that TSL translates into the appropriate low-level language at compile time.

TSL uses a node-based architecture where each operation creates a node in a graph. Nodes for texture sampling, math operations, UV coordinates, time values, and lighting calculations connect together to form the complete shader. The syntax looks like JavaScript: const color = texture(diffuseMap, uv()).mul(vec3(1.0, 0.5, 0.5)) creates a tinted texture sample. The resulting node graph is compiled into optimized GLSL or WGSL depending on the active renderer.

The primary advantage of TSL is portability. WebGPU uses WGSL, a completely different shader language from GLSL. Without TSL, supporting both WebGL and WebGPU would require maintaining two separate shader codebases. TSL eliminates this duplication. As WebGPU adoption grows and Three.js moves toward WebGPU as its primary backend, TSL becomes increasingly important for future-proofing shader code.

TSL also enables runtime shader composition. Because shader logic is expressed as a JavaScript graph rather than a static string, you can conditionally include or exclude features, swap texture inputs, and modify shader behavior programmatically. This is useful for LOD (level of detail) systems that simplify materials for distant objects, or for settings menus that let players toggle visual effects.

Classic ShaderMaterial with raw GLSL remains fully supported and is the right choice when you are porting existing shaders, need fine-grained control over the exact GPU instructions, or are working on a WebGL-only project with no plans for WebGPU migration. TSL is recommended for new projects that want cross-backend compatibility or prefer a JavaScript-integrated workflow.

Debug and Optimize Three.js Shaders

The renderer.info object provides real-time statistics about the rendering pipeline. It reports the number of draw calls, triangles rendered, textures in memory, and active shader programs. Monitoring these values helps you identify when shader changes affect the rendering budget. A sudden increase in draw calls after adding a custom material might indicate that the material cannot be batched with other objects, which is worth investigating.

Shader compilation errors appear in the browser console with line numbers referencing the compiled GLSL source (including Three.js's injected code, not just your custom code). When debugging, it helps to log the full shader source by accessing material.vertexShader and material.fragmentShader after compilation, so you can see the complete code with all injected declarations and map error line numbers accurately.

Spector.js is a browser extension that captures WebGL call sequences and lets you inspect shader programs, textures, framebuffers, and render state for each draw call. It is the most detailed debugging tool available for WebGL shader development. You can see exactly what uniforms and textures are bound when your shader runs, verify that your custom shader is being used (not a fallback), and compare the visual output at each stage of the rendering pipeline.

Performance optimization for Three.js shaders follows the same principles as general GLSL optimization: minimize texture samples, avoid unnecessary branching, use lower precision where possible, and move per-pixel calculations to the vertex shader when interpolation artifacts are acceptable. Three.js-specific optimizations include ensuring that objects with identical materials are grouped for batching, using InstancedMesh for repeated geometry with per-instance shader parameters, and disabling features (shadows, fog, environment maps) on materials that do not need them.

Key Takeaway

Three.js gives you multiple entry points for custom shaders: ShaderMaterial for full GLSL control, onBeforeCompile for extending built-in materials, and TSL for cross-backend JavaScript-native shader authoring. Choosing the right approach depends on whether you prioritize raw control, integration with the standard pipeline, or future WebGPU compatibility.