Creating 3D Characters in Three.js

Three.js is a JavaScript library for drawing in 3D with WebGL. It enables us to add 3D objects to a scene, and manipulate things like position and lighting. If you’re a developer used to working with the DOM and styling elements with CSS, Three.js and WebGL can seem like a whole new world, and perhaps a little intimidating! This article is for developers who are comfortable with JavaScript but relatively new to Three.js. Our goal is to walk through building something simple but effective with Three.js — a 3D animated figure — to get a handle on the basic principles, and demonstrate that a little knowledge can take you a long way!

Setting the scene

In web development we’re accustomed to styling DOM elements, which we can inspect and debug in our browser developer tools. In WebGL, everything is rendered in a single <canvas> element. Much like a video, everything is simply pixels changing color, so there’s nothing to inspect. If you inspected a webpage rendered entirely with WebGL, all you would see is a <canvas> element. We can use libraries like Three.js to draw on the canvas with JavaScript.

Basic principles

First we’re going to set up the scene. If you’re already comfortable with this you can skip over this part and jump straight to the section where we start creating our 3D character.

We can think of our Three.js scene as a 3D space in which we can place a camera, and an object for it to look at.

Drawing of a transparent cube, with a smaller cube inside, showing the x, y and z axis and center co-ordinates
We can picture our scene as a giant cube, with objects placed at the center. In actual fact, it extends infinitely, but there is a limit to how much we can see.

First of all we need to create the scene. In our HTML we just need a <canvas> element:

<canvas data-canvas></canvas>

Now we can create the scene with a camera, and render it on our canvas in Three.js:

const canvas = document.querySelector('[data-canvas]')

// Create the scene
const scene = new THREE.Scene()

// Create the camera
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 1000)
scene.add(camera)

// Create the renderer
const renderer = new THREE.WebGLRenderer({ canvas })

// Render the scene
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.render(scene, camera)

For brevity, we won’t go into the precise details of everything we’re doing here. The documentation has much more detail about creating a scene and the various camera attributes. However, the first thing we’ll do is move the position of our camera. By default, anything we add to the scene is going to be placed at co-ordinates (0, 0, 0) — that is, if we imagine the scene itself as a cube, our camera will be placed right in the center. Let’s place our camera a little further out, so that our camera can look at any objects placed in the center of the scene.

Showing the camera looking towards the center of the scene
Moving the camera away from the center allows us to see the objects placed in the center of the scene.

We can do this by setting the z position of the camera:

camera.position.z = 5

We won’t see anything yet, as we haven’t added any objects to the scene. Let’s add a cube to the scene, which will form the basis of our figure.

3D shapes

Objects in Three.js are known as meshes. In order to create a mesh, we need two things: a geometry and a material. Geometries are 3D shapes. Three.js has a selection of geometries to choose from, which can be manipulated in different ways. For the purpose of this tutorial — to see what interesting scenes we can make with just some basic principles — we’re going to limit ourselves to only two geometries: cubes and spheres.

Let’s add a cube to our scene. First we’ll define the geometry and material. Using Three.js BoxGeometry, we pass in parameters for the x, y and z dimensions.

// Create a new BoxGeometry with dimensions 1 x 1 x 1
const geometry = new THREE.BoxGeometry(1, 1, 1)

For the material we’ll choose MeshLambertMaterial, which reacts to light and shade but is more performant than some other materials.

// Create a new material with a white color
const material = new THREE.MeshLambertMaterial({ color: 0xffffff })

Then we create the mesh by combining the geometry and material, and add it to the scene:

const mesh = new THREE.Mesh(geometry, material)
scene.add(mesh)

Unfortunately we still won’t see anything! That’s because the material we’re using depends on light in order to be seen. Let’s add a directional light, which will shine down from above. We’ll pass in two arguments: 0xffffff for the color (white), and the intensity, which we’ll set to 1.

const lightDirectional = new THREE.DirectionalLight(0xffffff, 1)
scene.add(lightDirectional)
Light shining down from above at position 0, 1, 0
By default, the light points down from above

If you’ve followed all the steps so far, you still won’t see anything! That’s because the light is pointing directly down at our cube, so the front face is in shadow. If we move the z position of the light towards the camera and off-center, we should now see our cube.

const lightDirectional = new THREE.DirectionalLight(0xffffff, 1)
scene.add(lightDirectional)

// Move the light source towards us and off-center
lightDirectional.position.x = 5
lightDirectional.position.y = 5
lightDirectional.position.z = 5
Light position at 5, 5, 5
Moving the light gives us a better view

We can alternatively set the position on the x, y and z axis simultaneously by calling set():

lightDirectional.position.set(5, 5, 5)

We’re looking at our cube straight on, so only one face can be seen. If we give it a little bit of rotation, we can see the other faces. To rotate an object, we need to give it a rotation angle in [radians](). I don’t know about you, but I don’t find radians very easy to visualize, so I prefer to use a JS function to convert from degrees:

const degreesToRadians = (degrees) => {
	return degrees * (Math.PI / 180)
}

mesh.rotation.x = degreesToRadians(30)
mesh.rotation.y = degreesToRadians(30)

We can also add some ambient light (light that comes from all directions) with a color tint, which softens the effect slightly end ensures the face of the cube turned away from the light isn’t completely hidden in shadow:

const lightAmbient = new THREE.AmbientLight(0x9eaeff, 0.2)
scene.add(lightAmbient)

Now that we have our basic scene set up, we can start to create our 3D character. To help you get started I’ve created a boilerplate which includes all the set-up work we’ve just been through, so that you can jump straight to the next part if you wish.

Creating a class

The first thing we’ll do is create a class for our figure. This will make it easy to add any number of figures to our scene by instantiating the class. We’ll give it some default parameters, which we’ll use later on to position our character in the scene.

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
	}
}

Groups

In our class constructor, let’s create a Three.js group and add it to our scene. Creating a group allows us to manipulate several geometries as one. We’re going to add the different elements of our figure (head, body, arms, etc.) to this group. Then we can position, scale or rotate the figure anywhere in our scene without having to concern ourselves with individually positioning those parts individually every time.

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
		
		this.group = new THREE.Group()
		scene.add(this.group)
	}
}

Creating the body parts

Next let’s write a function to render the body of our figure. It’ll be much the same as the way we created a cube earlier, except, we’ll make it a little taller by increasing the size on the y axis. (While we’re at it, we can remove the lines of code where we created the cube earlier, to start with a clear scene.) We already have the material defined in our codebase, and don’t need to define it within the class itself.

Instead of adding the body to the scene, we instead add it to the group we created.

const material = new THREE.MeshLambertMaterial({ color: 0xffffff })

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
		
		this.group = new THREE.Group()
		scene.add(this.group)
	}
	
	createBody() {
		const geometry = new THREE.BoxGeometry(1, 1.5, 1)
		const body = new THREE.Mesh(geometry, material)
		this.group.add(body)
	}
}

We’ll also write a class method to initialize the figure. So far it will call only the createBody() method, but we’ll add others shortly. (This and all subsequent methods will be written inside our class declaration, unless otherwise specified.)

createBody() {
	const geometry = new THREE.BoxGeometry(1, 1.5, 1)
	const body = new THREE.Mesh(geometry, material)
	this.group.add(body)
}
	
init() {
	this.createBody()
}

Adding the figure to the scene

At this point we’ll want to render our figure in our scene, to check that everything’s working. We can do that by instantiating the class.

const figure = new Figure()
figure.init()

Next we’ll write a similar method to create the head of our character. We’ll make this a cube, slightly larger than the width of the body. We’ll also need to adjust the position so it’s just above the body, and call the function in our init() method:

createHead() {
	const geometry = new THREE.BoxGeometry(1.4, 1.4, 1.4)
	const head = new THREE.Mesh(geometry, material)
	this.group.add(head)
	
	// Position it above the body
	head.position.y = 1.65
}

init() {
	this.createBody()
	this.createHead()
}

You should now see a narrower cuboid (the body) rendered below the first cube (the head).

Adding the arms

Now we’re going to give our character some arms. Here’s where things get slightly more complex. We’ll add another method to our class called createArms(). Again, we’ll define a geometry and a mesh. The arms will be long, thin cuboids, so we’ll pass in our desired dimensions for these.

As we need two arms, we’ll create them in a for loop.

createArms() {
	for(let i = 0; i < 2; i++) {
		const geometry = new THREE.BoxGeometry(0.25, 1, 0.25)
		const arm = new THREE.Mesh(geometry, material)
		
		this.group.add(arm)
	}
}

We don’t need to create the geometry in the for loop, as it will be the same for each arm.

Don’t forget to call the function in our init() method:

init() {
	this.createBody()
	this.createHead()
	this.createArms()
}

We’ll also need to position each arm either side of the body. I find it helpful here to create a variable m (for multiplier). This helps us position the left arm in the opposite direction on the x axis, with minimal code. (We’ll also use it rotate the arms in a moment too.)

createArms() {
	for(let i = 0; i < 2; i++) {
		const geometry = new THREE.BoxGeometry(0.25, 1, 0.25)
		const arm = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		this.group.add(arm)
		
		arm.position.x = m * 0.8
		arm.position.y = 0.1
	}
}

Additionally, we can rotate the arms in our for loop, so they stick out at a more natural angle (as natural as a cube person can be!):

arm.rotation.z = degreesToRadians(30 * m)
Figure with co-ordinate system overlaid
If our figure is placed in the center, the arm on the left will be positioned at the negative equivalent of the x-axis position of the arm on the right

Pivoting

When we rotate the arms you might notice that they rotate from a point of origin in the center. It can be hard to see with a static demo, but try moving the slider in this example.

See the Pen
ThreeJS figure arm pivot example (default pivot from center) by Michelle Barker (@michellebarker)
on CodePen.0

We can see that the arms don’t move naturally, at an angle from the shoulder, but instead the entire arm rotates from the center. In CSS we would simply set the transform-origin. Three.js doesn’t have this option, so we need to do things slightly differently.

Two figures, the leftmost with an arm that pivots from the center, the rightmost with an arm that pivots from the top left
The figure on the right has arms that rotate from the top, for a more natural effect

Our steps are as follows for each arm:

  1. Create a new Three.js group.
  2. Position the group at the “shoulder” of our figure (or the point from which we want to rotate).
  3. Create a new mesh for the arm and position it relative to the group.
  4. Rotate the group (instead of the arm).

Let’s update our createArms() function to follow these steps. First we’ll create the group for each arm, add the arm mesh to the group, and position the group roughly where we want it:

createArms() {
	const geometry = new THREE.BoxGeometry(0.25, 1, 0.25)
	
	for(let i = 0; i < 2; i++) {
		const arm = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		// Create group for each arm
		const armGroup = new THREE.Group()
		
		// Add the arm to the group
		armGroup.add(arm)
		
		// Add the arm group to the figure
		this.group.add(armGroup)
		
		// Position the arm group
		armGroup.position.x = m * 0.8
		armGroup.position.y = 0.1
	}
}

To assist us with visualizing this, we can add one of Three.js’s built-in helpers to our figure. This creates a wireframe showing the bounding box of an object. It’s useful to help us position the arm, and once we’re done we can remove it.

// Inside the `for` loop:
const box = new THREE.BoxHelper(armGroup, 0xffff00)
this.group.add(box)

To set the transform origin to the top of the arm rather than the center, we then need to move the arm (within the group) downwards by half of its height. Let’s create a variable for height, which we can use when creating the geometry:

createArms() {
	// Set the variable
	const height = 1
	const geometry = new THREE.BoxGeometry(0.25, height, 0.25)
	
	for(let i = 0; i < 2; i++) {
		const armGroup = new THREE.Group()
		const arm = new THREE.Mesh(geometry, material)
		
		const m = i % 2 === 0 ? 1 : -1
		
		armGroup.add(arm)
		this.group.add(armGroup)
		
		// Translate the arm (not the group) downwards by half the height
		arm.position.y = height * -0.5
		
		armGroup.position.x = m * 0.8
		armGroup.position.y = 0.6
		
		// Helper
		const box = new THREE.BoxHelper(armGroup, 0xffff00)
		this.group.add(box)
	}
}

Then we can rotate the arm group.

// In the `for` loop
armGroup.rotation.z = degreesToRadians(30 * m)

In this demo, we can see that the arms are (correctly) being rotated from the top, for a more realistic effect. (The yellow is the bounding box.)

See the Pen
ThreeJS figure arm pivot example (using group) by Michelle Barker (@michellebarker)
on CodePen.0

Eyes

Next we’re going to give our figure some eyes, for which we’ll use the Sphere geometry in Three.js. We’ll need to pass in three parameters: the radius of the sphere, and the number of segments for the width and height respectively (defaults shown here).

const geometry = new THREE.SphereGeometry(1, 32, 16)

As our eyes are going to be quite small, we can probably get away with fewer segments, which is better for performance (fewer calculations needed).

Let’s create a new group for the eyes. This is optional, but it helps keep things neat. If we need to reposition the eyes later on, we only need to reposition the group, rather than both eyes individually. Once again, let’s create the eyes in a for loop and add them to the group. As we want the eyes to be a different color from the body, we can define a new material:

createEyes() {
	const eyes = new THREE.Group()
	const geometry = new THREE.SphereGeometry(0.15, 12, 8)
	
	// Define the eye material
	const material = new THREE.MeshLambertMaterial({ color: 0x44445c })
	
	for(let i = 0; i < 2; i++) {
		const eye = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		// Add the eye to the group
		eyes.add(eye)
		
		// Position the eye
		eye.position.x = 0.36 * m
	}
}

We could add the eye group directly to the figure. However, if we decide we want to move the head later on, it would be better if the eyes moved with it, rather than being positioned entirely independently! For that, we need to modify our createHead() method to create another group, comprising both the main cube of the head, and the eyes:

createHead() {
	// Create a new group for the head
	this.head = new THREE.Group()
	
	// Create the main cube of the head and add to the group
	const geometry = new THREE.BoxGeometry(1.4, 1.4, 1.4)
	const headMain = new THREE.Mesh(geometry, material)
	this.head.add(headMain)
	
	// Add the head group to the figure
	this.group.add(this.head)
	
	// Position the head group
	this.head.position.y = 1.65
	
	// Add the eyes by calling the function we already made
	this.createEyes()
}

In the createEyes() method we then need to add the eye group to the head group, and position them to our liking. We’ll need to position them forwards on the z axis, so they’re not hidden inside the cube of the head:

// in createEyes()
this.head.add(eyes)

// Move the eyes forwards by half of the head depth - it might be a good idea to create a variable to do this!
eyes.position.z = 0.7

Legs

Lastly, let’s give our figure some legs. We can create these in much the same way as the eyes. As they should be positioned relative to the body, we can create a new group for the body in the same way that we did with the head, then add the legs to it:

createLegs() {
	const legs = new THREE.Group()
	const geometry = new THREE.BoxGeometry(0.25, 0.4, 0.25)
	
	for(let i = 0; i < 2; i++) {
		const leg = new THREE.Mesh(geometry, material)
		const m = i % 2 === 0 ? 1 : -1
		
		legs.add(leg)
		leg.position.x = m * 0.22
	}
	
	this.group.add(legs)
	legs.position.y = -1.15
	
	this.body.add(legs)
}

Positioning in the scene

If we go back to our constructor, we can position our figure group according to the parameters:

class Figure {
	constructor(params) {
		this.params = {
			x: 0,
			y: 0,
			z: 0,
			ry: 0,
			...params
		}
		
		this.group.position.x = this.params.x
		this.group.position.y = this.params.y
		this.group.position.z = this.params.z
		this.group.rotation.y = this.params.ry
	}
}

Now, passing in different parameters enables us to position it accordingly. For example, we can give it a bit of rotation, and adjust its x and y position:

const figure = new Figure({ 
	x: -4,
	y: -2,
	ry: degreesToRadians(35)
})
figure.init()

Alternatively, if we want to center the figure within the scene, we can use the Three.js Box3 function, which computes the bounding box of the figure group. This line will center the figure horizontally and vertically:

new THREE.Box3().setFromObject(figure.group).getCenter(figure.group.position).multiplyScalar(-1)

Making it generative

At the moment our figure is all one color, which doesn’t look particularly interesting. We can add a bit more color, and take the extra step of making it generative, so we get a new color combination every time we refresh the page! To do this we’re going to use a function to randomly generate a number between a minimum and a maximum. This is one I’ve borrowed from George Francis, which allows us to specify whether we want an integer or a floating point value (default is an integer).

const random = (min, max, float = false) => {
  const val = Math.random() * (max - min) + min

  if (float) {
    return val
  }

  return Math.floor(val)
}

Let’s define some variables for the head and body in our class constructor. Using the random() function, we’ll generate a value for each one between 0 and 360:

class Figure {
	constructor(params) {
		this.headHue = random(0, 360)
		this.bodyHue = random(0, 360)
	}
}

I like to use HSL when manipulating colors, as it gives us a fine degree of control over the hue, saturation and lightness. We’re going to define the material for the head and body, generating different colors for each by using template literals to pass the random hue values to the hsl color function. Here I’m adjusting the saturation and lightness values, so the body will be a vibrant color (high saturation) while the head will be more muted:

class Figure {
	constructor(params) {
		this.headHue = random(0, 360)
		this.bodyHue = random(0, 360)
		
		this.headMaterial = new THREE.MeshLambertMaterial({ color: `hsl(${this.headHue}, 30%, 50%` })
		this.bodyMaterial = new THREE.MeshLambertMaterial({ color: `hsl(${this.bodyHue}, 85%, 50%)` })
	}
}

Our generated hues range from 0 to 360, a full cycle of the color wheel. If we want to narrow the range (for a limited color palette), we could select a lower range between the minimum and maximum. For example, a range between 0 and 60 would select hues in the red, orange and yellow end of the spectrum, excluding greens, blues and purples.

We could similarly generate values for the lightness and saturation if we choose to.

Now we just need to replace any reference to material with this.headMaterial or this.bodyMaterial to apply our generative colors. I’ve chosen to use the head hue for the head, arms and legs.

See the Pen
ThreeJS figure (generative) by Michelle Barker (@michellebarker)
on CodePen.0

We could use generative parameters for much more than just the colors. In this demo I’m generating random values for the size of the head and body, the length of the arms and legs, and the size and position of the eyes.

See the Pen
ThreeJS figure random generated by Michelle Barker (@michellebarker)
on CodePen.0

Animation

Part of the fun of working with 3D is having our objects move in a three-dimensional space and behave like objects in the real world. We can add a bit of animation to our 3D figure using the Greensock animation library (GSAP).

GSAP is more commonly used to animate elements in the DOM. As we’re not animating DOM elements in this case, it requires a different approach. GSAP doesn’t require an element to animate — it can animate JavaScript objects. As one post in the GSAP forum puts it, GSAP is just “changing numbers really fast”.

We’ll let GSAP do the work of changing the parameters of our figure, then re-render our figure on each frame. To do this, we can use GSAP’s ticker method, which uses requestAnimationFrame. First, let’s animate the ry value (our figure’s rotation on the y axis). We’ll set it to repeat infinitely, and the duration to 20 seconds:

gsap.to(figure.params, {
	ry: degreesToRadians(360),
	repeat: -1,
	duration: 20
})

We won’t see any change just yet, as we aren’t re-rendering our scene. Let’s now trigger a re-render on every frame:

gsap.ticker.add(() => {
	// Update the rotation value
	figure.group.rotation.y = this.params.ry
	
	// Render the scene
	renderer.setSize(window.innerWidth, window.innerHeight)
	renderer.render(scene, camera)
})

Now we should see the figure rotating on its y axis in the center of the scene. Let’s give him a little bounce action too, by moving him up and down and rotating the arms. First of all we’ll set his starting position on the y axis to be a little further down, so he’s not bouncing off screen. We’ll set yoyo: true on our tween, so that the animation repeats in reverse (so our figure will bounce up and down):

// Set the starting position
gsap.set(figure.params, {
	y: -1.5
})

// Tween the y axis position and arm rotation
gsap.to(figure.params, {
	y: 0,
	armRotation: degreesToRadians(90),
	repeat: -1,
	yoyo: true,
	duration: 0.5
})

As we need to update a few things, let’s create a method called bounce() on our Figure class, which will handle the animation. We can use it to update the values for the rotation and position, then call it within our ticker, to keep things neat:

/* In the Figure class: */
bounce() {
	this.group.rotation.y = this.params.ry
	this.group.position.y = this.params.y
}

/* Outside of the class */
gsap.ticker.add(() => {
	figure.bounce()
	
	// Render the scene
	renderer.setSize(window.innerWidth, window.innerHeight)
	renderer.render(scene, camera)
})

To make the arms move, we need to do a little more work. In our class constructor, let’s define a variable for the arms, which will be an empty array:

class Figure {
	constructor(params) {
		this.arms = []
	}
}

In our createArms() method, in addition to our code, we’ll push each arm group to the array:

createArms() {
	const height = 0.85
	
	for(let i = 0; i < 2; i++) {
		/* Other code for creating the arms.. */
		
		// Push to the array
		this.arms.push(armGroup)
	}
}

Now we can add the arm rotation to our bounce() method, ensuring we rotate them in opposite directions:

bounce() {
	// Rotate the figure
	this.group.rotation.y = this.params.ry
	
	// Bounce up and down
	this.group.position.y = this.params.y
	
	// Move the arms
	this.arms.forEach((arm, index) => {
		const m = index % 2 === 0 ? 1 : -1
		arm.rotation.z = this.params.armRotation * m
	})
}

Now we should see our little figure bouncing, as if on a trampoline!

See the Pen
ThreeJS figure with GSAP by Michelle Barker (@michellebarker)
on CodePen.0

Wrapping up

There’s much, much more to Three.js, but we’ve seen that it doesn’t take too much to get started building something fun with just the basic building blocks, and sometimes limitation breeds creativity! If you’re interested in exploring further, I recommend the following resources.

Resources

  • Three.js Journey video course by Bruno Simon (includes transcripts and starter projects every lesson)
  • Three.js Fundamentals
  • Three.js documentation
  • A Generative SVG Starter Kit by George Francis — not Three.js-related, but covers some of the principles of generative art, and some handy utility functions
  • Greensock documentation