Create Custom Materials by Extending Three.js Default Materials

Published on 31 Jan, 2025 | ~6 min read

Creating your own materials using shaders is fun... Well, until you realize there are some features you take for granted that you'll have to rewrite yourself. The most common one? Making your material interact properly with light.

An easy and clean solution is to borrow the desired features from existing materials, and this article will show you exactly how to do that.

The Problem

Let's start with the following example.

Clone the Three.js boilerplate from here (if you don't have a project set up already) and copy-paste the code below.

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Sets the color of the background.
renderer.setClearColor(0xfefefe);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);

// Sets orbit control to move the camera around.
const orbit = new OrbitControls(camera, renderer.domElement);

camera.position.set(0, -2, 300);
camera.lookAt(0, 0, 0);

const ambLight = new THREE.AmbientLight(0xa3a3a3, 0.8);
scene.add(ambLight);

const dirLight = new THREE.DirectionalLight(0xffffff);
dirLight.position.set(2, 2, 8);
scene.add(dirLight);

const geo = new THREE.IcosahedronGeometry(4, 30);
const material1 = new THREE.MeshPhysicalMaterial();
for (let i = 0; i < 500; i++) {
  const mesh = new THREE.Mesh(geo, material1);
  mesh.position.x = Math.random() * 1000 - 500;
  mesh.position.y = Math.random() * 1000 - 500;
  mesh.position.z = Math.random() * 1000 - 500;
  mesh.scale.x = mesh.scale.y = mesh.scale.z = Math.random() * 3 + 1;
  scene.add(mesh);
}

function animate() {
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(animate);

window.addEventListener('resize', function () {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

Full example.

Here, we have 500 meshes, all using MeshPhysicalMaterial, and you can clearly see how the light affects them.

500 meshes affected by light
500 meshes affected by light

Now, let's say we want to dynamically color these meshes based on their positions in the viewport.

To achieve that, we need to use a custom material.

First, we'll create an object that holds uniform variables like the resolution. In addition, we'll create an instance of ShaderMaterial.

main.js:

const uniforms = {
  u_resolution: {
    value: new THREE.Vector2(window.innerWidth, window.innerHeight),
  },
};

const material2 = new THREE.ShaderMaterial({
  uniforms,
  vertexShader: document.getElementById('vertexshader').textContent,
  fragmentShader: document.getElementById('fragmentshader').textContent,
});

const geo = new THREE.IcosahedronGeometry(4, 30);
// const material1 = new THREE.MeshPhysicalMaterial();
for (let i = 0; i < 500; i++) {
  // const mesh = new THREE.Mesh(geo, material1);
  const mesh = new THREE.Mesh(geo, material2);
  mesh.position.x = Math.random() * 1000 - 500;
  mesh.position.y = Math.random() * 1000 - 500;
  mesh.position.z = Math.random() * 1000 - 500;
  mesh.scale.x = mesh.scale.y = mesh.scale.z = Math.random() * 3 + 1;
  scene.add(mesh);
}

And of course, we'll create the vertex and fragment shaders.

index.html:

<script id="vertexshader" type="vertex">
  void main() {
    gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  }
</script>

<script id="fragmentshader" type="fragment">
  uniform vec2 u_resolution;
  void main() {
    vec2 st = gl_FragCoord.xy / u_resolution.xy;

    gl_FragColor = vec4(vec3(st.x / 0.6, st.y / 1.8, (st.x + st.y) / 1.2), 1.0);
  }
</script>
500 meshes colored based on their position in the viewport
500 meshes colored based on their position in the viewport

Full example.

And there we go—everything works fine... right?

Well, it might seem that way, but no.

As you can see, we lost an important detail: how the spheres respond to the scene's lights.

When you use ShaderMaterial in Three.js, you actually lose a ton of important information beyond just shading, like metalness, roughness, reflection, and more.

"So, how can we create custom materials while keeping those important details?"

Creating new materials while preserving the necessary properties.
Creating new materials while preserving the necessary properties.

To do that, we have three options.

The first one is to manually handle the calculations in the fragment shader.

The second option is to extend an existing material by using onBeforeCompile() to replace parts of its GLSL code.

The third option is similar to the second, but we use a library instead. And that's exactly what we're going to do.

Three Custom Shader Material

Three Custom Shader Material is a library that lets you build new materials on top of existing ones. This solution is arguably the easiest and cleanest option available.

First and foremost, we need to install the library by running this command in the terminal: npm install three-custom-shader-material.

Next, we'll import it.

import CustomShaderMaterial from 'three-custom-shader-material/vanilla';

Note: make sure to add '/vanilla' to the path if you're using standard Three.js, not React Three Fiber.

Now, we'll simply create an instance of CustomShaderMaterial and set the material we want to extend using the baseMaterial property.

const material3 = new CustomShaderMaterial({
  baseMaterial: THREE.MeshPhysicalMaterial,
});
// const mesh = new THREE.Mesh(geo, material2);
  const mesh = new THREE.Mesh(geo, material3);
Custom material built on top of MeshPhysicalMaterial
Custom material built on top of MeshPhysicalMaterial

As you can see, everything works as expected. Additionally, we can set material properties like color, maps, and all the properties that belong to MeshPhysicalMaterial.

const material3 = new CustomShaderMaterial({
  baseMaterial: THREE.MeshPhysicalMaterial,
  color: 0xffea00,
  metalness: 0.6,
  roughness: 0.4,
});
Custom material with MeshPhysicalMaterial properties
Custom material with MeshPhysicalMaterial properties

Now, let's revisit the example I tried earlier.

So, we'll set the fragment shader and pass the resolution uniform.

const material3 = new CustomShaderMaterial({
  baseMaterial: THREE.MeshPhysicalMaterial,
  color: 0xffea00,
  metalness: 0.6,
  roughness: 0.4,
  fragmentShader: document.getElementById('fragmentshader').textContent,
  uniforms: {
    u_resolution: {
      value: new THREE.Vector2(window.innerWidth, window.innerHeight),
    },
  },
});

Doing this won't change anything because we need to update something in the fragment shader.

Right now, gl_FragColor is the shader's output, which is typically how things are done.

That said, you definitely don't want to modify that variable when using this library. Instead, we'll need to work with a new set of predefined variables.

In our case, we'll use the csm_DiffuseColor variable instead. This ensures we only modify the material's color while preserving other data, such as shading calculations.

csm_DiffuseColor = vec4(vec3(st.x / 0.6, st.y / 1.8, (st.x + st.y) / 1.2), 1.0);
// gl_FragColor = vec4(vec3(st.x / 0.6, st.y / 1.8, (st.x + st.y) / 1.2), 1.0);
Custom material built on MeshPhysicalMaterial
Custom material built on MeshPhysicalMaterial

And there you have it!

Full example.

Now, let's say we want to animate the vertices of the meshes using the vertex shader.

To do that, we'll add the vertex shader to the CustomShaderMaterial instance and, of course, set the time uniform.

const material3 = new CustomShaderMaterial({
	baseMaterial: THREE.MeshPhysicalMaterial,
	color: 0xFFEA00,
	metalness: 0.6,
	roughness: 0.4,
	uniforms:{
		u_resolution: {value: new THREE.Vector2(window.innerWidth, window.innerHeight)},
		u_time: {value: 0}
	},
	vertexShader: document.getElementById('vertexshader').textContent,
	fragmentShader: document.getElementById('fragmentshader').textContent
});
const clock = new THREE.Clock();
function animate() {
  material3.uniforms.u_time.value = clock.getElapsedTime();
  renderer.render(scene, camera);
}

And again, we'll need a special variable in the vertex shader called csm_Position.

uniform float u_time;
void main() {
  vec3 newPosition = position * sin(u_time);
  csm_Position = newPosition;
  // gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

Full example.

Wrap Up

And with that, we’ve come to the end of this article.

Now, you can unleash your creativity with shaders—no more struggling with massive onBeforeCompile() code just to make your masterpieces react to light like any other material.

Bye!

Buy me a coffee

Credits and Resources

Related Content