Create a cool and realistic earth with Three.js

image.png
Next, I will explain it step by step, with online examples on the digital twin platform.

Add a sphere first

We use SphereGeometry in threejs to create a sphere and attach an earth texture to it.

let earthGeo = new THREE.SphereGeometry(10, 64, 64)
let earthMat = new THREE.MeshStandardMaterial({<!-- -->
  map: albedoMap,
})
this.earth = new THREE.Mesh(earthGeo, earthMat)
this.group.add(this.earth)

Then write a function to allow the sphere to rotate, and the function will be called every frame.

updateScene(interval, elapsed) {<!-- -->
    this.controls.update()
    this.stats1.update()

    this.earth.rotateY(interval * 0.005 * params.speedFactor)
}

We will get the following effect on the screen.
image.png

Add bump map

There are mountains and oceans on the earth, which are bumpy in nature and will show different shadow effects under the sun. So in order to make our earth more realistic, we can use the bump map of the earth provided by NASA and apply it to the MeshStandardMaterial of the earth.

this.dirLight = new THREE.DirectionalLight(0xffffff, params.sunIntensity)
this.dirLight.position.set(-50, 0, 30)
scene.add(this.dirLight)

let earthMat = new THREE.MeshStandardMaterial({<!-- -->
      map: albedoMap,
      bumpMap: bumpMap,
      bumpScale: 0.03,
})

This way we can immediately see the shadows cast by the mountains as the sun hits them.
image.png
bumpScale This value should be as small as possible. If it is too large, you will find that even on the shadow side of the earth, the mountains will be illuminated.

Add cloud

NASA officials also provide cloud textures. We add the cloud texture to a separate mesh sphere with a radius larger than the Earth. This is done to later simulate the effect of clouds casting shadows on the earth.

const cloudsMap = await loadTexture(Clouds)
...

let cloudGeo = new THREE.SphereGeometry(10.05, 64, 64)
let cloudsMat = new THREE.MeshStandardMaterial({<!-- -->
    alphaMap: cloudsMap,
    transparent: true,
})
this.clouds = new THREE.Mesh(cloudGeo, cloudsMat)
this.group.add(this.clouds)

After adding clouds, the effect will look more realistic.
image.png
Next we implement the cloud cast shadow effect. Specific method: On any uv point (X point) on the earth texture map, find the uv point (Y point) superimposed on the cloud image, and extract the color value of the Y point. We then darken the color value of the X point based on the color value of the Y point (i.e. the intensity of the Y point cloud).

Also, because the clouds rotate twice as fast as the Earth, to get the correct shadow (cloud) position in the Earth’s fragment shader, we need to subtract the Earth’s coordinates uv_xOffset so that the result will be in In the range -1 to 1, we also need to set RepeatWrapping for the cloud texture’s wrapS so that texture2D still works for -1 to 0.

Where the clouds are thicker the shadows should be thicker, so we subtract cloudsMapValue from 1.0 to get the shadowValue and multiply it by diffuseColor. We also limit the ShadowValue to a minimum value of 0.2 so that it doesn’t becomes too dark.

shader.uniforms.tClouds = {<!-- --> value: cloudsMap }
shader.uniforms.tClouds.value.wrapS = THREE.RepeatWrapping;
shader.uniforms.uv_xOffset = {<!-- --> value: 0 }
shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
    // Because this is before lighting calculations are done and after most default color/texture calculations are applied
    #include <emissivemap_fragment>
float cloudsMapValue = texture2D(tClouds, vec2(vMapUv.x - uv_xOffset, vMapUv.y)).r;
diffuseColor.rgb *= max(1.0 - cloudsMapValue, 0.2 );`)

updateScene(interval, elapsed) {<!-- -->
    ...
    this.earth.rotateY(interval * 0.005 * params.speedFactor)
    this.clouds.rotateY(interval * 0.01 * params.speedFactor)
    
    // Calculate uv_xOffset and pass it to the shader used by the Earth's MeshStandardMaterial
    // For every n radians the X point rotates, the Y point will rotate 2n radians
    // Therefore, uv.x of point Y is always equal to (uv.x - n / 2π) of point X
    // Dividing n by 2π converts radians (i.e. 0 to 2π) to uv space (i.e. 0 to 1)
    //The offset n / 2π will be passed to the shader program through uv_xOffset
    // "offset % 1" is because a uv.x value of 1 means a complete circle
    // Whenever uv_xOffset is greater than 1, an offset of 2π radians is like no offset at all
  const shader = this.earth.material.userData.shader
    if ( shader ) {<!-- -->
      let offset = (interval * 0.005 * params.speedFactor) / (2 * Math.PI)
      shader.uniforms.uv_xOffset.value + = offset % 1
    }
  }

After making these changes, we now have subtle but realistic cloud shadows.
image.png

Let the ocean reflect sunlight

Next we can make the ocean look more realistic. Water is a strong reflector of light, especially when the waves are quiet or the angle of incidence of light is small. This is not difficult to do with MeshStandardMaterial, by taking advantage of roughness and metalness.

const oceanMap = await loadTexture("./assets/Ocean.png")
const params = {<!-- -->
  ...
  metalness: 0.1,
}
let earthMat = new THREE.MeshStandardMaterial({<!-- -->
      map: albedoMap,
      bumpMap: bumpMap,
      bumpScale: 0.03, // It must be very small. If it is too big, even the convex areas on the back will be illuminated.
      roughnessMap: oceanMap, // Replace the roughness map with an ocean map, which will invert the grayscale values in the shader
      metalness: params.metalness, // Multiply with the texture value of the metalness map
      metalnessMap: oceanMap,
})
earthMat.onBeforeCompile = function( shader ) {<!-- -->
      ...
      shader.fragmentShader = shader.fragmentShader.replace('#include <roughnessmap_fragment>', `
        float roughnessFactor = roughness;

        #ifdef USE_ROUGHNESSMAP

          vec4 texelRoughness = texture2D( roughnessMap, vRoughnessMapUv );
          texelRoughness = vec4(1.0) - texelRoughness;

          roughnessFactor *= clamp(texelRoughness.g, 0.5, 1.0);

        #endif
      `);
      ...
  }

Because the ocean map used here is actually only black and white, if the ocean area is white, we have to invert the black and white values to get the roughness correct, because we want the roughness of the land to be “white” (value: 1). For the water, we don’t want it to have zero roughness as that would make it look too metallic and too reflective and the ocean would lose its usual blue color, so we limit the roughness value to a minimum of 0.5.
image.png

Night view

It wouldn’t make sense if Earth’s nights were completely dark, because there would normally be night lights. Then our next implementation logic is: display the normal earth map during the day, and replace it with a night scene map in areas where the sun does not shine.

const lightsMap = await loadTexture(NightLights)
...

let earthMat = new THREE.MeshStandardMaterial({<!-- -->
    ...
    emissiveMap: lightsMap,
    emissive: new THREE.Color(0xffff88),
})
earthMat.onBeforeCompile = function( shader ) {<!-- -->
      ...
      shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
        #ifdef USE_EMISSIVEMAP

          vec4 emissiveColor = texture2D( emissiveMap, vEmissiveMapUv );

          // geometryNormal is the normalized normal in view space
          // For the night side of the Earth, the dot product between geometryNormal and the directional light will be negative
          // For the illuminated side of the Earth, the situation is reversed, so emissiveColor will be multiplied by 0
          // smoothstep is used to smooth the changes between day and night
          emissiveColor *= 1.0 - smoothstep(-0.02, 0.0, dot(geometryNormal, directionalLights[0].direction));
          
          totalEmissiveRadiance *= emissiveColor.rgb;

        #endif

        ...
      `)
      ...
}

Add atmospheric Fresnel effect

The Fresnel effect can be imagined. When we see a clearer reflection from the surface of the lake farther away from us than the surface near us at the lakeside; in fact, it is understood that it can be clearer at a smaller angle. to see the reflection on the surface.

While our oceans are already reflective, we’re still missing an atmosphere. We will simulate the atmospheric effect in two steps:

  1. Adjust the Earth texture’s diffuse color diffuseColor so that it appears a brighter blue near the edges of the sphere as viewed by the observer (the edges always appear smaller due to more light being accumulated from thicker paths) does look brighter, see below the b path is much longer than the a path)

image.png

  1. Add another sphere mesh on top of the clouds and earth mesh for the atmosphere itself.
shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
  ...compute self-illumination

  ...compute cloud shadows

  // Add a small amount of atmospheric Fresnel effect to make it more realistic
  // Fine-tune the first constant below for a stronger or weaker effect
  float intensity = 1.4 - dot( geometryNormal, vec3( 0.0, 0.0, 1.0 ) );
  vec3 atmosphere = vec3( 0.3, 0.6, 1.0 ) * pow(intensity, 5.0);
 
  diffuseColor.rgb + = atmosphere;
`)

geometryNormal is the normal of the earth’s surface in the view space, which is the 3D space with the camera as the origin. So vec3( 0.0, 0.0, 1.0 ) will be a normalized vector pointing towards yourself (camera). Therefore, at the center of the earth’s surface, the result of dot(geometryNormal, vec3(0.0, 0.0, 1.0)) will be exactly 1.0; from the perspective of the observer (camera), the result of the edge point of the earth will be is 0.0, the result for the center point on the back side of the Earth will be -1.0. vec3( 0.3, 0.6, 1.0 ) is just light blue, to make the effect more realistic.
Atmospheric Fresnel is added on the left, but not on the right

Add atmosphere

This will be the last effect we’re going to add to the earth. We have all seen beautiful photos of the Earth taken from space, always surrounded by a thin, light blue band, which is the atmosphere. As for materials, we will use ShaderMaterial, which means we will provide our own vertex and fragment shaders.

varying vec3 vNormal;
varying vec3 eyeVector;

void main() {
  vec4 mvPos = modelViewMatrix * vec4( position, 1.0 );

  vNormal = normalize( normalMatrix * normal );

  eyeVector = normalize(mvPos.xyz);

  gl_Position = projectionMatrix * mvPos;
}

Here gl_Position is still calculated using the standard formula, usually done in one line, but I split it into two steps because I want to assign the normalized value of mvPos to eyeVector. These two variables will be used in the fragment shader along with vNormal.

varying vec3 vNormal;
varying vec3 eyeVector;
uniform float atmOpacity;
uniform float atmPowFactor;
uniform float atmMultiplier;

void main() {
    // Starting from the edge to the center of the back of the earth, dotP will increase from 0 to 1
    float dotP = dot( vNormal, eyeVector );
    // This factor is to create a realistic atmosphere thickening effect
    float factor = pow(dotP, atmPowFactor) * atmMultiplier;
    // Add a bit of dotP to the color to make it whiter and darker
    vec3 atmColor = vec3(0.35 + dotP/4.5, 0.35 + dotP/4.5, 1.0);
    // Use atmOpacity to control the overall intensity of the atmosphere color
    gl_FragColor = vec4(atmColor, atmOpacity) * factor;
}

For the method applied in the fragment shader, one method I followed was to render only the back side of this atmosphere mesh, using additional blending to make it look transparent, and quickly increasing the shading from the edge of the sphere towards its center. Why dotP increases from 0 to 1 towards the center here is because we are rendering the back side and vNormal is gradually pointing towards the camera rather than away from it.
image.png
atmOpacity controls the opacity of the atmospheric shading, and atmMultiplier is the factor by which the shading is multiplied, which can be used to make the color more or less intense. atmPowFactor is used to control the speed of atmospheric color change. Note that I also added dotP to the r and g components of atmColor so that as the atmosphere “intensifies” the color becomes whiter, resulting in a more realistic color.

import vertexShader from "./shaders/vertex.glsl"
import fragmentShader from "./shaders/fragment.glsl"
...
const params = {<!-- -->
  ...
    atmOpacity: {<!-- --> value: 0.7 },
atmPowFactor: {<!-- --> value: 4.1 },
atmMultiplier: {<!-- --> value: 9.5 },
  }
  ...
  let app = {<!-- -->
    async initScene() {<!-- -->
      ...
      let atmosGeo = new THREE.SphereGeometry(12.5, 64, 64)
      let atmosMat = new THREE.ShaderMaterial({<!-- -->
        vertexShader: vertexShader,
        fragmentShader: fragmentShader,
        uniforms: {<!-- -->
          atmOpacity: params.atmOpacity,
          atmPowFactor: params.atmPowFactor,
          atmMultiplier: params.atmMultiplier
        },
        // Note that Three.js uses NormalBlending by default. If the opacity of the output color is lowered, the displayed color may become white.
        blending: THREE.AdditiveBlending, // works better than setting transparent: true because it avoids the weird dark edges around the earth
        side: THREE.BackSide // so it doesn't overlap on top of the earth; this points the normal in the opposite direction in the vertex shader
      })
      this.atmos = new THREE.Mesh(atmosGeo, atmosMat)
      this.group.add(this.atmos)
        ...
  },
  ...
}

There are a lot of predefined numbers here, like atmosphere radius is 12.5, atmPowFactor is 4.1, atmMultiplier is 9.5, etc… There’s no clear answer here, this is just one of the combinations I’ve tested and it works great.
image.png

Final step: starry sky background

The background is just a matter of inserting any skybox you see fit. I used an equirectangular image to achieve this.

const envMap = await loadTexture(GaiaSky)
envMap.mapping = THREE.EquirectangularReflectionMapping
    
scene.background = envMap

Let’s take a look at the final effect:
image.png