Custom shaders with Three.JS: Uniforms, textures and lighting

If you’re familiar to WebGL and GLSL programming and have started using three.js, you’ll eventually run into a situation where you want to code your own shader, but at the same time use the resources that this library provides you with. In this post, I’ll show you how to setup a custom shader with a three.js geometry, pass it your own uniforms, bind a texture to a particular uniform and receive all lights that you’ve added to the scene.

Source code for demo

The source code below has been tested with three.js r84 and can be visualized here.

<html>
<head>
<script src="three.min.js"></script>
<script src="render.js"></script>
<script id="vertShader" type="shader">
varying vec2 vUv;
varying vec3 vecPos;
varying vec3 vecNormal;
 
void main() {
  vUv = uv;
  // Since the light is in camera coordinates,
  // I'll need the vertex position in camera coords too
  vecPos = (modelViewMatrix * vec4(position, 1.0)).xyz;
  // That's NOT exacly how you should transform your
  // normals but this will work fine, since my model
  // matrix is pretty basic
  vecNormal = (modelViewMatrix * vec4(normal, 0.0)).xyz;
  gl_Position = projectionMatrix *
                vec4(vecPos, 1.0);
}
</script>
<script id="fragShader" type="shader">
precision highp float;
 
varying vec2 vUv;
varying vec3 vecPos;
varying vec3 vecNormal;
 
uniform float lightIntensity;
uniform sampler2D textureSampler;

struct PointLight {
  vec3 color;
  vec3 position; // light position, in camera coordinates
  float distance; // used for attenuation purposes. Since
                  // we're writing our own shader, it can
                  // really be anything we want (as long as
                  // we assign it to our light in its
                  // "distance" field
};

uniform PointLight pointLights[NUM_POINT_LIGHTS];
 
void main(void) {
  // Pretty basic lambertian lighting...
  vec4 addedLights = vec4(0.0,
                          0.0,
                          0.0,
                          1.0);
  for(int l = 0; l < NUM_POINT_LIGHTS; l++) {
      vec3 lightDirection = normalize(vecPos
                            - pointLights[l].position);
      addedLights.rgb += clamp(dot(-lightDirection,
                               vecNormal), 0.0, 1.0)
                         * pointLights[l].color
                         * lightIntensity;
  }
  gl_FragColor = texture2D(textureSampler, vUv)
                 * addedLights;
}
</script>
</head>
<body style="margin: 0px;" onload="init()"></body>
</html>

And this is the source for the render.js file:

// standard global variables
var scene, camera, renderer, textureLoader, light;

// Character 3d object
var character = null;

// FUNCTIONS
function init() {
  // SCENE
  scene = new THREE.Scene();
  textureLoader = new THREE.TextureLoader();

  // CAMERA
  var SCREEN_WIDTH = window.innerWidth;
  var SCREEN_HEIGHT = window.innerHeight;
  var VIEW_ANGLE = 45;
  var ASPECT = SCREEN_WIDTH / SCREEN_HEIGHT;
  var NEAR = 0.1;
  var FAR = 1000;
  camera = new THREE.PerspectiveCamera(VIEW_ANGLE, ASPECT,
                                       NEAR, FAR);
  scene.add(camera);
  camera.position.set(0,0,5);
  camera.lookAt(scene.position);

  // RENDERER
  renderer = new THREE.WebGLRenderer({
    antialias:true,
    alpha: true
  });
  renderer.setSize(SCREEN_WIDTH, SCREEN_HEIGHT);
  var container = document.body;
  container.appendChild( renderer.domElement );

  // Create light
  light = new THREE.PointLight(0xffffff, 1.0);
  // We want it to be very close to our character
  light.position.set(0.0, 0.0, 0.1);
  scene.add(light);

  // Create character
  character = buildCharacter();
  scene.add(character);

  // Start animation
  animate();
}

var buildCharacter = (function() {
  var _geo = null;

  // Share the same geometry across all planar objects
  function getPlaneGeometry() {
    if(_geo == null) {
      _geo = new THREE.PlaneGeometry(1.0, 1.0);
    }

    return _geo;
  };

  return function() {
    var g = getPlaneGeometry();
    var creatureImage = textureLoader.load('texture.png');
    creatureImage.magFilter = THREE.NearestFilter;

    var mat = new THREE.ShaderMaterial({
        uniforms: THREE.UniformsUtils.merge([
            THREE.UniformsLib['lights'],
            {
                lightIntensity: {type: 'f', value: 1.0},
                textureSampler: {type: 't', value: null}
            }
        ]),
        vertexShader: document.
                      getElementById('vertShader').text,
        fragmentShader: document.
                        getElementById('fragShader').text,
        transparent: true,
        lights: true
    });
    // THREE.UniformsUtils.merge() call THREE.clone() on
    // each uniform. We don't want our texture to be
    // duplicated, so I assign it to the uniform value
    // right here.
    mat.uniforms.textureSampler.value = creatureImage;

    var obj = new THREE.Mesh(g, mat);

    return obj;
  }
})();

function animate() {
  // Update light profile
  var timestampNow = new Date().getTime()/1000.0;
  var lightIntensity = 0.75 +
                       0.25 * Math.cos(timestampNow *
                                       Math.PI);

  character.material.uniforms
           .lightIntensity.value = lightIntensity;
  light.color.setHSL(lightIntensity, 1.0, 0.5);

  // Render scene
  renderer.render(scene, camera);
  requestAnimationFrame(animate);
}

There’s nothing special about the init() function: It sets up the webgl renderer, creates the scene, the camera and a point light and adds an object to the scene. The function getPlaneGeometry() just instantiates a THREE.PlaneGeometry, which contains vertices, normals, and texture coordinates. The anonymous function on line 61 will create our mesh object. In three.js, a mesh is a “renderable” type formed by combining a geometry (from which three.js will create a vertex buffer object) and a material (which is basically a shader with metadata, such as uniforms). Here, we create a ShaderMaterial, which allows us to specify our custom vertex and fragment shaders. The “uniforms” parameter takes an object with the format:

UNIFORM_NAME: {
  type: UNIFORM_TYPE,
  value: UNIFORM_VALUE
}

For a guide on accepted formats, consult here. In this example, I created a float uniform named lightIntensity and a texture sampler uniform named, well, textureSampler.

Finally, on function animate I update the color uniform to a simple sinusoidal function. Three.js will automatically send the current value of each uniform to the GPU every time you call render.

Vertex shader and implicit attributes and uniforms

If you paid close attention to the vertex shader code, you’ve probably noticed that the declaration for the “position” attribute seems to be missing. The same also applies to the projectionMatrix and modelViewMatrix. In fact, three.js modifies the vertex shader given to it by appending the declaration of several attributes and uniforms. To understand the fields that are automatically created by three.js, refer to the WebGLProgram documentation.

Texture Sampler Uniform

In three.js, texture sampler uniforms have a type "t" and must be assigned to THREE.Texture objects. If your texture is an image, there’s a helper function that simplifies this task:

// It accepts other formats too
var loader = new THREE.TextureLoader;
loader.load("path/to/image.png")
We will draw this guy inside our polygon. Kudos for Stephen
We will draw this guy inside our polygon. Kudos for Stephen “Redshrike” Challener and William.Thompsonj

After I’ve chosen the texture image (remember to use an image whose dimensions are powers of two), our shader material is initialized in the mat variable.

Here I’ve loaded the image file and created a THREE.Texture object. I also set the magnification filter to nearest (I want my texture to be pixelated as I scale it up). More information on the filter constants used by three.js can be found on the texture constants documentation. You can also see the fields for the THREE.Texture object here.

Another important property introduced is the transparent flag, since my texture has some regions with alpha=0. By setting the transparent flag to true, three.js will automatically call gl.enable(GL_BLEND) when this object is about to be drawn. It also defers the rendering of transparent objects to after opaque objects are drawn. Also, three.js draws these objects ordered from the farthest to the closest ones.

Now that we’ve seen how to create a shader material, let’s take a look at the shader code itself.

Dealing with lights

In order to deal with light objects in your shader, you have to manually setup the required uniforms both on the material object and in your shader code.

Setting up the material

The first thing you have to do is to enable the lights flag in the material object by setting this field to true.

You also have to include the required light objects in the uniform list of your material object. Since three.js already has a public field with all light uniforms (and there’s a lot of them) in THREE.UniformsLib['lights'], you can merge them to your uniform object with the function THREE.UniformUtils.merge:

    var mat = new THREE.ShaderMaterial({
        uniforms: THREE.UniformsUtils.merge([
            THREE.UniformsLib['lights'],
            {
                lightIntensity: {type: 'f', value: 1.0},
                textureSampler: {type: 't', value: null}
            }
        ]),
        vertexShader: document.
                      getElementById('vertShader').text,
        fragmentShader: document.
                        getElementById('fragShader').text,
        transparent: true,
        lights: true
    });
    // THREE.UniformsUtils.merge() call THREE.clone() on
    // each uniform. We don't want our texture to be
    // duplicated, so I assign it to the uniform value
    // right here.
    mat.uniforms.textureSampler.value = creatureImage;

Create some lights

In order to test our demo, we create a point light right in front of our object. This must be done before the character creation, making sure that THREE.UniformsLib['lights'] contain the required light objects.

  light = new THREE.PointLight(0xffffff, 1.0);
  // We want it to be very close to our character
  light.position.set(0.0, 0.0, 0.1);
  scene.add(light);

Setting up the shader

In our demo, three.js is automatically sending all your lights to our light uniforms. Since we are using a point light, we’ll need to create the following uniform:

struct PointLight {
  vec3 color;
  vec3 position; // light position, in camera coordinates
  float distance; // used for attenuation purposes. Since
                  // we're writing our own shader, it can
                  // really be anything we want (as long
                  // as we assign it to our light in its
                  // "distance" field
};

uniform PointLight pointLights[NUM_POINT_LIGHTS];

Notice that the PointLight structure is in (a partial) format expected by three.js to provide the data relative to Point Lights to the shader program. In the UniformsLib documentation you can see a list of all such uniforms provided by the THREE.UniformsLib. You can also see their shader implementation here. Notice that the constant NUM_POINT_LIGHTS is automatically created by three.js, so no need to worry about it.

And that’s it! This is how the final character looks like:

Whoa! Now you look like a badass, sir!
Whoa! Now you look like a badass, sir!

Adding lights at any moment (aka adding lights at runtime)

So you want to add a light after your shader programs were compiled and are running (for instance, you may have a candle being lit in the middle of the gameplay). Two steps are required. First, add the light to the scene, as explained previously. Then, for each material (make sure you have access to them), set its needsUpdate flag to true. That should get your shader recompiled to take into account the new number of lights.

var light = new THREE.PointLight(0xffffff, 1.0);
light.position.set(0.0,0.0,0.1);
scene.add(light);
material.needsUpdate = true;

If you have access to your character (and any objects that contain a material), you can access its material in the “material” field:

character.material.needsUpdate = true;

Notice that you will have to do that to all materials in your scene (or at least those you think that will be affected). Also, you only have to perform shader recompilation if the number of similar lights is changed, since you would be changing the NUM_*_LIGHTS constant. Keep in mind that, if you add a point light and remove a spotlight, you will still need to update your material, as both the number of spotlights and point lights have changed.

Final thoughts

This is a pretty basic example that shows how to setup a custom shader with textures and three.js lights. Although the final effect could have been easily achieved without touching any shader, I hope this will prove useful to someone willing to write a shader that’s not available in the standard three.js material list.

Considering the fact that most cool effects are achievable by multipass rendering, I’ll eventually extend this post in the future to cover that subject as well.

Advertisements

Hello, world!

I spent the last few months working on a game project that proved to be too ambitious for the manpower I had available. So we decided to make small games and try to learn as much as possible from this experience, and bring our findings to the public.

In this post I’ll start by revealing the final year project I conducted as a computer science student at Universidade Federal de Minas Gerais (UFMG) back in 2010. Although not related to games, the idea was a system very similar to Oculus Rift, in which you could control a robot instead of a game character.

In the video, what we see is a pair of cameras attached to a setup with two servo motors, which allowed the cameras to rotate in pitch and yaw. The user would equip a headset (not shown in the video) attached to an IMU (a sensor that can estimate orientation in space), and the cameras would be moved to match the user head orientation.