Color Basics in Three.js

Published

A few weeks back I wanted to dig into learning WebGL with Three.js and started poking through the site to see if I could start with an example to get things rolling. I found this creating a scene introduction in the docs and decided to start there. After seeing how to create and render a scene and then do a simple cube animation I wanted to explore and learn a little more of how to use color in Three.js.

Color Initialization

Using the creating a scene introduction code as a starter, I initialize a few colors:

const white = new THREE.Color( 0xffffff );
const black = new THREE.Color( 0x000000 );
const red = new THREE.Color( 0xff0000 );
const green = new THREE.Color( 0x00ff00 );
const blue = new THREE.Color( 0x0000ff );

Here’s the Color constructor:

Color( r : Color_Hex_or_String, g : Float, b : Float )

There are different color formats you can pass into the constructor but hexadecimal is recommended.

const white = new THREE.Color( 0xffffff ); // hexadecimal
const white = new THREE.Color('rgb(255,255,255)'); // RGB string
const white = new THREE.Color(1, 1, 1); // Separate RGB values between 0 and 1

Changing scene background color

I wanted to have the canvas transparent so I could just set the background color of the page. I passed in a config object to the WebGLRenderer constructor setting the alpha transparency to true:

const renderer = new THREE.WebGLRenderer({ alpha: true });

You can also set the scene background:

scene.background = white;

Changing cube color on user input

That was pretty nice to play with so far but now I could use those new colors to change the cube color on some sort of user input. First I set up the default color for the cube:

let cubeColor = black;

Then, I set the color of the cube every time requestAnimationFrame is called inside the animate function:

cube.material.color.set(cubeColor);

Here’s what the animate function looks like with this line added:

const animate = () => {
  requestAnimationFrame( animate );
  cube.material.color.set(cubeColor); // set cube color
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;
  renderer.render( scene, camera );
};

Now I just needed to set the cubeColor when a user types a letter on the keyboard. I mapped the keyup event to R, G, B keys—the first letter of each color I want to change the cube to:

window.addEventListener("keyup", event => {
  switch(event.code) {
    case "KeyR":
      cubeColor = red;
      break;
    case "KeyG":
      cubeColor = green;
      break;
    case "KeyB":
      cubeColor = blue;
      break;
  }
}, true);

Now I can change the cube color by typing ‘R’ for red, ‘G’ for green, and ‘B’ for blue. Here’s what that looks like:

I made a branch on GitHub for the code up to this point.

Color tweening

I thought it would be interesting to see if I could add a transition between the color changes instead of it happening instantly. I set up a cube state object to keep track of all the different states through the animation.

let state = {
  cubeColor: black,
  previousTweenColor: black,
  nextTweenColor: black,
  alphaUnit: 0,
  isTweenRunning: false
}

I also created a function to change the state and allowed me to pass in the next color to tween to:

const tweenColors = color => {
  state = {
    ...state,
    previousTweenColor: state.cubeColor,
    nextTweenColor: color,
    alphaUnit: 0,
    isTweenRunning: true
  }
}

Now I can change the keyup event to use this function within the switch statement:

window.addEventListener("keyup", event => {
  switch(event.code) {
    case "KeyR":
      tweenColors(red);
      break;
    case "KeyG":
      tweenColors(green);
      break;
    case "KeyB":
      tweenColors(blue);
      break;
  }
}, true);

The last thing to do to make the tween work is to add some logic during requestAnimationFrame:

  if (state.isTweenRunning && !state.previousTweenColor.equals(state.nextTweenColor) ) {
    state.alphaUnit = +(state.alphaUnit + 0.01).toFixed(2);
    state.cubeColor = state.cubeColor.lerpColors(state.previousTweenColor, state.nextTweenColor, state.alphaUnit);
    cube.material.color.set(state.cubeColor);
  } else {
    state.isTweenRunning = false;
  }

Let me unpack what’s going on here. I introduced a couple more Three.js color-specific methods.

!state.previousTweenColor.equals(state.nextTweenColor)

The .equals() method tests whether the previous color I had stored in state does not match the next color I wanted to tween to. So it is a true statement if the color has not fully tweened to the new color and that in turn sets the cubeColor to somewhere between the old and new using the .lerpColors() method:

state.cubeColor = state.cubeColor.lerpColors(state.previousTweenColor, state.nextTweenColor, state.alphaUnit);

The alphaUnit state is the interpolation factor value somewhere between and including 0 and 1. So if the alphaUnit value is 0.5, the cubeColor value is exactly in the middle of its color tween state. Here’s what that looks like:

MeshStandardMaterial and Lighting

That’s looking great but it doesn’t look very 3D to me. There aren’t any shadows so I thought I must need to add lighting to the scene. After reading a bit through the lighting docs I added some directional lighting to the scene:

const directionalLight = new THREE.DirectionalLight( 0xffffff );
directionalLight.position.y = 0;
directionalLight.position.z = 1;
scene.add( directionalLight );

After adding this, I didn’t see a difference in the cube and finally figured out that I needed to use a different mesh for the cube. I changed:

const material = new THREE.MeshBasicMaterial();

to:

const material = new THREE.MeshStandardMaterial();

Using MeshStandardMaterial() I was able to see the cube shadows.

I have the code on GitHub and here’s what the final JavaScript looks like:

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
const renderer = new THREE.WebGLRenderer({ alpha: true }); // adds canvas transparency
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );

const white = new THREE.Color( 0xffffff );
const grey = new THREE.Color( 0xdddddd );
const red = new THREE.Color( 0xff0000 );
const green = new THREE.Color( 0x00ff00 );
const blue = new THREE.Color( 0x0000ff );

scene.background = white;

let state = {
  cubeColor: grey,
  previousTweenColor: grey,
  nextTweenColor: grey,
  alphaUnit: 0
}

const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshStandardMaterial();
const cube = new THREE.Mesh( geometry, material );
scene.add( cube );

const directionalLight = new THREE.DirectionalLight( 0xffffff );
directionalLight.position.y = 0;
directionalLight.position.z = 1;
scene.add( directionalLight );

cube.material.color.set(state.cubeColor);
camera.position.z = 5;

window.addEventListener("keyup", event => {
  switch(event.code) {
    case "KeyR":
      tweenColors(red);
      break;
    case "KeyG":
      tweenColors(green);
      break;
    case "KeyB":
      tweenColors(blue);
      break;
  }
}, true);

const tweenColors = color => {
  state = {
    ...state,
    previousTweenColor: state.cubeColor,
    nextTweenColor: color,
    alphaUnit: 0
  }
}

const animate = () => {
  requestAnimationFrame( animate );
  cube.rotation.x += 0.01;
  cube.rotation.y += 0.01;

  if (!state.previousTweenColor.equals(state.nextTweenColor) ) {
    state.alphaUnit = +(state.alphaUnit + 0.01).toFixed(2);
    state.cubeColor = state.cubeColor.lerpColors(state.previousTweenColor, state.nextTweenColor, state.alphaUnit);
    cube.material.color.set(state.cubeColor);
  }

  renderer.render( scene, camera );
};

animate();

This was a lot of fun. I think I’ll explore a little more in the future. 😎