Extending three.js materials with GLSL

What are three.js materials?

MeshStandardMaterial extended with specular/gloss + instancing +map transformations. See demo.

Material in three.js is a high level abstraction to low level systems both WebGL and Three.js.

Three.js materials all share a common base in order to be able to work with the entire rendering system. All of them share some common properties that work with low level three.js and webgl concepts (including 2d canvas and the DOM), but each has a set of properties or some unique GLSL code that drive some properties that are specific to the material. This article is about the WebGL context.

What do materials do?

Absolutely everything! Almost all rendering operations are somehow tied to a material. The material holds some state that three’s WebGLRenderer uses to set the appropriate WebGL state (is blending turned on for example, or what kind of depth test should be used, if at all…). It also holds some GLSL code (shader), that is used to actually compute things or draw stuff on screen. While Material is the only wrapper around shaders, it’s not the only thing that interfaces with it.

For example, MeshStandardMaterial exposes a roughness parameter to the user. WebGLRenderer maps this to a low level WebGL input type (called uniform ). The material also needs the input for the camera, but this is not set through the Material interface, but by calling renderer.render(scene,camera) . ‘The renderer then provides this camera input to every material it renders.

Another example are shadows. Once a shadow pass is done, it results with a shadow map (a texture) for a specific light. The materials that are reacting to this light ( .receiveShadow == true ) need this texture as an input. While the user may have made many materials describing them as more or less rough, or more or less blue, the renderer is the only authority on which shadow map should be used, if at all.

It’s a complex system.

What do shaders do?

Computation! Lot’s of it, and in parallel. Shaders are programs written in a shader language (GLSL in case of WebGL) that run at various stages of the rendering pipeline and are executed in parallel on the GPU. WebGL shaders have two stages, vertex and fragment .

Fragment shaders also called pixel shaders, are the programs that decide what color the pixel on your screen should be. This type of program may run some composition math and combine two inputs (like two different videos), or can run some lighting calculation and shade some model to appear as if it were shiny and metal.

Vertex shaders run the math that transforms models from one 3d space to another. When you want to render some 3d model, you have to provide one set of spatial data to the gpu (preferably once), that describes that model. This means that some numbers decribe a shape of a cat, and some other numbers describe a shape of a dog. There can be tens or hundreds of thousands of points, times the dimension, yielding many numbers to be crunched. If we want to rotate the model compared to how a 3d artist modeled it, we would have to apply a transformation to each one of these numbers. This is not suited for real time graphics and GPUs help here by runing these operations in parallel. WebGL first says “describe a dog” and then says “tell me where it is, and where it’s looking at”. In this second stage, we only have to provide webgl with a few numbers (usually 16 in a form of a 4x4 matrix) and transform an arbitrary amount of points with GPU acceleration. The logic for whether some 3d object is represented through an orthographic projection, or a perspective one, happens in the vertex shader.

What are we trying to solve?

Say we have a high level problem “show a 3d car in the browser”. With three.js this is incredibly trivial and high level. If the car is properly stored in some format that describes a scene graph (like glTF , an artist can set up lights and cameras. If the format supports a compatible three.js material (or vice versa), an artist can set how shiny the car body is, or how dull the tires are.

It may be enough to do something like this:

new Loader.load( ‘someModel.someFormat’, model => scene.add(model) )

Pretty simple. A bunch of files load, a scene graph is built, meshes and materials configured and you get a beautiful shiny realistic looking car.

If you want to add shadows, you would do that on your app level. Through a very high level interface. Even though the shadows are a part of the shader system, the input is on the scene graph ( Mesh ) and WebGLRenderer itself.

model.traverse( obj => {

obj.receiveShadows = true

obj.castShadows = true

})

Perfect, the car just got more beautiful. Now you get tasked to add an interactive creative effect to the car. When you hover over the body of the car, it should deform it. Maybe the surface bulbs up towards your cursor, as if it were a really strong magnet or the body was somehow fluid. Or perhaps it’s part of an elaborate transition, when you change the color in some car configurator and click on the body, it sends a ripple out changing the color originating from that point.

The car still needs to look like a car, be shiny, cast shadows. At certain intervals, during transitions, this effect has to run though.

Let’s assume that this additional effect, just like shadowing, Standard lighting computation etc. has to run in the shaders. We want to use all or most of the code that runs MeshStandardMaterial (thousands of lines) but modify only a small portion of it.

The research

Let’s dig into the code a bit. MeshStandardMaterial like other materials, has a core GLSL description in form of a template:

https://github.com/mrdoob/three.js/tree/dev/src/renderers/shaders/ShaderLib

https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderLib/meshphysical_vert.glsl

The syntax found in these files is half GLSL half three.js specific syntax:

#define PHYSICAL //GLSL varying vec3 vViewPosition; //GLSL #ifndef FLAT_SHADED //GLSL varying vec3 vNormal; //GLSL #endif //GLSL #include <common> //NOT GLSL

#include <uv_pars_vertex> //NOT GLSL

#include <uv2_pars_vertex> //NOT GLSL

In order to make this code maintainable (when all the includes parse it’s thousands of lines of code) these templates reference something called a ShaderChunk . This is a dictionary of reusable GLSL code snippets that lives on the globally accessible dictionary THREE.ShaderChunk . The additional syntax in form of #include <foo> is particular to three.js, WebGLRenderer internally parses this, by looking up a chunk from this dictionary, and appending it onto a string. The resulting string is a valid GLSL program with no additional syntax.

Chunks can be found here:

https://github.com/mrdoob/three.js/tree/dev/src/renderers/shaders/ShaderChunk

And can be as simple as this:

https://github.com/mrdoob/three.js/blob/dev/src/renderers/shaders/ShaderChunk/alphamap_fragment.glsl

#ifdef USE_ALPHAMAP

diffuseColor.a *= texture2D( alphaMap, vUv ).g;

#endif

If WebGLRenderer encounters a material that has an alpha map, it will compile the shader in such a way to trigger this branch. Once this branch runs, the order of operations is, texture lookup is done on map alphaMap at the interpolated 2d coordinate vUv , the second component of the resulting vector is selected .g and the diffuseColor 's 4th component .a is multiplied by it.

At a high level, each one of these chunks does something to transform the shader inputs, into shader outputs. Some declare the inputs, some write the outputs, some transform intermediary values. Like the alpha map one, which is applied to the final color, or the constant alpha value already present.

The conclusion is, we can modify any material by tweaking it’s GLSL code, as little or as much as we would like.

The problem

How do we modify the material? Let’s consider the naive approach first:

We have forked three.js, and we have access to these shader files. We can modify the code there, but each time we want to see a change we have to compile the entire library. Those .glsl files need to be parsed and “baked” into that THREE.ShaderChunk dictionary, otherwise it won’t run correctly at runtime.

A valid GLSL program does not exist until three.js parses this custom syntax. This, for most apps, happens the first time an mesh with that material is rendered.

This works, but on the downside, it’s tedious, has overhead, and you end up with a different version of three.js.

The copy paste route

The official way of extending a shader can be found in this example:

https://github.com/mrdoob/three.js/blob/dev/examples/webgl_buffergeometry_instancing_lambert.html

We would find the shader template we wish to extend, copy it’s contents over and construct a generic THREE.ShaderMaterial . ShaderMaterial doesn’t have to take valid GLSL, #include statements can be parsed within it as well.

The idea is that you would have an overview in the pasted, unrolled code, and that you could easily tell where to add your own.

This definitely works, but has some drawbacks.

It’s verbose, you actually have to copy over the entire recipe, or resort to string manipulation if you try to reference it. The example above only adds two lines of code, but has to copy over the entire template!

The inputs get obfuscated. ShaderMaterial ‘s inputs are on the .uniforms dictionary property, and each on their own is a dictionary (we have to access .value ):

var myStandardMaterial = new THREE.MeshStandardMaterial()

myStandardMaterial.roughness = 1 var myExtendedStandardMaterial = new MyExtendedStandardMaterial()

myStandardMaterial.uniforms.roughness.value = 1 //interface changed

The monkey patch route

Another valid approach, some times suggested is to monkey patch the THREE.ShaderChunk dictionary. Since it is global, and it is public, one can replace any chunk in there.

//before loading your app

THREE.ShaderChunk.some_chunk = my_chunk

Doing this goes against some best practices and has it’s drawbacks but it works.

It would be disastrous if we modified this at runtime, but as soon as three.js mounts, we can do a one time patch which should be safe. Three now globally uses your version of the chunks.

What if we don’t want each instance of MeshStandardMaterial to use the same chunks? Tricky, because we’ve overwritten the global dictionary. We can though, do something like this:

#ifdef MY_DEFINE myLogic() #else



threeDefaultLogic() #endif

Piggy back on the preprocessor directives. This way we can have both our modified chunk logic and the default one. We would control this as such:

var myDefaultMaterial = new THREE.MeshStandardMaterial() var myModifiedMaterial = new THREE.MeshStandardMaterial()

myModifiedMaterial.defines.MY_DEFINE = '' //triggers our branch

This can now work but still has limitations. Some transformations that happen in the middle of the shader can be safely added. Something that depends on a geometry attribute as input (like instancing) can also safely be added, but hooking up uniforms is extremely involved.

The onBeforeCompile route

What if we could tell three.js that when it picks up the template for processing (in order to generate a valid GLSL program), it doesn’t exclusively sample the chunks from that one THREE.ShaderChunk dictionary?

We could provide the material instance with our own chunk, without modifying the global dictionary or using preprocessor tokens.

This is possible with a callback on the Material class called .onBeforeCompile . This is a very powerful and amazing feature, but with a somewhat ambiguous name.

It is invoked by WebGLRenderer before two important steps — parsing and compiling. The valid GLSL program has to be compiled by WebGL, this always happens at runtime. Before this, WebGLRenderer transforms the custom syntax into valid GLSL, this could be called parse time . onBeforeCompile actually happens before this parse time. The argument passed through is a shader object, containing the template syntax. This is why you can use non GLSL #include statements even in ShaderMaterial . At this time, you have the opportunity to intercept this template, and parse the #include statement yourself. Once this happens, three.js will still parse the remaining statements in a discrete step, before actually compiling the shader.

Let’s take that alphamap_fragment chunk for example and modify it.

#ifdef USE_ALPHAMAP

diffuseColor.a *= texture2D( alphaMap, vUv * 2. ).g;

#endif

For every diffuse or specular map, we’re going to tile the alpha map twice in both UV directions. Other maps use vUv , we will tell this one to use vUv * 2.

To apply this to some instance of some Material :

var myMaterial = new THREE.MeshStandardMaterial() myMaterial.onBeforeCompile = shader => { shader.fragmentShader = //this is the fragment program string in the template format

shader.fragmentShader.replace( //we have to transform the string

'#include <alphamap_fragment>', //we will swap out this chunk

require('my_alphamap_fragment.glsl') //with our own

) }

Now, when the material is parsed, before three gets a chance to swap out that #include statement with a corresponding value from the THREE.ShaderChunk dictionary, we swap it out ourselves. Three’s parser then simply doesn’t find it.

Let’s swap out the hardcoded value 2 with a dynamic input.

First we tell the chunk to use myValue instead of 2.

#ifdef USE_ALPHAMAP

diffuseColor.a *= texture2D( alphaMap, vUv * myValue ).g;

#endif

Then we inject that input into the shader:

var myMaterial = new THREE.MeshStandardMaterial() myMaterial.userData.myValue = { value: 2 } //this will be our input, the system will just reference it myMaterial.onBeforeCompile = shader => { shader.uniforms.myValue = myMaterial.userData.myValue //pass this input by reference



//prepend the input to the shader

shader.fragmentShader = 'uniform vec2 myValue;

' + shader.fragmentShader //the rest is the same

shader.fragmentShader =

shader.fragmentShader.replace(

'#include <alphamap_fragment>',

require('my_alphamap_fragment.glsl')

)

}

Whenever we change the myMaterial.userData.myValue.value the shader will update. Now we can tile the alphamap independent of other maps.

While powerful, this approach also has drawbacks. It’s impossible to tell what’s in the onBeforeCompile function if you receive an instance of a Material that has already been extended.

It’s verbose. For the most part, we would be doing the same thing over and over again — given some chunk name, replace it with some string. Three.js could do this internally, if we could only provide a key with a string value, three could use that instead of THREE.ShaderChunk without the need for us to manually parse the template.

However, these onBeforeCompile callbacks could be chained together, as long as we don’t have two extensions colliding by modifying the same chunk, several effects could be stacked together.

Some code

You can get pretty creative with onBeforeCompile . For example, you can parse the entire shader yourself and then look for patterns on a more granular level than just swapping out chunks.

This demo, adds some texture transform properties to each someMap slot of a material, by running some regexes on the entire compiled shader.

Some gotchas

If you want your modified material to work with shadows, and you’ve done some kind of additional transformation of the vertices, you need to use Mesh.customDepthMaterial that has the corresponding extension ie. whatever you apply to some mesh material, you need to apply to this:

myMesh.customDepthMaterial = new THREE.MeshDepthMaterial()

onBeforeCompile has some issues with hashing, you can’t get too fancy with the logic in there.

Some thoughts

If it were possible to pass own THREE.ShaderChunk dictionary to any THREE.Material i believe it would be the most flexible solution for working with a chunk system.

When monkey patching, we only have the defines to pass around and clone, all the shader code is global. Instead of having many per material branches in a chunk (ie 5 different #ifdef FOO_MATERIAL ) we would just pass the appropriate snippets to appropriate materials. This way the chunk doesn’t have to know about all the possible materials that could be using it.

Also having the code available as soon as you instantiate materials would allow them to be modified and cloned better. With onBeforeCompile this is a black box, so for example if you download some 3rd party module that uses it, it’s almost impossible to further extend it with your own code that you’re using through onBeforeCompile.

const material = new THREE.MeshBasicMaterial() material.chunks.begin_normal = myChunk

While it may be possible to store a THREE.ShaderChunk like structure under userData it may be impossible to make a generic onBeforeCompile to use it.

onBeforeCompile has an issue when hashing materials, so if you have something generic like:

const myGenericOnBeforeCompile = shader=>{

const {customUniforms, customChunks} = this.userData Object.keys(customUniforms).forEach(uName=>{

shader.uniforms[uName] = customUniforms[uName]

})

Object.keys(customChunks).forEach(chunkName=>{

//store `vertex` or `fragment`

let shaderStage = customChunks[chunkName].vertexStage

shaderStage = `${shaderStage}Shader` shader[shaderStage] = shader[shaderStage].replace(

`#include <${chunkName}>`,

customChunks[chunkName]

}

})

} myMaterial.onBeforeCompile = myGenericOnBeforeCompile.bind(myMaterial)

As amazing as it seems, it wouldn’t work. No matter what you put in your userData.customChunk the WebGLRenderer would not care, it would see the same material and retreive some instance from the cache (most likely the first one it compiled). It calls onBeforeCompile.toString() for hashing, and all it would ever get is the same generic body :(

I have tried to make a decoupling of the entire material system. Currently in a very rough state but can be seen here.