Creating Photorealistic 3D Graphics on the Web

Before becoming a web developer, I worked in the visual effects industry, creating award-winning, high-end 3D effects for movies and TV Shows such as Tron, The Thing, Resident Evil, and Vikings. To be able to create these effects, we would need to use highly sophisticated animation software such as Maya, 3Ds Max or Houdini and do long hours of offline rendering on Render Farms that consisted of hundreds of machines. It's because I worked with these tools for so long that I am now amazed by the state of the current web technology. We can now create and display high-quality 3D content right inside the web browser, in real time, using WebGL and Three.js.

Here is an example of a project that is built using these technologies. You can find more projects that use three.js on their website.

Some projects using three.js

As the examples on the three.js website demonstrate, 3D visualizations have a vast potential in the domains of e-commerce, retail, entertainment, and advertisement.

WebGL is a low-level JavaScript API that enables creation and display of 3D content inside the browser using the GPU. Unfortunately, since WebGL is a low-level API, it can be a bit hard and tedious to use. You need to write hundreds of lines of code to perform even the simplest tasks. Three.js, on the other hand, is an open source JavaScript library that abstracts away the complexity of WebGL and allows you to create real-time 3D content in a much easier manner.

In this tutorial, I will be introducing the basics of the three.js library. It makes sense to start with a simple example to communicate the fundamentals better when introducing a new programming library but I would like to take this a step further. I will also aim to build a scene that is aesthetically pleasant and even photorealistic to a degree.

We will just start out with a simple plane and sphere but in the end it will end up looking like this:

See the Pen learning-threejs-final by Engin Arslan (@enginarslan) on CodePen.

Photorealism is the pinnacle of computer graphics but achieving is not necessarily a factor of the processing power at your disposal but a smart deployment of techniques from your toolbox. Here are a few techniques that you will be learning about in this tutorial that will help your scenes achieve photo-realism.

  • Color, Bump and Roughness Maps.
  • Physically Based Materials.
  • Lighting with Shadows.
Photorealistic 3D portrait by Ian Spriggs

The basic 3D principles and techniques that you will learn here are relevant in any other 3D content creation environment whether it is Blender, Unity, Maya or 3Ds Max.

This is going to be a long tutorial. If you are more of a video person or would like to learn more about the capabilities of three.js you should check out my video training on the subject from Lynda.com.

Requirements

When using three.js, if you are working locally, it helps to serve the HTML file through a local server to be able to load in scene assets such as external 3D geometry, images, etc. If you are looking for a server that is easy to setup, you can use Python to spin up a simple HTTP Server. Python is pre-installed on many operating systems.

You don't have to worry about setting a local dev server to follow this tutorial though. You will instead rely on data URL's to load in assets like images to remove the overhead of setting up a server. Using this method you will be able to easily execute your three.js scene in online code editors such as CodePen.

This tutorial assumes a prior, basic to intermediate, knowledge of JavaScript and some understanding of front-end web development. If you are not comfortable with JavaScript but want to get started with it in an easy manner you might want to check out the course/book "Coding for Visual Learners: Learning JavaScript with p5.js". (Disclaimer: I am the author)

Let's get started with building 3D graphics on the Web!

Getting Started

I have already prepared a Pen that you can use to follow this tutorial with.

The HTML code that you will be using is going to be super simple. It just needs to have a div element to host the canvas that is going the display the 3D graphics. It also loads up the three.js library (release 86) from a CDN.

<div id="webgl"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/86/three.min.js"></script> 

Codepen hides some of the HTML structure that is currently present for your convenience. If you were building this scene on some other online editors or on your local your HTML would need to have something like this code below where main.js would be the file that would hold the JavaScript code.

<!DOCTYPE html>
<html>
<head>
    <title>Three.js</title>
    <style type="text/css">
        html, body {
            margin: 0;
            padding: 0;
            overflow: hidden;
        }
    </style>
</head>
<body>
    <div id="webgl"></div>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/86/three.min.js"></script>
    <script src="./main.js"></script>
</body>
</html>

Notice the simple CSS declaration inside the HTML. This is what you would have in the CSS tab of Codepen:

html, body {
  margin: 0;
  padding: 0;
  overflow: hidden;
}

This is to ensure that you don't have any margin and padding values that might be applied by your browser and you don't get a scrollbar so that you can have the graphics fill the entire screen. This is all we need to get started with building 3D Graphics.

Part 1 - Three.js Scene Basics

When working with three.js and with 3D in general, there are a couple of required objects you need to have. These objects are scene, camera and the renderer.

First, you should create a scene. You can think of a scene object as a container for every other 3D object that you are going to work with. It represents the 3D world that you will be building. You can create the scene object by doing this:

 var scene = new THREE.Scene();

Another thing that you need to have when working with 3D is the camera. Think of camera like the eyes that you will be viewing this 3D world through. When working with a 2D visualization, the concept of a camera usually doesn't exist. What you see is what you get. But in 3D, you need a camera to define your point of view as there are many positions and angles that you could be looking at a scene from. A camera doesn't only define a position but also other information like the field of view or the aspect ratio.

var camera = new THREE.PerspectiveCamera(
    45, // field of view
    window.innerWidth / window.innerHeight, // aspect ratio
    1, // near clipping plane (beyond which nothing is visible)
    1000 // far clipping plane (beyond which nothing is visible)
);

The camera captures the scene for display purposes but for us to actually see anything, the 3D data needs to be converted into a 2D image. This process is called rendering and you need a renderer to render the scene in three.js. You can initialize a renderer like this:

var renderer = new THREE.WebGLRenderer();

And then set the size of the renderer. This will dictate the size of the output image. You will make it cover the window size.

renderer.setSize(window.innerWidth, window.innerHeight);

To be able to display the results of the render you need to append the domElement property of the renderer to your HTML content. You will use the empty div element that you created that has the id webgl for this purpose.

document.getElementById('webgl').appendChild(renderer.domElement);

And having done all this you can call the render method on the renderer by providing the scene and the camera as the arguments.

renderer.render(
    scene,
    camera
);

To have things a bit tidier, put everything inside a function called init and execute that function.

init();

And now you would see nothing... but a black screen. Don't worry, this is normal. The scene is working but since you didn't include any objects inside the scene, what you are looking at is basically empty space. Next, you will be populating this scene with 3D objects.

See the Pen learning-threejs-01 by Engin Arslan (@enginarslan) on CodePen.

Adding Objects to the Scene

Geometric objects in three.js are made up of two parts. A geometry that defines the shape of the object and a material that defines the surface quality, the appearance, of the object. The combination of these two things makes up a mesh in three.js which forms the 3D object.

Three.js allows you to create some simple shapes like a cube or a sphere in an easy manner. You can create a simple sphere by providing the radius value.

var geo = new THREE.SphereGeometry(1);

There are various kinds of materials that you could use on geometries. Materials determine how an object reacts to the scene lighting. We can use a material to make an object reflective, rough, transparent, etc.. The default material that three.js objects are created with is the MeshBasicMaterial. MeshBasicMaterial is not affected by the scene lighting at all. This means that your geometry is going to be visible even when there is no lighting in the scene. You can pass an object with a color property and a hex value to the MeshBasicMaterial to be able to set the desired color for the object. You will use this material for now but later update it to have your objects be affected by the scene lighting. You don't have any lighting in the scene for now so MeshBasicMaterial should be a good enough choice.

var material = new THREE.MeshBasicMaterial({
        color: 0x00ff00
});

You can combine the geometry and material to create a mesh which is going to form the 3D object.

var mesh = new THREE.Mesh(geometry, material);

Create a function to encapsulate this code that creates a sphere. You won't be creating more than one sphere in this tutorial but it is still good to keep things neat and tidy.

function getSphere(radius) {
    var geometry = new THREE.SphereGeometry(radius);
    var material = new THREE.MeshBasicMaterial({
        color: 0x00ff00
    });
    var sphere = new THREE.Mesh(geometry, material);
    return sphere;
}

var sphere = getSphere(1);

Then you need to add this newly created object to the scene for it to be visible.

scene.add(sphere);

Let's check out the scene again. You will still see a black screen.

See the Pen learning-threejs-02 by Engin Arslan (@enginarslan) on CodePen.

The reason why you don't see anything right now is that whenever you add an object to the scene in three.js, the object gets placed at the center of the scene, at the coordinates of 0, 0, 0 for x, y and z. This simply means that you currently have the camera and the sphere at the same position. You should change the position of either one of them to be able to start seeing things.

3D coordinates

Let's move the camera 20 units on the z axis. This is achieved by setting the position.z property on the camera. 3D objects have position, rotation and scale properties that would allow you to transform them into the 3D space.

camera.position.z = 20;

You could move the camera on other axises as well.

camera.position.x = 0;
camera.position.y = 5;
camera.position.z = 20;

The camera is positioned higher now but the sphere is not at the center of the frame anymore. You need to point the camera to it. To be able to do so, you can call a method on the camera called lookAt. The lookAt method on the camera determines which point the camera is looking at. The points in the 3D space are represented by Vectors. So you can pass a new Vector3 object to this lookAt method to be able to have the camera look at the 0, 0, 0 coordinates.

camera.lookAt(new THREE.Vector3(0, 0, 0));

The sphere object doesn't look too smooth right now. The reason for that is the SphereGeometry function actually accepts two additional parameters, the width and height segments, that affects the resolution of the surface. Higher these values, smoother the curved surfaces will appear. I will set this value to be 24 for width and height segments.

var geo = new THREE.SphereGeometry(radius, 24, 24);

See the Pen learning-threejs-03 by Engin Arslan (@enginarslan) on CodePen.

Now you will create a simple plane geometry for the sphere to be sitting on. PlaneGeometry function requires a width and height parameter. In 3D, 2D objects don't have both of their sides rendering by default so you need to pass a side property to the material to have both sides of the plane geometry to render.

function getPlane(w, h) {
  var geo = new THREE.PlaneGeometry(w, h);
  var material = new THREE.MeshBasicMaterial({
    color: 0x00ff00,
    side: THREE.DoubleSide,
  });
  var mesh = new THREE.Mesh(geo, material);

  return mesh;
}

You can now add this plane object to the scene as well. You will notice that the initial rotation of the plane geometry is parallel to the y-axis but you will likely need it to be horizontal for it to act as a ground plane. There is one important thing you should keep in mind regarding the rotations in three.js though. They use radians as a unit, not degrees. A rotation of 90 degrees in radians is equivalent to Math.PI/2.

var plane = getPlane(50, 50);
scene.add(plane);
plane.rotation.x = Math.PI/2;

When you created the sphere object, it got positioned using its center point. If you would like to move it above the ground plane then you can just increase its position.y value by the current radius amount. But that wouldn't be a programmatic way of doing things. If you would like the sphere to stay on the plane whatever its radius value is, you should make use of the radius value for the positioning.

sphere.position.y = sphere.geometry.parameters.radius;

See the Pen learning-threejs-04 by Engin Arslan (@enginarslan) on CodePen.

Animations

You are almost done with the first part of this tutorial. But before we wrap it up, I want to illustrate how to do animations in three.js. Animations in three.js make use of the requestAnimationFrame method on the window object which repeatedly executes a given function. It is somewhat like a setInterval function but optimized for the browser drawing performance.

Create an update function and pass the renderer, scene, and camera in there to execute the render method of the renderer inside this function. You will also declare a requestAnimationFrame function inside there and call this update function recursively from a callback function that is passed to the requestAnimationFrame function. It is better to illustrate this in code than to write about it.

function update(renderer, scene, camera) {
  renderer.render(scene, camera);

  requestAnimationFrame(function() {
    update(renderer, scene, camera);
  });
}

Everything might look same to you at this point but the core difference is that the requestAnimationFrame function is making the scene render around 60 frames per second with a recursive call to the update function. Which means that if you are to execute a statement inside the update function, that statement would get executed at around 60 times per second. Let's add a scaling animation to the sphere object. To be able to select the sphere object from inside the update function you could pass it as an argument but we will use a different technique. First, set a name attribute on the sphere object and give it a name of your liking.

sphere.name = 'sphere';

Inside the update function, you could find this object using its name by using the getObjectByName method on its parent object, the scene.

var sphere = scene.getObjectByName('sphere');
sphere.scale.x += 0.01;
sphere.scale.z += 0.01;

With this code, the sphere is now scaling on its x and z axises. Our intention is not to create a scaling sphere though. We are setting up the update function so that you can leverage for different animations later on. Now that you have seen how it works you can remove this scaling animation.

See the Pen learning-threejs-05 by Engin Arslan (@enginarslan) on CodePen.

Part 2 - Adding Realism to the Scene

Currently, we are using MeshBasicMaterial which displays the given color even when there is no lighting in the scene which results in a very flat look. Real-world materials don't work this way though. The visibility of the surface in the real world depends on how much light is reflecting back from the surface back to our eyes. Three.js comes with a couple of different materials that provide a better approximation of how real-world surfaces behave and one of them is the MeshStandardMaterial. MeshStandardMaterial is a physically based rendering material that can help you achieve photorealistic results. This is the kind of material that modern game engines like Unreal or Unity use and is an industry standard in gaming and visual effects.

Let's start using the MeshStandardMaterial on our objects and change the color of the materials to white.

var material = new THREE.MeshStandardMaterial({
  color: 0xffffff,
});

You will once again get a black render at this point. That is normal. For objects to be visible we need to have lights in the scene. This wasn't a requirement with MeshBasicMaterial as it is a simple material that displays the given color at all conditions but other materials require an interaction with light to be visible. Let's create a SpotLight creating function. You will be creating two spotlights using this function.

function getSpotLight(color, intensity) {
  var light = new THREE.SpotLight(color, intensity);

  return light;
}

var spotLight_01 = getSpotlight(0xffffff, 1);
scene.add(spotLight_01);

You might start seeing something at this point. Position the light and the camera a bit differently for a better framing and shading. Also create a secondary light as well.

var spotLight_02 = getSpotlight(0xffffff, 1);
scene.add(spotLight_02);

camera.position.x = 0;
camera.position.y = 6;
camera.position.z = 6;

spotLight_01.position.x = 6;
spotLight_01.position.y = 8;
spotLight_01.position.z = -20;

spotLight_02.position.x = -12;
spotLight_02.position.y = 6;
spotLight_02.position.z = -10;

Having done this you have two light sources in the scene, illuminating the sphere from two different positions. The lighting is helping a bit in understanding the dimensionality of the scene, but things are still looking extremely fake at this point because the lighting is missing a critical component: the shadows!

Rendering a shadow in Three.js is unfortunately not too straightforward. This is because shadows are computationally expensive and we need to activate shadow rendering on multiple places. First, you need to tell the renderer to start rendering shadows:

var renderer = new THREE.WebGLRenderer();
renderer.shadowMap.enabled = true;

Then you need to tell the light to cast shadows. Do that in the getSpotLight function.

light.castShadow = true;

You should also tell the objects to cast and/or receive shadows. In this case, you will make the sphere cast shadows and the plane to receive shadows.

mesh.castShadow = true;
mesh.receiveShadow = true;

After all these settings we should start seeing shadows in the scene. Initially, they might be a bit lower quality. You can increase the resolution of the shadows by setting the light shadow map size.

light.shadow.mapSize.x = 4096;
light.shadow.mapSize.y = 4096;

MeshStandardMaterial has a couple of properties such as the roughness and metalness that controls the interaction of the surface with the light. The properties take values in between 0 and 1 and they control the corresponding behavior of the surface. Increase the roughness value on the plane material to 1 to see the surface look more like a rubber as the reflections get blurrier.

// material adjustments
var planeMaterial = plane.material;
planeMaterial.roughness = 1;

We won't be using 1 as a value in this tutorial though. Feel free to experiment with values but set it back to 0.65 for roughness and 0.75 for metalness.

planeMaterial.roughness = 0.65;
planeMaterial.metalness = 0.75;

Even though the scene should be looking much more promising right now it is still hard to call it realistic. The truth is, it is very hard to establish photorealism in 3D without using texture maps.

See the Pen learning-threejs-06 by Engin Arslan (@enginarslan) on CodePen.

Texture Maps

Texture maps are 2D images that can be mapped on a material for the purpose of providing surface detail. So far you were only getting solid colors on the surfaces but using a texture map you can map any image you would like on a surface. Texture maps are not only used to manipulate the color information of surfaces but they can also be used to manipulate other qualities of the surface like reflectiveness, shininess, roughness, etc.

Textures can be derived from photographic sources or can also be painted from scratch as well. For a texture to be useful in a 3D context it should be captured in a certain manner. Images that have reflections or shadows in them, or images where the perspective is too distorted wouldn't make great texture maps. There are several dedicated websites for finding textures online. One of them is textures.com which has a pretty good archive. They have some free download options but requires you to register to be able to do so. Another website for 3D textures is Megascans which does high resolution, high-quality environment scans that are of high-end production quality.

I have used a website called mb3d.co.uk for this example. This site provides seamless, free to use textures. A seamless texture implies a texture that can be repeated on the surface many times without having any discontinuations where the edges meet. This is the link to the texture file that I have used. I have decreased the size to 512px for width and height and converted the image file to data URI using an online service called ezgif to be able to include it as part of the JavaScript code as opposed to loading it in as a separate asset. (hint: don't include tags as you are outputting the data if you are to use this service)

Create a function that returns the data URI we have generated so that we don't have to put that huge string in the middle of our code.

function getTexture() {
    var data = 'data:image/jpeg;base64,/...'; // paste your data URI inside the quotation marks.
    return data
}

Next, you need to load in the texture and apply it on the plane surface. You will be using the three.js TextureLoader for this purpose. After loading in the texture you will load in the texture to the map property of the desired material to have it as a color map on the surface.

var textureLoader = new THREE.TextureLoader();
var texture = textureLoader.load(getTexture());
planeMaterial.map = texture;

Things would be looking rather ugly right now as the texture on the surface is pixelated. The image is stretching too much to cover the entire surface. What you can do is to make the image repeat itself instead of scaling so that it doesn't get as pixelated. To do so, you need to set the wrapS and wrapT properties on the desired map to THREE.RepeatWrapping and specify a repetition value. Since you will be doing this for other kinds of maps as well (like bump or roughness map) it is better to create a loop for this:

var repetition = 6;
var textures = ['map']// we will add 'bumpMap' and 'roughnessMap'
textures.forEach((mapName) => {
  planeMaterial[mapName].wrapS = THREE.RepeatWrapping;
  planeMaterial[mapName].wrapT = THREE.RepeatWrapping;
  planeMaterial[mapName].repeat.set(repetition, repetition);
});

This should look much better. Since the texture you are using is seamless you wouldn't notice any disconnections around the edges where the repetition happens.

Loading of a texture is actually an asynchronous operation. This means that your 3D scene is generated before the image file is loaded in. But since you are continuously rendering the scene using requestAnimationFrame this doesn't cause any issues in this example. If you weren't doing this, you would need to use callbacks or other async methods to manage the loading order.

See the Pen learning-threejs-07 by Engin Arslan (@enginarslan) on CodePen.

Other Texture Maps

As mentioned in the previous chapter, textures are not only used to define the color of the surfaces but to define other qualities of it as well. One other way that textures can be used as are bump maps. When used as a bump map, the brightness values of the texture simulates a height effect.

planeMaterial.bumpMap = texture;

Bump map should also be using the same repetition configuration as the color map so include it in the textures array.

var textures = ['map', 'bumpMap'];

With a bump map, brighter the value of a pixel, higher the corresponding surface would look. But a bump map doesn't actually change the surface, it just manipulates how the light interacts with the surface to create an illusion of uneven topology. The bump amount looks a bit too much right now. Bump maps work best when they are used in subtle amounts. So let's change the bumpScale parameter to something lower for a more subtle effect.

planeMaterial.bumpScale = 0.01;

Notice how this texture made a huge difference in appearance. The reflections are not perfect anymore but nicely broken up as they would be in real life. Another kind of map slot that is available to the StandardMaterial is the roughness map. A texture map used as a roughness map allows you to control the sharpness of the reflections using the brightness values of a given image.

planeMaterial.roughnessMap = texture;
var textures = ['map', 'bumpMap', 'roughnessMap'];

According to the three.js documentation, the StandardMaterial works best when used in conjunction with an environment map. An environment map simulates a distant environment reflecting off of the reflective surfaces in the scene. It really helps when you are trying to simulate reflectivity on objects. Environment maps in three.js are in the form of cube maps. A Cube map is a panoramic view of a scene that is mapped inside a cube. A cube map is made up of 6 separate images that correspond to each face of a cube. Since loading 6 mode images inside an online editor is going to be a bit too much work you won't actually be using an environment map in this example. But to be able to make this sphere object a bit more interesting, add a roughness map to it as well. You will be using this texture but 320x320px in size and as a data URI.

Create a new function called getMetalTexture

function getMetalTexture() {
    var data = 'data:image/jpeg;base64,/...'; // paste your data URI inside the quotation marks.
    return data
}

And apply it on the sphere material as bumpMap and roughnessMap:

var sphereMaterial = sphere.material;
var metalTexture = textureLoader.load(getMetalTexture());

sphereMaterial.bumpMap = metalTexture;
sphereMaterial.roughnessMap = metalTexture;
sphereMaterial.bumpScale = 0.01;
sphereMaterial.roughness = 0.75;
sphereMaterial.metalness = 0.25;

See the Pen learning-threejs-08 by Engin Arslan (@enginarslan) on CodePen.

Wrapping it up!

You are almost done! Here you will do just a couple of small tweaks. You can see the final version of this scene file in this Pen.

Provide a non-white color to the lights. Notice how you can actually use CSS color values as strings to specify color:

var spotLight_01 = getSpotlight('rgb(145, 200, 255)', 1);
var spotLight_02 = getSpotlight('rgb(255, 220, 180)', 1);

And add some subtle random flickering animation to the lights to add some life to the scene. First, assign a name property to the lights so you can locate them inside the update function using the getObjectByName method.

spotLight_01.name = 'spotLight_01';
spotLight_02.name = 'spotLight_02';

And then create the animation inside the update function using the Math.random() function.

var spotLight_01 = scene.getObjectByName('spotLight_01');
spotLight_01.intensity += (Math.random() - 0.5) * 0.15;
spotLight_01.intensity = Math.abs(spotLight_01.intensity);

var spotLight_02 = scene.getObjectByName('spotLight_02');
spotLight_02.intensity += (Math.random() - 0.5) * 0.05;
spotLight_02.intensity = Math.abs(spotLight_02.intensity);

And as a bonus, inside the scene file, I have included the OrbitControls script for the three.js camera which means that you can actually drag your mouse on the scene to interact with the camera! I have also made it so that the scene resizes with the changing window size. I have achieved this using an external script for convenience.

See the Pen learning-threejs-final by Engin Arslan (@enginarslan) on CodePen.

Now, this scene is somewhat close to becoming photorealistic. There are still many missing pieces though. The sphere ball is too dark due to lack of reflections and ambient lighting. The ground plane is looking too flat in the glancing angles. The profile of the sphere is too perfect - it is CG (Computer Graphics) perfect. The lighting is not actually as realistic as it could be; It doesn't decay (lose intensity) with the distance from the source. You should also probably add particle effects, camera animation, and post-processing filters if you want to go all the way with this. But this still should be a good enough example to illustrate the power of three.js and the quality of graphics that you can create inside the browser. For more information on what you could achieve using this amazing library, you should definitely check out my new course on Lynda.com about the subject!

Thanks for making it this far! Hope you enjoyed this write-up and feel free to reach to me @inspiratory on Twitter or on my website with any questions you might have!