In Chapter 7, we explored the power of built-in materials like MeshBasicMaterial, MeshLambertMaterial, and MeshPhongMaterial. These provide a solid foundation for most 3D scenes. However, for truly unique visual effects, stunning realism, or creative artistic styles, we need to venture into the realm of custom materials. This is where ShaderMaterial in Three.js shines.
Shaders are small programs that run on your graphics processing unit (GPU). They are responsible for drawing everything you see on your screen, from the color of a pixel to how light interacts with surfaces. Three.js provides ShaderMaterial as a way to write and use your own custom shaders, giving you granular control over the rendering process. This opens up a universe of possibilities for creating dynamic, expressive, and breathtaking visuals.
At its core, a ShaderMaterial requires two essential pieces of code: a vertex shader and a fragment shader.
graph TD; A[Three.js Application] --> B(ShaderMaterial); B --> C(Vertex Shader); B --> D(Fragment Shader); C --> E(GPU); D --> E(GPU); E --> F(Screen Output);
The vertex shader runs once for each vertex of your 3D model. Its primary job is to transform the 3D coordinates of these vertices into 2D screen coordinates, determining where each point of the model will be rendered. It can also pass data, like colors or texture coordinates, to the fragment shader.
The fragment shader (also known as the pixel shader) runs for every pixel that a primitive (like a triangle) covers on the screen. It's responsible for determining the final color of that pixel. This is where you'll implement complex lighting models, procedural textures, and all sorts of visual effects.
Let's look at a basic example of how to create a ShaderMaterial. The ShaderMaterial constructor takes an object with properties like vertexShader and fragmentShader.
const material = new THREE.ShaderMaterial({
vertexShader: `
void main() {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
void main() {
gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0); // Red color
}
`
});In this minimal example:
- The
vertexShaderis incredibly simple. It takes thepositionattribute (the vertex coordinates) and transforms it using the standardprojectionMatrixandmodelViewMatrixto get the finalgl_Positionon the screen. - The
fragmentShadersimply sets thegl_FragColorto a solid red. This means any geometry using this material will appear entirely red, regardless of lighting or textures.
You can then apply this custom material to any mesh, just like you would with a standard material.
const geometry = new THREE.BoxGeometry(1, 1, 1);
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);To create more dynamic and interesting shaders, you'll need to understand uniforms and attributes.
Attributes are values that are unique per vertex. For example, the vertex position, normal, and UV coordinates are all attributes. They are passed from your JavaScript code to the vertex shader. You can define your own custom attributes.
Uniforms are values that are the same for all vertices and fragments within a single draw call. Think of them as global variables for your shader. They are perfect for passing things like time, camera position, light color, or any other data that you want to influence your shader's output.
Let's enhance our red material to accept a color uniform.
const customColor = new THREE.Color(0x00ff00); // Green
const material = new THREE.ShaderMaterial({
vertexShader: `
varying vec3 vColor;
void main() {
vColor = color; // Pass color attribute to fragment shader
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
varying vec3 vColor;
void main() {
// Combine uniform color with vertex color if available
gl_FragColor = vec4(uColor * vColor, 1.0);
}
`,
uniforms: {
uColor: { value: customColor }
},
attributes: {
color: { type: 'v3', value: [] } // Placeholder for vertex color attribute
},
// Specify that this material uses vertex colors
vertexColors: true,
// You might also want to set shading to THREE.FlatShading or THREE.SmoothShading if not using custom lighting
// shading: THREE.FlatShading
});
// Assign vertex colors to the geometry
const geometry = new THREE.BoxGeometry(1, 1, 1);
const positions = geometry.attributes.position.array;
const colors = [];
for (let i = 0; i < positions.length; i += 3) {
colors.push(Math.random(), Math.random(), Math.random()); // Random color per vertex
}
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);In this updated example:
- We define a
uniform vec3 uColorin the fragment shader and provide its initial value via theuniformsobject. - We also introduce
varying vec3 vColorwhich is used to pass data from the vertex shader to the fragment shader. Here, we're passing thecolorattribute (which we'll set up on the geometry). - In the JavaScript, we create
customColorand pass it to theuColoruniform. - We also declare a
colorattribute and tell Three.js that we are usingvertexColors. - Crucially, we then manually set a
colorattribute on thegeometry. Each vertex of the box now gets a random color. The fragment shader then multiplies the uniform color with the interpolated vertex color, creating a more varied output.
Working with shaders can feel like a steep learning curve. The GLSL (OpenGL Shading Language) syntax and the concepts of vertex/fragment processing take time to grasp. However, the payoff is immense. With ShaderMaterial, you can achieve effects that are impossible with standard Three.js materials, from emissive and animated surfaces to complex post-processing filters.
Remember to consult the official GLSL documentation and Three.js shader examples to explore the vast capabilities of custom shaders. This is where you truly unlock the creative potential of 3D on the web.