Creating a custom shader in Three.js

3D stuff in the browser is awesome. After playing around with threejs for some time and making a&nbsp;<a href="https://dodge.imanidap.nl/" target="_blank">mini-game</a>&nbsp;at school I started to like it a lot. A classmate that is really into graphics programming told me a little bit about WebGL and shaders. It seemed really cool and I promised myself I would make my own shader. Of course some other shiny thing caught my attention and I forgot about it but, from today on I can finally say that I have created a shader and used it within threejs.

3D stuff in the browser is awesome. After playing around with threejs for some time and making a mini-game at school I started to like it a lot. A classmate that is really into graphics programming told me a little bit about WebGL and shaders. It seemed really cool and I promised myself I would make my own shader. Of course some other shiny thing caught my attention and I forgot about it but, from today on I can finally say that I have created a shader and used it within threejs.

Three JS

Before going all in on shaders it is probably a good idea to explain what three js is. Threejs is a javascript library to ease the process of creating 3D scenes on a canvas. Other popular solutions like a-frame and whitestorm jsare build on top of it. If you have ever played around with those but want even more control definitely try it out! (If you are a TypeScript lover, three js has type definitions 😉).

The most popular intro to this library is creating a cube and making it spin. There is a written tutorial in the threejs documentation and a brilliant youtube tutorial by CJ Gammon that is part of his 'diving in: three js' series.


Creating this cube is a basically preparing a film set and placing it inside of that set. You create a scene and a camera and pass these to a renderer to say: "hey this is my movie set". Then you can place mesh, which is basically an object, within the scene. This mesh consists of a geometry (the shape of the object) and a material (the color, behavior towards light and more). Based on the material you have chosen, you might want to add different kinds of lights to the scene. In order to animate the object and actually display everything you create a loop. Within this loop you tell the renderer to show the scene. Your code might look like this:

window.addEventListener('load', init)
let scene
let camera
let renderer
let sceneObjects = []

function init() {
scene = new THREE.Scene()

camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
camera.position.z = 5

renderer = new THREE.WebGLRenderer()
renderer.setSize(window.innerWidth, window.innerHeight)

document.body.appendChild(renderer.domElement)
adjustLighting()
addBasicCube()
animationLoop()
}

function adjustLighting() {
let pointLight = new THREE.PointLight(0xdddddd)
pointLight.position.set(-5, -3, 3)
scene.add(pointLight)

let ambientLight = new THREE.AmbientLight(0x505050)
scene.add(ambientLight)

}

function addBasicCube() {
let geometry = new THREE.BoxGeometry(1, 1, 1)
let material = new THREE.MeshLambertMaterial()

let mesh = new THREE.Mesh(geometry, material)
mesh.position.x = -2
scene.add(mesh)
sceneObjects.push(mesh)
}

function animationLoop() {
renderer.render(scene, camera)

for(let object of sceneObjects) {
object.rotation.x += 0.01
object.rotation.y += 0.03
}

requestAnimationFrame(animationLoop)
}

Shaders

Shaders are basically functions or small scripts that are executed by the GPU. This is where WebGL and GLSL (OpenGL Shading Language) come into play. WebGL is a browser API that allows javascript to run code on the GPU. This can increase the performance of certain scripts because your GPU is optimized for doing graphics related calculations. WebGL even allows us to write code that will be executed directly by the GPU in the GLSL language. These pieces of GLSL code are our shaders and since threejs has a WebGL renderer we can write shaders to modify our mesh. In threejs you can create custom material by using the 'shader material'. This material accepts two shaders, a vertex shader and a fragment shader. Let's try to make 'gradient material'.

Vertex Shader

A vertex shader is a function that is applied on every vertex (point) of a mesh. It is usually used to distort or animate the shape of a mesh. Within our script it looks something like this:

function vertexShader() {
return `
varying vec3 vUv;

void main() {
  vUv = position; 

  vec4 modelViewPosition = modelViewMatrix * vec4(position, 1.0);
  gl_Position = projectionMatrix * modelViewPosition; 
}

} </pre><p>The first thing that you probably notice is that all our GLSL code is in a string. We do this because WebGL will pass this piece of code to our GPU and we have to pass the code to WebGL within javascript. The second thing you might notice is that we are using variables that we did not create. This is because threejs passes those variables to the GPU for us.</p><p>Within this piece of code we calculate where the points of our mesh should be placed. We do this by calculating where the points are in the scene by multiplying the position of the mesh in the scene (modelViewMatrix) and the position of the point. After that we multiply this value with the camera's relation to the scene (projectionMatrix) so the camera settings within threejs are respected by our shader. The gl_Position is the value that the GPU takes to draw our points.</p><p>Right now this vertex shader doesn't change anything about our shape. So why even bother creating this at all? We will need the positions of parts of our mesh to create a nice gradient. By creating a 'varying' variable we can pass the position to another shader.</p><h2>Fragment shader</h2><p>A fragment shader is a function that is applied on every fragment of our mesh. A fragment is a result of a process called rasterization which turns the entire mesh into a collection of triangles. For every pixel that is covered by our mesh there will be at least one fragment. The fragment shader is usually used to do color transformations on pixels. Our fragment shader looks like this:</p><pre class="ql-syntax" spellcheck="false"> return
uniform vec3 colorA;
uniform vec3 colorB;
varying vec3 vUv;

  void main() {
    gl_FragColor = vec4(mix(colorA, colorB, vUv.z), 1.0);
  }

`
}

As you can see we take the value of the position that was passed by the vertex shader. We want to apply a mix of the colors A and B based on the position of the fragment on the z axis of our mesh. But where do the colors A and B come from? These are 'uniform' variables which means they are passed into the shader from the outside. The mix function will calculate the RGB value we want to draw for this fragment. This color and an additional value for the opacity are passed to gl_FragColor. Our GPU will set the color of a fragment to this color.

Creating the material

Now that we've created the shaders we can finally build our threejs mesh with a custom material.

function addExperimentalCube() {
let uniforms = {
colorB: {type: 'vec3', value: new THREE.Color(0xACB6E5)},
colorA: {type: 'vec3', value: new THREE.Color(0x74ebd5)}
}

let geometry = new THREE.BoxGeometry(1, 1, 1)
let material = new THREE.ShaderMaterial({
uniforms: uniforms,
fragmentShader: fragmentShader(),
vertexShader: vertexShader(),
})

let mesh = new THREE.Mesh(geometry, material)
mesh.position.x = 2
scene.add(mesh)
sceneObjects.push(mesh)
}

This is where everything comes together. Our 'uniforms' colorA and colorB are created and passed along with the vertex shader and fragment shader into the shader material. The material and geometry are used to create a mesh and the mesh is added to the scene.



I build this in glitch. A friend recommended it and it is great! Some add blockers block you loading the embed though, so here is a direct link just in case.

The left cube is a cube using mesh lambert material, the right cube uses our own 'gradient material'. As you can see our material looks pretty sweet but ignores the light settings in the scene. This is because we didn't do the math in our fragment shader to take the light into account. This is hopefully something I figure out soon 😝.

Resources

It took some time to figure this out and if you liked this you should really check out the sources I have used to learn and understand this:


Why my window resize event listener doesn't work? Using Three.js and Vuetify.js

I am putting a three.js renderer inside of a , using vuetify.js framework. What I would like my code to do is to change the div element dimensions whenever the window is resized.

I am putting a three.js renderer inside of a , using vuetify.js framework. What I would like my code to do is to change the div element dimensions whenever the window is resized.

This is part of my project and I omitted unnecessary code blocks, so don't mind the not used variables :)

<style scoped>
.map__three {
  position: absolute;
  bottom: 60px;
  left: 0px;
}
</style>

<template>
<div class="flex fill-height wrap">
<v-btn></v-btn>
<div id="map" class="flex fill-height wrap" v-on:dblclick="addNewPoi3d"></div>
</div>
</template>

<script>

export default {
name: 'ThreeTest',
data() {
return {
scene: null,
renderer: null,
camera: null,
mouse: null,
mousePosition: new THREE.Vector2(),
canvasPosition: null,
rayCaster: new THREE.Raycaster(),
mapWidth: null,
mapHeight: null,
mapDimensions: null
};
},

methods: {
init() {
let map = document.getElementById('map');
this.mapDimensions = map.getBoundingClientRect();
this.mapWidth = this.mapDimensions.width;
this.mapHeight = this.mapDimensions.height;
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color( 0xf0f0f0 );

  this.camera = new THREE.PerspectiveCamera(
    75,
    this.mapWidth/this.mapHeight,
    0.1,
    1000,
  );
  this.camera.position.z = 3;


  this.renderer = new THREE.WebGLRenderer();
  this.renderer.setSize(this.mapWidth, this.mapHeight);
  map.appendChild(this.renderer.domElement);



  // EVENT LISTENERS:
  window.addEventListener('resize', this.onWindowResize, false);
},


onWindowResize()  {
    this.camera.aspect = this.mapWidth / this.mapHeight;
    this.camera.updateProjectionMatrix();
    this.renderer.setSize(this.mapWidth, this.mapHeight);
},



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

},

mounted() {
this.init();
this.animate();
}
};
</script>

EXPECTED: it should resize the dimensions of the scene and camera aspect ratio I've loaded in.

WHAT IT DOES: none of that :D It pertains the same size of the scene and camera.