[Three.js] PBR (Physical-based rendering) Material

Three.js provides the material attribute for a 3D object, which determines how the object reflects light and how the object is rendered in a camera. The properties of material are composed of base color, metalness, roughness, and so on. Moreover, we can decorate the surface of a 3D object by using texture maps. The texture map is a 2D image map which describes the characteristic of a material with respect to the UV map of the object surface. Thus, texture map helps us to make a realistic object.

Prerequisite

Code changes

In order to concentrate the textures of the earth, fix the position of the earth object. Let the sun revolves around the earth by modifying the below lines

scene.add(light);

function updateSystem(sec) {
    moon.position.set(0.4*Math.cos(w_moon*sec), 0, -0.4*Math.sin(w_moon*sec));
    earth_equator.position.set(3*Math.cos(w_orbit*sec), 0, -3*Math.sin(w_orbit*sec));
    earth.rotateY(w_rotate);
}

into

sun.add(light);

function updateSystem(sec) {
    moon.position.set(0.4*Math.cos(w_moon*sec), 0, -0.4*Math.sin(w_moon*sec));
    sun.position.set(3*Math.cos(w_orbit*sec), 0, -3*Math.sin(w_orbit*sec));
    earth.rotateY(w_rotate);
}

Besides, the light has been attached into the revolving sun. This is much intuitive hierarchy. Then, the result is:

Texture download

In the article, I’ll use MeshPhongMaterial for creating a realistic earth. Texture map for color, normal, bump, specular, etc. can be found at here or here. Of course, you can use other texture map by searching with a keyword, “earth texture map”. Example for color and normal map are:

A color of normal map represents normalized normal vector of surface as RGB. Indeed,

\[R = (n_x + 1) / 2\] \[G = (n_y + 1) / 2\] \[B = (n_z + 1) / 2\]

Usually, since the texture with smooth surface has normal vector, \(n = (0, 0, 1)\), a normal map is represented with purple color, #7F7FFF, in general.

Texture mapping

To use the downloaded texture map, Three.js provides TextureLoader class. It helps to load a texture map from a local file or URL. If we define TextureLoader with load(url: String, onLoad: Function, onProgress: Function, onError: Function) callback functions, the texture map is applied to the object asynchronously. In the following, we are about to apply color map, normal map, and specular map to the earth object.

const material_earth = new THREE.MeshPhongMaterial({specular: new THREE.Color(0xFFFFFF), shininess: 3});
const earth = new THREE.Mesh(geometry_sphere, material_earth);
earth.scale.set(0.2, 0.2, 0.2);

const loader = new THREE.TextureLoader();
loader.load('./assets/2k_earth_daymap.jpg', (texture)=>{
    material_earth.map = texture;
    material_earth.needsUpdate = true;
});
loader.load('./assets/2k_earth_normal_map.tif', (texture)=>{
    material_earth.normalMap = texture;
    material_earth.normalScale = new THREE.Vector2(2, 2);
    material_earth.needsUpdate = true;
});
loader.load('./assets/2k_earth_specular_map.tif', (texture)=>{
    material_earth.specularMap = texture;
    material_earth.needsUpdate = true;
});

Notice that you have to run material_earth.needsUpdate = true when you update the attributes of the material after its construction. The combination of color, specular, and normal maps is here:

If we apply only color map, specular map, or normal map in order to see each influence, the results are as follows:

Color map Specular map Normal map

Since the reflectivity of the ocean is higher than the land, ocean part has brighter value in the specular map. Also, in the normal map, you can notice that the shadow of mountains changes depending on the sun direction.

Shadow

In the above, we’ve created the earth. Next, let’s create the moon.

loader.load('./assets/moonmap2k.jpg', (texture)=>{
	material_moon.map = texture;
	material_moon.needsUpdate = true;
});

In the result, day and night are well-rendered at both earth and moon. But, a solar eclipse does not happen even though the earth and moon are in line with the sun. Also, lunar eclipse would not be happened, too. So, let’s activate a shadow effect of Three.js renderer.

renderer.shadowMap.enabled = true;
...

light.castShadow = true;
...

earth.castShadow = true;
earth.receiveShadow = true;
...

moon.castShadow = true;
moon.receiveShadow = true;
...

renderer.shadowMap configures the characteristics of shadow map. Depending on the scale of a scene, you have to tune this parameter. castShadow = true is applied to the object that generates shadow. Otherwise, receiveShadow = true is applied to the object that depicts shadow regions. Because moon and earth interact with each other, castShadow and receiveShadow are set as true for both moon and earth. If castShadow of earth or receiveShadow of moon is set as false, lunar eclipse would not be appeared.

Now, we can observe the eclipse. Although the boundary of shadow looks not much natural, but let’s skip now. Then, for real scale, how about if we reduce the size of the moon?

This rasterized boundary of shadow gets worse if the distance between the light and earth. To make the shadow more realistic, we can configure the size of shadow map and blur as below.

light.shadow.mapSize = new THREE.Vector2(4096, 4096);
light.shadow.radius = 20;

In this way, we can tune the value of shadow map resolution for further planet, but it would be the waste of memory because only some of small surface are affected by shadows. Also, the quality of shadow will be different depending on the distance from the lighting source. So, it’s time to use Shader.

Limitation

Overall, the material and texture makes an object almost realistic. But if we want to illustrate the night side of Earth, the shadow of cloud, and the high resolution of eclipse, we need shader material which will be covered in the following article.

Entire code

import * as THREE from 'three'
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'

const canvas = document.createElement("canvas");
document.body.appendChild(canvas);

const renderer = new THREE.WebGLRenderer({canvas: canvas, alpha: true, antialias: true});
renderer.setSize( window.innerWidth, window.innerHeight );
renderer.shadowMap.enabled = true;

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 0, 5);

const geometry_sphere = new THREE.SphereGeometry(1, 30, 30);

const material_sun = new THREE.MeshBasicMaterial({color: 0xffaa00});
const sun = new THREE.Mesh(geometry_sphere, material_sun);

const material_earth = new THREE.MeshPhongMaterial({specular: new THREE.Color(0xFFFFFF), shininess: 3});
const earth = new THREE.Mesh(geometry_sphere, material_earth);
earth.scale.set(0.2, 0.2, 0.2);
earth.castShadow = true;
earth.receiveShadow = true;

const loader = new THREE.TextureLoader();
loader.load('./assets/2k_earth_daymap.jpg', (texture)=>{
    material_earth.map = texture;
    material_earth.needsUpdate = true;
});
loader.load('./assets/2k_earth_normal_map.tif', (texture)=>{
    material_earth.normalMap = texture;
    material_earth.normalScale = new THREE.Vector2(2, 2);
    material_earth.needsUpdate = true;
});
loader.load('./assets/2k_earth_specular_map.tif', (texture)=>{
    material_earth.specularMap = texture;
    material_earth.needsUpdate = true;
});

const material_moon = new THREE.MeshLambertMaterial();
const moon = new THREE.Mesh(geometry_sphere, material_moon);
moon.scale.set(0.05, 0.05, 0.05);
moon.castShadow = true;
moon.receiveShadow = true;

loader.load('./assets/moonmap2k.jpg', (texture)=>{
    material_moon.map = texture;
    material_moon.needsUpdate = true;
});

const light = new THREE.PointLight(0xffffff, 15);
light.position.set(0, 0, 0);
light.castShadow = true;
light.shadow.mapSize = new THREE.Vector2(4096, 4096);
light.shadow.radius = 20;

const earth_orbit = new THREE.Object3D();
const earth_equator = new THREE.Object3D();
const moon_orbit = new THREE.Object3D();
// earth_equator.rotateZ(23.5*Math.PI/180);

const scene = new THREE.Scene();
scene.add(sun);
sun.add(light);
scene.add(earth_orbit);
earth_orbit.add(earth_equator);
earth_equator.add(earth);
earth_equator.add(moon_orbit);
moon_orbit.add(moon);

const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;

const w_moon = 2;
const w_orbit = 0.5;
const w_rotate = 0.1;

function updateSystem(sec) {
    moon.position.set(0.4*Math.cos(w_moon*sec), 0, -0.4*Math.sin(w_moon*sec));
    sun.position.set(3*Math.cos(w_orbit*sec), 0, -3*Math.sin(w_orbit*sec));
    earth.rotateY(w_rotate);
}

function animate (msec) {
    requestAnimationFrame(animate);
    
    updateSystem(msec * 0.001);

    controls.update();
	renderer.render(scene, camera);
}
animate();