Physically Based Rendering and Lighting
Recently, physically based rendering (PBR) has become the industry-standard method of rendering both real-time and cinematic 3D scenes. As the name suggests, this rendering technique uses real-world physics to calculate the way surfaces react to light, taking the guesswork out of setting up materials and lighting in your scenes. PBR was created by Disney for their feature-length animations and is also used in modern game engines such as Unreal and Frostbite. Amazingly, the tiny (600kb when compressed) three.js core allows us to use the same physically correct rendering techniques as these industry-leading giants, and not only that, but we can run these even on low-power devices such as smartphones. Only a few years ago, this was cutting-edge tech that required huge banks of powerful computers, and now we can run this in a web browser, from anywhere.
Using PBR in three.js is as simple as switching the material we use and adding a light source. We’ll introduce the most important three.js PBR material, the MeshStandardMaterial
, below. We won’t get into the technical details of physically based rendering in this book, but if you’re interested in learning more, the brilliant, Academy Award winning book (yes, they give Oscars to books, apparently)
Physically Based Rendering: From Theory To Implementation is completely free.
Lighting and Materials
Lighting and materials are intrinsically linked in computer graphics rendering systems. We can’t talk about one without the other, which is why, in this chapter, we’re also introducing a new light: the DirectionalLight
. This light type mimics rays from a faraway light source like the sun. We’ll explore how lights and materials interact in more detail later in the book. To use PBR materials such as the MeshStandardMaterial
, we must add a light to the scene. This makes sense - if there is no light, we cannot see. The MeshBasicMaterial
we’ve been using so far is not physically based and does not require a light.
Day to Night with the Flick of a Switch
Creating good-looking scenes using old-school, non-physically based rendering involves a lot of tedious tweaking. Consider this scenario: you’ve set up a day-time dining room scene for an architectural showcase, with sunlight streaming through the windows creating beautiful highlights and shadows around the room. Later, you decide to add a night-time mode to show off the lighting fixtures around the room. Using non-PBR techniques, setting this up would be a lot of work. All lighting and material parameters would need to be tweaked, and then re-tweaked and then re-tweaked again until the night scene looks as good as the day scene.
Now, imagine the same scenario, but this time you’re using physically correct lighting and materials. To switch day-time to night-time, you simply turn off the light representing the sun and switch on the lights in the light fixtures. That main ceiling light is a hundred-watt incandescent bulb? Examine the packaging of the equivalent bulb in the real world, note how many lumens it outputs, and then use that value in your code, and you are done.
Well crafted physically based materials look great in all lighting conditions.
Enable Physically Correct Lighting
Before we add a light to our scene, we’ll switch to using physically correct lighting intensity calculations. Physically correct lighting is not the same thing as physically based rendering, however, it makes sense to use both together to give us a complete physically accurate scene. Physically correct lighting means calculating how light fades with distance from a light source (attenuation) using real-world physics equations. This is fairly simple to calculate and you can find these equations in any physics textbook. On the other hand, physically based rendering involves calculating, in a physically correct manner, how light reacts with surfaces. These equations are much more complex, at least for any surface more complicated than a mirror. Fortunately, we don’t have to understand them to use them!
To turn on physically correct lighting, simply enable the renderer’s
.physicallyCorrectLights
setting:
function createRenderer() {
const renderer = new WebGLRenderer();
// turn on the physically correct lighting model
renderer.physicallyCorrectLights = true;
return renderer;
}
This setting is disabled by default to maintain backward compatibility. However, there are no downsides to turning it on so we’ll always enable it. There are a few more parameters we need to tweak to get colors and lighting working in a physically correct manner. However, by enabling this setting we’ve taken the important first step towards production-grade, physically-accurate lighting in our scenes.
Create Physically Sized Scenes
For physically correct lighting to be accurate, you need to build physically sized scenes. There’s no point in using data from a real bulb if your room is a thousand kilometers wide! If you want a hundred-watt bulb to light a room in the same way the equivalent bulb in the equivalent real room does, you have to build the room to the correct scale using meters.
Units of size in three.js are meters
- The $2\times 2 \times 2$ cube we created earlier is two meters long on each side.
camera.far = 100
means we can see for a distance of one hundred meters.camera.near = 0.1
means objects closer to the camera than ten centimeters will not be visible.
Using meters is a convention rather than a rule. If you don’t follow it, everything except for physically accurate lighting will still work. Indeed, there are situations where it makes sense to use a different scale. For example, if you’re building a huge-scale space simulation you might decide to use $ 1 \text{ unit} = 1000 \text{ kilometers}$. However, if you want physically accurate lighting then you must build your scenes to real-world scale using this formula:
$ 1 \text{ unit} = 1 \text{ meter}$
If you bring in models built by another artist that are measured in feet, inches, centimeters, or furlongs, you should re-scale them to meters. We’ll show you how to scale objects in the next chapter.
Lighting in three.js
If you turn on a lightbulb in a dark room, objects in that room will receive the light in two ways:
- Direct lighting: light rays that come directly from the bulb and hit an object.
- Indirect lighting: light rays that have bounced off the walls and other objects in the room before hitting an object, changing color, and losing intensity with each bounce.
Matching these, the light classes in three.js are split into two types:
- Direct lights, which simulate direct lighting.
- Ambient lights, which are a cheap and somewhat believable way of faking indirect lighting.
We can simulate direct lighting easily. Direct light rays come out of a light source and continue in a straight line until they hit an object, or not. However, indirect lighting is much harder to simulate since doing so requires calculating an infinite number of light rays bouncing forever from all the surfaces in the scene. There is no computer powerful enough to do that, and even if we limit ourselves to merely calculating a few thousand light rays, each making just a couple of bounces ( raytracing), it still generally takes too long to calculate in real-time. As a result, if we want realistic lighting in our scene, we need some way of faking indirect lighting. There are several techniques for doing this in three.js, of which ambient lights are one. Other techniques are image-based lighting (IBL), and light probes, as we’ll see later in the book.
Direct Lighting
In this chapter, we’ll add the DirectionalLight
, which simulates light from the sun or another very bright far away source. We’ll come back to
ambient lighting later in this section. There are a total of four direct light types available in the three.js core, each of which simulates a common real-world source of light:
-
DirectionalLight
=> Sunlight -
PointLight
=> Light Bulbs -
RectAreaLight
=> Strip lighting or bright windows -
SpotLight
=> Spotlights
Shadows are Disabled By Default
One difference between the real world and three.js, even when we use PBR, is that objects don’t block light, by default. Every object in the path of a light will receive illumination, even if there is a wall in the way. The light falling on an object will illuminate it, but pass straight through and illuminate the objects behind as well. So much for physical correctness!
We can manually enable shadows, object by object, and light by light. However, shadows are expensive so we usually only enable shadows for one light or two lights, especially if our scene needs to work on mobile devices. Only direct light types can cast shadows, ambient lights cannot.
Introducing the DirectionalLight
The
DirectionalLight
is designed to mimic a distant source of light such as the sun. Light rays from a DirectionalLight
don’t fade with distance. All objects in the scene will be illuminated equally brightly no matter where they are placed - even behind the light.
The light rays of a DirectionalLight
are parallel and shine from a position and towards a target. By default, the target is placed at the center of our scene (the point $(0, 0, 0)$), so as we move the light around it will always shine towards the center.
Add a DirectionalLight
to Our Scene
That’s enough talk, let’s add a DirectionalLight
to our scene. Open or create the components/lights.js module, which will follow the same pattern as the other components in this folder. First, we’ll import the DirectionalLight
class, then we’ll set up a createLights
function, and finally, we’ll export the function:
import { DirectionalLight } from 'three';
function createLights() {
const light = null; // TODO
return light;
}
export { createLights };
Create a DirectionalLight
The
DirectionalLight
constructor takes two parameters, color, and intensity. Here, we create a pure white light with an intensity of 8:
function createLights() {
// Create a directional light
const light = new DirectionalLight('white', 8);
return light;
}
All three.js lights have both color and intensity settings, inherited from
the Light
base class.
Position the Light
The DirectionalLight
shines from light.position
, to light.target.position
. As we mentioned above, the default position for both the light and the target is the center of our scene, $(0, 0, 0)$. This means the light is currently shining from $(0, 0, 0)$, towards $(0, 0, 0)$. This does work, but it doesn’t look great. We can improve the appearance of the light by adjusting the light.position
.
We’ll move it left, up, and towards us by setting the position to $(10, 10, 10)$.
import { DirectionalLight } from 'three';
function createLights() {
// Create a directional light
const light = new DirectionalLight('white', 8);
// move the light right, up, and towards us
light.position.set(10, 10, 10);
return light;
}
export { createLights };
Now the light is shining from $(10, 10, 10)$, towards $(0, 0, 0)$.
World.js Setup
Over in World.js, import the new module:
import { createCamera } from './components/camera.js';
import { createCube } from './components/cube.js';
import { createLights } from './components/lights.js';
import { createScene } from './components/scene.js';
import { createRenderer } from './systems/renderer.js';
import { Resizer } from './systems/Resizer.js';
...
Then create a light and add it to the scene. Adding a light to the scene works just like adding a mesh:
class World {
constructor(container) {
camera = createCamera();
scene = createScene();
renderer = createRenderer();
container.append(renderer.domElement);
const cube = createCube();
const light = createLights();
scene.add(cube, light);
const resizer = new Resizer(container, camera, renderer);
}
Note that we have added the light and the mesh in a single call of scene.add
. We can add as many objects as we like, separated by commas.
Switch to the Physically Based MeshStandardMaterial
Adding the light won’t have any immediate effect since we’re currently using a MeshBasicMaterial
. As we mentioned earlier, this material ignores any lights in the scene. Here, we’ll switch to a MeshStandardMaterial
.
The MeshBasicMaterial
As the name implies,
MeshBasicMaterial
is the most basic material available in three.js. It doesn’t react to lights at all and the entire surface of a mesh is shaded with a single color. No shading based on viewing angle or distance is performed, so the object doesn’t even look three dimensional. All we can see is a 2D outline.
In the controls above, the Material
menu has parameters that all three.js materials share, while the MeshBasicMaterial
menu has parameters that come from this material type. It’s possible to improve the appearance of this material by adjusting the parameters, in particular by using textures, as we’ll see later. You can test the effect of the color map using the map
parameter. Or, try setting the environment texture using the envMap
parameter. Environment maps are an important form of image-based lighting. However, no matter how much we tweak these settings, we’ll never reach the quality of a physically based material.
Introducing the MeshStandardMaterial
In this chapter, we’ll replace the basic material with a
MeshStandardMaterial
. This is a high-quality, general-purpose, physically-accurate material that reacts to light using real-world physics equations. As the name suggests, MeshStandardMaterial
should be your go-to “standard” material for nearly all situations. With the addition of well-crafted textures, we can recreate nearly any common surface using the MeshStandardMaterial
.
If you look through the menus here, you’ll see that three.js materials have a lot of settings! The controls in this scene only shows a few of the available MeshStandardMaterial
parameters.
The Material Base Class
If you open the Material menu in both of the above scenes, you’ll see that both materials have many of the same settings, such as transparent (whether the material is see-through), opacity (how see-through it is), visible (true/false to show/hide the material), and so on. The reason for this is that both materials, and indeed, all three.js materials, inherit from the
Material
base class. You can’t use Material
directly. Instead, you must always use one of the derived classes like MeshStandardMaterial
or MeshBasicMaterial
.
Switch the Cube’s Material
Head over to cube.js and we’ll switch to this new material. First, we need to import it:
import { BoxBufferGeometry, Mesh, MeshStandardMaterial } from 'three';
Then, update the createCube
function and switch the old, boring, basic material to a fancy new standard material:
function createCube() {
const geometry = new BoxBufferGeometry(2, 2, 2);
// Switch the old "basic" material to
// a physically correct "standard" material
const material = new MeshStandardMaterial();
const cube = new Mesh(geometry, material);
return cube;
}
Change the Material’s Color
We’ll make one more change in this module, and set the material’s color to purple. Setting the material parameters is slightly different from other classes like the box geometry since we need to use a specification object with named parameters:
To keep our code short and readable, we’ll declare the specification object inline:
function createCube() {
const geometry = new BoxBufferGeometry(2, 2, 2);
// Switch the old "basic" material to
// a physically correct "standard" material
const material = new MeshStandardMaterial({ color: "purple" });
const cube = new Mesh(geometry, material);
return cube;
}
When we set the scene’s background color, we used a CSS color name, and we’ve done the same here.
Rotate the Cube
As a final touch, let’s rotate the cube so we are no longer looking at it head-on. Adjusting the rotation of an object works in much the same way as setting the position. Add the following line to the cube module:
function createCube() {
const geometry = new BoxBufferGeometry(2, 2, 2);
// Switch the old "basic" material to
// a physically correct "standard" material
const material = new MeshStandardMaterial({ color: 'purple' });
const cube = new Mesh(geometry, material);
cube.rotation.set(-0.5, -0.1, 0.8);
return cube;
}
Put any values you like in there for now. Now that we’re no longer viewing the cube face on, it finally looks like a cube rather than a square.
Rotation is the second method of moving objects around that we have encountered, along with setting the position (translation). The technical term for moving objects around is transformation, and the third method we’ll use for transforming objects is scaling. Translation, rotation, and scaling (TRS) are the three fundamental transformations that we’ll use for positioning objects in 3D space, and we’ll examine each of these in detail in the next chapter.