Creating a Risograph Grain Light Effect in Three.js

Recently, I release my brand new portfolio, home to my projects that I have worked on in the past couple of years:

As I was doing experimentations for the portfolio, I tried to reproduce this kind of effect I found on the web:

I really like these 2D grain effects applied to 3D elements. It kind of has this cool feeling of cray and rocks and I decided to try and reproduce it from scratch. I started with a custom light shader, then mixed it with a grain effect and by playing with some values I got to this final result:

In this tutorial I’d like to share with you what I’ve done to achieve this effect. We’re going to explore two different ways of doing it.

Note that I won’t get into too much detail about Three.js and WebGL for simplicity, so it’s good to have some solid knowledge of JavaScript, Three.js and some notions about shaders before starting with this tutorial. If you’re not very familiar with shaders but with Three.js, then the second way is for you!

Summary

Method 1: Writing our own custom ShaderMaterial (That’s the harder path but you’ll learn about how light reflection works!)

  • Creating a basic Three.js scene
  • Use ShaderMaterial
  • Create a diffuse light shader
  • Create a grain effect using 2D noise
  • Mix it with light

Method 2: Starting from MeshLambertMaterial shader (Easier but includes unused code from Three.js since we’ll rewrite the Three.js LambertMaterial shader)

  • Copy and paste MeshLambertMaterial
  • Add our custom grain light effect to the fragmentShader
  • Add any Three.js lights

1. Writing our own custom ShaderMaterial

Creating a basic Three.js scene

First we need to set up a basic Three.js scene with a simple sphere in the center:

Here is a Three.js basic scene with a camera, a renderer and a sphere in the middle. You can find the code in this repository in the file src/js/Scene.js, so you can start the tutorial from here.

Use ShaderMaterial

Let’s create a custom shader in Three.js using the ShaderMaterial class. You can pass it uniforms objects, and a vertex and a fragment shader as parameters. The cool thing about this class is that it’s already giving you most of the necessary uniforms and attributes for a basic shader (positions of the vertices, normals for light, ModelViewProjection matrices and more).

First, let’s create a uniform that will contain the default color of our sphere. Here I picked a light blue (#51b1f5) but feel free to pick your favorite color. We’ll use a new THREE.Color() and call our uniform uColor. We’ll replace the material from the previous code l.87:

const material = new THREE.ShaderMaterial({
  uniforms: {
    uColor: { value: new THREE.Color(0x51b1f5) }
  }
});

Then let’s create a simple vertex shader in vertexShader.glsl, a separated file that will display the sphere vertices at the correct position related to the camera.

void main(void) {
  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}

And finally, we write a basic fragment shader fragmentShader.glsl in a separated file as well, that will use our uniform uColor vec3 value:

uniform vec3 uColor;

void main(void) {
  gl_FragColor = vec4(uColor, 1.);
}

Then, let’s import and link them to our ShaderMaterial.

import vertexShader from './vertexShader.glsl'
import fragmentShader from './fragmentShader.glsl'
...    
const material = new THREE.ShaderMaterial({
  vertexShader: vertexShader,
  fragmentShader: fragmentShader,
  uniforms: {
    uColor: { value: new THREE.Color(0x51b1f5) }
  }
});

Now we should have a nice monochrome sphere:

Create a diffuse light shader

Creating our own custom light shader will allow us to easily manipulate how the light should affect our mesh.

Even if that seems complicated to do, it’s not that much code and you can find great articles online explaining how light reflection works on a 3D object. I recommend you read webglfundamentals if you would like to learn more details on this topic.

Going further, we want to add a light source in our scene to see how the sphere reflects light. Let’s add three new uniforms, one for the position of our spotlight, the other for the color and a last one for the intensity of the light. Let’s place the spotlight above the object, 5 units in Y and 1 unit in Z, use a white color and an intensity of 0.7 for this example.

 ...
 uLightPos: {
   value: new THREE.Vector3(0, 5, 3) // position of spotlight
 },
 uLightColor: {
   value: new THREE.Color(0xffffff) // default light color
 },
 uLightIntensity: {
   value: 0.7 // light intensity
 },

Now let’s talk about normals. A THREE.SphereGeometry has normals 3D vectors represented by these arrows:

For each surface of the sphere, these red vectors define in which direction the light rays should be reflected. That’s what we’re going to use to calculate the intensity of the light for each pixel.

Let’s add two varyings on the vertex shader:

  • vNormals, the normals vectors of the object related to the world position (where it is in the global scene).
  • vSurfaceToLight, this represents the direction of the light position minus the direction of each surface of the sphere.
uniform vec3 uLightPos;

varying vec3 vNormal;
varying vec3 vSurfaceToLight;

void main(void) {
  vNormal = normalize(normalMatrix * normal);

  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
  // General calculations needed for diffuse lighting
  // Calculate a vector from the fragment location to the light source
  vec3 surfaceToLightDirection = vec3( modelViewMatrix * vec4(position, 1.0));
  vec3 worldLightPos = vec3( viewMatrix * vec4(uLightPos, 1.0));
  vSurfaceToLight = normalize(worldLightPos - surfaceToLightDirection);
}

Now let’s generate colors based on these light values in the Fragment shader.

We already have the normals values with vNormals. To calculate a basic light reflection on a 3D object we need two values light types: ambient and diffuse.

Ambient light is a constant value that will give a global light color of the whole scene. Let’s just use our light color for this case.

Diffuse light is representing the value of how strong the light is depending on how the object reflects it. That means that all surfaces which are close to and facing the spotLight should be more enlightened than surfaces that are far away and in the same direction. There is an amazing math function to calculate this value called the dot() product. The formula for getting a diffuse color is the dot product of vSurfaceToLight and vNormal. In this image you can see that vectors facing the sun are brighter than the others:

Then we need to addition the ambient and diffuse light and finally multiply it by a lightIntensity. Once we got our light value let’s multiply it by the color of our sphere. Fragment shader:

uniform vec3 uLightColor;
uniform vec3 uColor;
uniform float uLightIntensity;

varying vec3 vNormal;
varying vec3 vSurfaceToLight;

vec3 light_reflection(vec3 lightColor) {
  // AMBIENT is just the light's color
  vec3 ambient = lightColor;

  //- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
  // DIFFUSE  calculations
  // Calculate the cosine of the angle between the vertex's normal
  // vector and the vector going to the light.
  vec3 diffuse = lightColor * dot(vSurfaceToLight, vNormal);

  // Combine 
  return (ambient + diffuse);
}

void main(void) {
  vec3 light_value = light_reflection(uLightColor);
  light_value *= uLightIntensity;

  gl_FragColor = vec4(uColor * light_value, 1.);
}

And voilà:

Feel free to click and drag on this sandbox scene to rotate the camera.

Note that if you want to recreate MeshPhongMaterial you also need to calculate the specular light. This represent the effect you can observe when a ray of light gets directly into our eyes when reflected by an object, but we don’t need that precision here.

Create a grain effect using 2D noise

To get a 2D grain effect we’ll have to use a noise function that will display a gray color from 0 to 1 for each pixel of the screen in a “beautiful randomness”. There are a lot of functions online for creating simplex noise, perlin noise or others. Here we’ll use glsl-noise for a 2D simplex noise and glslify to import the noise function directly at the beginning of our fragment shader using:

#pragma glslify: snoise2 = require(glsl-noise/simplex/2d)

Thanks to the native WebGL value gl_FragCoord.xy we can easily get the UVs (coordinates) of the screen. Then we just have to apply the noise to these coordinates vec3 textureNoise = vec3(snoise2(uv)); This will create our 2D noise. Then, let’s apply these noise colors to our gl_FragColor:

#pragma glslify: snoise2 = require(glsl-noise/simplex/2d)
... 
// grain
vec2 uv = gl_FragCoord.xy;

vec3 noiseColors = vec3(snoise2(uv));

gl_FragColor = vec4(noiseColors, 1.0);

What a nice old TV noise effect:

As you can see, when moving the camera, the texture feels like it’s “stuck” to the screen, that’s because we matched our simplex noise effect to the coordinates of the screen to create a 2D style effect. You can also adjust the size of the noise like so uv /= myNoiseScaleVal;

Mixing it with the light

Now that we got our noise value and our light let’s mix them! The idea is to apply less noise where the light value is stronger (1.0 == white) and more noise where the light value is weaker (0.0 == black). We already have our light value, so let’s just multiply the texture value with that:

colorNoise *= light_value.r;

You can see how the light affects the noise now, but this doesn’t look very strong. We can accentuate this value by using an exponential function. To do that in GLSL (the shader language) you can use pow(). It’s already included in shaders, here I used the exponential of 5.

colorNoise *= pow(light_value.r, 5.0);

Then, let’s enlighten the noise color effect like so:

vec3 colorNoise = vec3(snoise2(uv) * 0.5 + 0.5);

To gray, right? Almost there, let’s re-add our beautiful color that we got from the start. We can say that if the light is strong it will go white, and if the light is weak it will be clamped to the initial channel color of the sphere like this:

gl_FragColor.r = max(textureNoise.r, uColor.r);
gl_FragColor.g = max(textureNoise.g, uColor.g);
gl_FragColor.b = max(textureNoise.b, uColor.b);
gl_FragColor.a = 1.0;

Now that we have this Material ready, we can apply it to any object of the scene:

Congrats, you finished the first way of doing this effect!

2. Starting from MeshLambertMaterial shader

This way is simpler since we’ll directly reuse the MeshLambertMaterial from Three.js and apply our grain in the fragment shader. First let’s create a basic scene like in the first method. You can take this repository, and start from the src/js/Scene.js file to follow this second method.

Copy and paste MeshLambertMaterial

In Three.js all the Materials shaders can be found here. They are composed by shunks (reusable GLSL code) that are included here and there in Three.js shaders. We’re going to copy the MeshLambertMaterial fragment shader from here and paste it in a new fragment.glsl file.

Then, let’s add a new ShaderMaterial that will include this fragmentShader. However, for the vertex, since we’re not changing it, we can just pick it directly from the lib THREE.ShaderLib.lambert.vertexShader.

Finally, we need to merge the Three.js uniforms with ours, using THREE.UniformsUtils.merge(). Like in the first method, let’s use the sphere color uColor, uNoiseCoef to play with the grain effect and a uNoiseScale for the grain size.

import fragmentShader from './fragmentShader.glsl'
...

this.uniforms = THREE.UniformsUtils.merge([
  THREE.ShaderLib.lambert.uniforms,
  {
    uColor: {
      value: new THREE.Color(0x51b1f5)
    },
    uNoiseCoef: {
      value: 3.5
    },
    uNoiseScale: {
      value: 0.8
    }
  }
])

const material = new THREE.ShaderMaterial({
  vertexShader: THREE.ShaderLib.lambert.vertexShader,
  fragmentShader: glslify(fragmentShader),
  uniforms: this.uniforms,
  lights: true,
  transparent: true
})

Note that we’re importing the fragmentShader using glslify because we’re going to use the same simplex noise 2D from the first method. Also, the lights parameter needs to be set to true so the materials can reuse the value of all source lights of the scene.

Add our custom grain light effect to the fragmentShader

In our freshly copied fragment shader, we’ll need to import the 2D simplex noise using the glslify and glsl-noise libs. #pragma glslify: snoise2 = require(glsl-noise/simplex/2d).

If we look closely at the MeshLambertMaterial fragment we can find a outgoingLight value. This looks very similar to our light_value from the first method, so let’s apply the same 2D grain shader effect to it:

// grain
vec2 uv = gl_FragCoord.xy;
uv /= uNoiseScale;

vec3 colorNoise = vec3(snoise2(uv) * 0.5 + 0.5);
colorNoise *= pow(outgoingLight.r, uNoiseCoef);

Then let’s mix our uColor with the colorNoise. And here is the final fragment shader:

#pragma glslify: snoise2 = require(glsl-noise/simplex/2d)
...
uniform float uNoiseScale;
uniform float uNoiseCoef;
...	
// write this the very end of the shader
// grain
vec2 uv = gl_FragCoord.xy;
uv /= uNoiseScale;

vec3 colorNoise = vec3(snoise2(uv) * 0.5 + 0.5);
colorNoise *= pow(outgoingLight.r, uNoiseCoef);

gl_FragColor.r = max(colorNoise.r, uColor.r);
gl_FragColor.g = max(colorNoise.g, uColor.g);
gl_FragColor.b = max(colorNoise.b, uColor.b);
gl_FragColor.a = 1.0;

Add any Three.js lights

No light? Let’s add some THREE.SpotLight in the scene in src/js/Scene.js file.

const spotLight = new THREE.SpotLight(0xff0000)
spotLight.position.set(0, 5, 4)
spotLight.intensity = 1.85
this.scene.add(spotLight)

And here you go:

You can also play with the alpha value in the fragment shader like this:

gl_FragColor = vec4(colorNoise, 1. - colorNoise.r);

And that’s it! Hope you enjoyed the tutorial and thank you for reading.

Resources

  • https://webglfundamentals.org/
  • https://www.behance.net/gallery/106782599/Risograph-Grain-Effect-for-Photoshop