Make Your Own Earth in Three.js

Franky Hung
20 min readOct 8, 2023

Hey folks, it’s been a long while since my last article here…But I’m back in the game! Previously I haven’t been working a lot with textures, so this time I came up with a project that definitely makes good use of textures, that is to recreate Earth in Three.js! Here’s a first look at what you can create at the end of this tutorial:

This is the final result!

Code Repo and Live Demo

As usual, I started the project with my threejs-starter-template, but you are free to use your own setup with plain javascript.

Complete project code: https://github.com/franky-adl/threejs-earth

Live demo(might take some while for the browser to download the textures used before the scene starts up…): https://projects.arkon.digital/threejs/threejs-earth/

Steps Summary

  1. Let there be Earth! 🌎 (applying the base texture)
  2. Adding the bump map
  3. Adding the Clouds ☁️
  4. Make the Ocean reflect light
  5. Humans need night lives 🌃
  6. Adding atmospheric fresnel
  7. Adding the atmosphere
  8. Final touch — Galactic background

1. Let there be Earth!

Let’s start off with the most important thing in this demo, adding the Earth right away!

(Note that the textures I use for this demo are mostly from NASA and the ESA, which I think are free to use personally or commercially, but you better check their licenses again for your own projects.)

So, assuming you guys are starting off with my threejs-starter-template, let’s update index.js as follows:

// ThreeJS and Third-party deps
import * as THREE from "three"
import * as dat from 'dat.gui'
import Stats from "three/examples/jsm/libs/stats.module"
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"

// Core boilerplate code deps
import { createCamera, createRenderer, runApp, updateLoadingProgressBar } from "./core-utils"

// Other deps
import { loadTexture } from "./common-utils"
import Albedo from "./assets/Albedo.jpg"

global.THREE = THREE
// previously this feature is .legacyMode = false, see https://www.donmccurdy.com/2020/06/17/color-management-in-threejs/
// turning this on has the benefit of doing certain automatic conversions (for hexadecimal and CSS colors from sRGB to linear-sRGB)
THREE.ColorManagement.enabled = true

/**************************************************
* 0. Tweakable parameters for the scene
*************************************************/
const params = {
// general scene params
sunIntensity: 1.8, // brightness of the sun
speedFactor: 2.0, // rotation speed of the earth
}


/**************************************************
* 1. Initialize core threejs components
*************************************************/
// Create the scene
let scene = new THREE.Scene()

// Create the renderer via 'createRenderer',
// 1st param receives additional WebGLRenderer properties
// 2nd param receives a custom callback to further configure the renderer
let renderer = createRenderer({ antialias: true }, (_renderer) => {
// best practice: ensure output colorspace is in sRGB, see Color Management documentation:
// https://threejs.org/docs/#manual/en/introduction/Color-management
_renderer.outputColorSpace = THREE.SRGBColorSpace
})

// Create the camera
// Pass in fov, near, far and camera position respectively
let camera = createCamera(45, 1, 1000, { x: 0, y: 0, z: 30 })


/**************************************************
* 2. Build your scene in this threejs app
* This app object needs to consist of at least the async initScene() function (it is async so the animate function can wait for initScene() to finish before being called)
* initScene() is called after a basic threejs environment has been set up, you can add objects/lighting to you scene in initScene()
* if your app needs to animate things(i.e. not static), include a updateScene(interval, elapsed) function in the app as well
*************************************************/
let app = {
async initScene() {
// OrbitControls
this.controls = new OrbitControls(camera, renderer.domElement)
this.controls.enableDamping = true

// adding a virtual sun using directional light
this.dirLight = new THREE.DirectionalLight(0xffffff, params.sunIntensity)
this.dirLight.position.set(-50, 0, 30)
scene.add(this.dirLight)

// updates the progress bar to 10% on the loading UI
await updateLoadingProgressBar(0.1)

// loads earth's color map, the basis of how our earth looks like
const albedoMap = await loadTexture(Albedo)
albedoMap.colorSpace = THREE.SRGBColorSpace
await updateLoadingProgressBar(0.2)

// create group for easier manipulation of objects(ie later with clouds and atmosphere added)
this.group = new THREE.Group()
// earth's axial tilt is 23.5 degrees
this.group.rotation.z = 23.5 / 360 * 2 * Math.PI

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)

// set initial rotational position of earth to get a good initial angle
this.earth.rotateY(-0.3)

scene.add(this.group)

// GUI controls
const gui = new dat.GUI()
gui.add(params, "sunIntensity", 0.0, 5.0, 0.1).onChange((val) => {
this.dirLight.intensity = val
}).name("Sun Intensity")
gui.add(params, "speedFactor", 0.1, 20.0, 0.1).name("Rotation Speed")

// Stats - show fps
this.stats1 = new Stats()
this.stats1.showPanel(0) // Panel 0 = fps
this.stats1.domElement.style.cssText = "position:absolute;top:0px;left:0px;"
// this.container is the parent DOM element of the threejs canvas element
this.container.appendChild(this.stats1.domElement)

await updateLoadingProgressBar(1.0, 100)
},
// @param {number} interval - time elapsed between 2 frames
// @param {number} elapsed - total time elapsed since app start
updateScene(interval, elapsed) {
this.controls.update()
this.stats1.update()

// use rotateY instead of rotation.y so as to rotate by axis Y local to each mesh
this.earth.rotateY(interval * 0.005 * params.speedFactor)
}
}

/**************************************************
* 3. Run the app
* 'runApp' will do most of the boilerplate setup code for you:
* e.g. HTML container, window resize listener, mouse move/touch listener for shader uniforms, THREE.Clock() for animation
* Executing this line puts everything together and runs the app
* ps. if you don't use custom shaders, pass undefined to the 'uniforms'(2nd-last) param
* ps. if you don't use post-processing, pass undefined to the 'composer'(last) param
*************************************************/
runApp(app, scene, renderer, camera, true, undefined, undefined)

Code is pretty much self-explanatory with comments so I won’t go into details for each line of code. For the Albedo.jpg, you can grab it from the link in my image attributions in README.md of my github repo. To explain the code structure in short:

  • initScene function adds objects to the scene(e.g. lighting, meshes, GUI controls, etc…)
  • updateScene function is called every frame so you can do animation here
  • runApp function is the starting trigger for the whole application

By this point, we should have a simple Sphere with the Earth’s albedo map correctly applied onto it:

Some points regarding the color space management in this demo:

  • You should have noticed we’re setting the output color space of the renderer to be THREE.SRGBColorSpace , which matches with the color space of most modern-day screens
  • Sometimes, we’d also have to specify the colorSpace of the colored textures used as THREE.SRGBColorSpace also, because color data in the images are usually also in SRGB color space. In this case though, it seems it works pretty much the same if you don’t specify the color space for textures
  • Read this official doc(https://threejs.org/docs/#manual/en/introduction/Color-management) thoroughly if you are not familiar with managing color space in Three.js

2. Adding the Bump map

This step is super easy to do. To make our earth more realistic, high mountains should cast shadows from the sunlight. As NASA already provided us with Earth’s bump map, we just need to apply it to our Earth’s MeshStandardMaterial.

These are the changes to be added in the index.js:

...
// 1. import the earth's bump map
import Bump from "./assets/Bump.jpg"
...
let app = {
async initScene() {
...
// 2. load the bump map as a texture
const bumpMap = await loadTexture(Bump)
await updateLoadingProgressBar(0.3)
...
// 3. apply the bump map to the earth's material
let earthMat = new THREE.MeshStandardMaterial({
map: albedoMap,
bumpMap: bumpMap,
bumpScale: 0.03, // must be really small, if too high even bumps on the back side got lit up
})
...
},
...
}

With these changes, on refreshing your demo, you should be able to see the beautiful shadows casted by the Andes Mountains right away.

Notice that I put a very small number in the bumpScale since if you overdo it, you’d notice the mountains are also lit up even on the shadow side of the Earth.

3. Adding the Clouds

This part will be a bit lengthy because it involves some advanced techniques, mainly on creating the shadow map of the clouds. This is where the fun begins!

Again, NASA already provided us an example cloud map of the Earth(see link in my README.md). Because we’d like to simulate cloud casting shadows onto Earth later, it’s natural to add the clouds as a separate mesh sphere, with a radius slightly higher than that of the Earth’s. As for the code changes:

...
// 1. import the earth's cloud map
import Clouds from "./assets/Clouds.png"
...
let app = {
async initScene() {
...
// 2. load the clouds map as a texture
const cloudsMap = await loadTexture(Clouds)
await updateLoadingProgressBar(0.4)
...
// 3. create the clouds mesh using the cloudsMap texture
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)
...
},
updateScene(interval, elapsed) {
...
// 4. rotate the clouds twice as fast as the land mass
this.clouds.rotateY(interval * 0.01 * params.speedFactor)
...
}
}

This time we apply cloudsMap to alphaMap instead of map attribute because we can make use of the darkness in the map as its level of transparency, thus we also turn transparent to true. Conveniently the default color of MeshStandardMaterial is already white, so we don’t have to explicitly set the color for this clouds layer.

Looking more realistic already with clouds added!

However, if you are more of a perfectionist, you’d certainly think it should look better if the clouds cast shadows.

Honestly I wasn’t sure how to do it until I’ve asked on discourse.threejs.org. Shout out to makc3d and Fyrestar who guided me to a pretty nice implementation. The idea is to use a “negative light map” approach to cast cloud shadows. On any uv point on Earth’s texture map(Point X), find the superimposing uv point(Point Y) on the clouds map, then we extract the color value at Point Y. We then darken the color value at Point X depending on the color value at Point Y, which is the intensity of the clouds at Point Y.

Code changes(sections starting with numbered code comments are code blocks to be added):

...
let app = {
async initScene() {
...
// set initial rotational position of earth to get a good initial angle
this.earth.rotateY(-0.3)
// 1. also set cloud's initial rotational position same as earth so we can calculate shadows position correctly
this.clouds.rotateY(-0.3)

// 2. Insert our custom shader code into the MeshStandardMaterial's shader code to calculate cloud shadows
earthMat.onBeforeCompile = function( shader ) {

shader.uniforms.tClouds = { value: cloudsMap }
shader.uniforms.tClouds.value.wrapS = THREE.RepeatWrapping;
shader.uniforms.uv_xOffset = { value: 0 }
shader.fragmentShader = shader.fragmentShader.replace('#include <common>', `
#include <common>
uniform sampler2D tClouds;
uniform float uv_xOffset;
`);

shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
#include <emissivemap_fragment>

// Methodology explanation:
//
// Our goal here is to use a “negative light map” approach to cast cloud shadows,
// the idea is on any uv point on earth map(Point X),
// we find the corresponding uv point(Point Y) on clouds map that is directly above Point X,
// then we extract color value at Point Y.
// We then darken the color value at Point X depending on the color value at Point Y,
// that is the intensity of the clouds at Point Y.
//
// Since the clouds are made to spin twice as fast as the earth,
// in order to get the correct shadows(clouds) position in this earth's fragment shader
// we need to minus earth's UV.x coordinate by uv_xOffset,
// which is calculated and explained in the updateScene()
// after minus by uv_xOffset, the result would be in the range of -1 to 1,
// we need to set RepeatWrapping for wrapS of the clouds texture so that texture2D still works for -1 to 0

float cloudsMapValue = texture2D(tClouds, vec2(vMapUv.x - uv_xOffset, vMapUv.y)).r;

// The shadow should be more intense where the clouds are more intense,
// thus we do 1.0 minus cloudsMapValue to obtain the shadowValue, which is multiplied to diffuseColor
// we also clamp the shadowValue to a minimum of 0.2 so it doesn't get too dark

diffuseColor.rgb *= max(1.0 - cloudsMapValue, 0.2 );
`)

// need save to userData.shader in order to enable our code to update values in the shader uniforms,
// reference from https://github.com/mrdoob/three.js/blob/master/examples/webgl_materials_modified.html
earthMat.userData.shader = shader
}
...
},
updateScene(interval, elapsed) {
...
this.earth.rotateY(interval * 0.005 * params.speedFactor)
this.clouds.rotateY(interval * 0.01 * params.speedFactor)

// 3. calculate uv_xOffset and pass it into the shader used by Earth's MeshStandardMaterial
// As for each n radians Point X has rotated, Point Y would have rotated 2n radians.
// Thus uv.x of Point Y would always be = uv.x of Point X - n / 2π.
// Dividing n by 2π is to convert from radians(i.e. 0 to 2π) into the uv space(i.e. 0 to 1).
// The offset n / 2π would be passed into the shader program via the uniform variable: uv_xOffset.
// We do offset % 1 because the value of 1 for uv.x means full circle,
// whenever uv_xOffset is larger than one, offsetting 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
}
}
}

There seems a lot to be added but most of it are comments explaining how the code works. Here we’re using a more advanced technique in Three.js, using onBeforeCompile to modify shader code of an existing Three.js Material class.

Quick quote of the function definition of onBeforeCompile from threejs docs:

An optional callback that is executed immediately before the shader program is compiled. This function is called with the shader source code as a parameter. Useful for the modification of built-in materials.

From what I’ve read from the community, this is the most preferred and straightforward way if you want to customize upon existing Three.js materials. Here, I’m customizing upon MeshStandardMaterial so I can still leverage all the nice and realistic lighting calculations from it while adding my own bit of “spice” to it. If you want to read more on this, I suggest reading Extending three.js materials with GLSL.

I’ll start explain the code a bit. Within the onBeforeCompile callback we just added, we have to first define the uniform variables to be used in the shader, tClouds storing the texture of the clouds, and uv_xOffset which stores the offset that is used to get the clouds shadows positioning correct.

shader.fragmentShader = shader.fragmentShader.replace('#include <common>', `
#include <common>
uniform sampler2D tClouds;
uniform float uv_xOffset;
`);

I don’t have a particular reason why I picked #include <common> as the “anchor point” to append the definitions of my custom uniforms, mostly because it’s very near the top of the fragment shader of meshphysical.glsl.js(source code link) used by MeshStandardMaterial.

Next, for the most important part where the “negative light map” is applied, removing the code comments from above, we have:

shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
#include <emissivemap_fragment>
float cloudsMapValue = texture2D(tClouds, vec2(vMapUv.x - uv_xOffset, vMapUv.y)).r;
diffuseColor.rgb *= max(1.0 - cloudsMapValue, 0.2 );
`)

I append the code after #include <emissivemap_fragment> because this is right before where the lighting calculations are done and after most of the default color/texture calculations are applied. For the most part of the explanation, you can refer to the code comments above which is already very detailed so I won’t repeat all of them here. But I’d like to explain the uv_xOffset with an illustration:

One more thing to add is that diffuseColor is the variable used to store the displayed color at each fragment/pixel before the lighting calculations are done. It is interesting to experiment on it yourself like setting it to vec3(1.0,0.0,0.0) just to see what the effects would be, also helps your understanding with it.

With these changes in place, now we get subtle but realistic cloud shadows. If you try to track certain patches of clouds then rotate to the right and left of it, you should see the shadows switching sides!

Cloud casting shadows add more realism to our simulation

4. Make the Ocean reflect light

It is time to make the ocean look more realistic. Water is a pretty strong reflector of light, especially when the waves are quiet or the incident angle of light is small. Luckily, by making use of roughness and metalness, this isn’t hard to do with MeshStandardMaterial.

Code changes for this section:

// 1. import the ocean texture
import Ocean from "./assets/Ocean.png"
...
// 2. add metalness to params
const params = {
...
metalness: 0.1,
}
...
let app = {
async initScene() {
...
// 3. load the ocean texture
const oceanMap = await loadTexture(Ocean)
await updateLoadingProgressBar(0.5)
...
// 4. add oceanMap to roughnessMap and metalnessMap
// also plug in metalness
let earthMat = new THREE.MeshStandardMaterial({
map: albedoMap,
bumpMap: bumpMap,
bumpScale: 0.03, // must be really small, if too high even bumps on the back side got lit up
roughnessMap: oceanMap, // will get reversed in the shaders
metalness: params.metalness, // gets multiplied with the texture values from metalness map
metalnessMap: oceanMap,
})
...
// 5. Insert our custom roughness calculation
// if the ocean map is white for the ocean, then we have to reverse the b&w values for roughness
// We want the land to have 1.0 roughness, and the ocean to have a minimum of 0.5 roughness
earthMat.onBeforeCompile = function( shader ) {
...
shader.fragmentShader = shader.fragmentShader.replace('#include <roughnessmap_fragment>', `
float roughnessFactor = roughness;

#ifdef USE_ROUGHNESSMAP

vec4 texelRoughness = texture2D( roughnessMap, vRoughnessMapUv );
// reversing the black and white values because we provide the ocean map
texelRoughness = vec4(1.0) - texelRoughness;

// reads channel G, compatible with a combined OcclusionRoughnessMetallic (RGB) texture
roughnessFactor *= clamp(texelRoughness.g, 0.5, 1.0);

#endif
`);
...
}
...
// 6. Add ocean metalness to GUI controls
gui.add(params, "metalness", 0.0, 1.0, 0.05).onChange((val) => {
earthMat.metalness = val
}).name("Ocean Metalness")
...
},
...
}

Note that if the ocean map image has white for the water, we would have to reverse the B&W values for roughness to be correct, since we want the land to be “white”(value: 1) in roughness. For the waters, we don’t want it to have zero roughness as that would make it look too metal-like and too reflective, the ocean would lose its usual blueness. So we clamp the roughness value with a minimum of 0.5. You can tweak that value you want, I just feel 0.5 is just about right.

We also get fresnel reflection from the ocean for free

5. Humans need night lives

It wouldn’t make sense if the Earth is completely dark on the night side. I believe there are many nocturnal animals living among us, myself included. Conveniently, NASA also has a map of city lights! There’s a catch though, we shouldn’t show the lights when it is daytime, we should only show the lights when it is nighttime, on the shaded side. Again, this can be achieved by tweaking the shader code in onBeforeCompile. Let’s look at the code changes:

// 1. import the night lights texture
import NightLights from "./assets/night_lights_modified.png"
...
let app = {
async initScene() {
...
// 2. load the night lighs texture
const lightsMap = await loadTexture(NightLights)
await updateLoadingProgressBar(0.6)
...
// 3. add lightsMap to the emissiveMap
// and set the emissive color to a light yellow
let earthMat = new THREE.MeshStandardMaterial({
...
emissiveMap: lightsMap,
emissive: new THREE.Color(0xffff88),
})
...
// 4. Tweak emissiveMap calculation to make the lights show only on the shaded side
earthMat.onBeforeCompile = function( shader ) {
...
shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
#ifdef USE_EMISSIVEMAP

vec4 emissiveColor = texture2D( emissiveMap, vEmissiveMapUv );

// Methodology of showing night lights only:
//
// going through the shader calculations in the meshphysical shader chunks (mostly on the vertex side),
// we can confirm that geometryNormal is the normalized normal in view space,
// for the night side of the earth, the dot product between geometryNormal and the directional light would be negative
// since the direction vector actually points from target to position of the DirectionalLight,
// for lit side of the earth, the reverse happens thus emissiveColor would be multiplied with 0.
// The smoothstep is to smoothen the change between night and day

emissiveColor *= 1.0 - smoothstep(-0.02, 0.0, dot(geometryNormal, directionalLights[0].direction));

totalEmissiveRadiance *= emissiveColor.rgb;

#endif

...previous calculations of clouds shadowing
`)
...
}
...
},
...
}

The main change we introduce into the emissivemap_fragment is just one line(I didn’t change the other two lines from the original fragment):

emissiveColor *= 1.0 - smoothstep(-0.02, 0.0, dot(geometryNormal, directionalLights[0].direction));

The dot product between the normal and the vector that points from light target to light position, further processed by a smoothstep between -0.02 and 0.0, then minus from 1, is what leads to the light showing only at night.

6. Adding atmospheric fresnel

Fresnel is the effect when you see a clearer reflection on a pond at surfaces further away from you than surfaces right beside you(imagine you might be at the lakeside, or on a boat in the middle of the pond); you see reflections off from a surface much clearer at a slimmer angle.

Although our ocean is already quite reflective, but we’re still missing the atmosphere! The atmosphere is what makes all the difference to make it look convincing! We’re going to simulate the atmosphere in two steps:

  1. Tweak diffuseColor of the Earth’s texture to make it get a brighter blue near the fringes of the sphere from the viewer(the fringes always look brighter due to how more light is accumulated from a thicker path, see how b is much longer than a below)

2. Add another sphere mesh as the atmosphere itself, on top of the Clouds and Earth meshes.

For number one, it’s actually just a few lines of code appended at the end of our fragment shader replacement containing the emissive and clouds shadow calculations:

shader.fragmentShader = shader.fragmentShader.replace('#include <emissivemap_fragment>', `
...calculations of the emissive map

...calculations of the clouds shadow

// adding a small amount of atmospheric fresnel effect to make it more realistic
// fine tune the first constant below for 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;
`)

Remember geometryNormal are the normals of the Earth in view space, which means the 3D space with respect to the camera, with the camera being the origin. So vec3( 0.0, 0.0, 1.0 ) would be a normalized vector pointing at your face(the camera). Thus the result of dot( geometryNormal, vec3( 0.0, 0.0, 1.0 ) ) would be exactly 1.0 at the center of the Earth, decreasing to 0.0 at points moving towards the edges as viewed from you(camera), and further to negative 1.0 at the center back of the Earth. vec3( 0.3, 0.6, 1.0 ) is just a light blue color I feel like making the effect realistic.

Left with the atmospheric fresnel added, right is before it’s added

You might say, ‘Wait! The calculated atmosphere value above would be highest at the back of the Earth, shouldn’t it be zero?’ The answer is: it doesn’t matter because you can only see the front side(from the camera’s point of view) of the Earth at all times; you can’t view the front and the back simultaneously however you rotate the Earth, you only see the side facing you.

7. Adding the atmosphere

We’re almost there! This will be the last effect we’re gonna add to the Earth. We have all seen beautiful pictures of our mother Earth taken from space. There is always a thin rim of light blue band surrounding it, and that of course is the atmosphere. We’ll have to add yet another sphere mesh to make the atmosphere. As for the material, we‘re gonna use ShaderMaterial which means we will provide our own vertex and fragment shaders.

Let’s add the shader files in first:

vertex.glsl:

varying vec3 vNormal;
varying vec3 eyeVector;

void main() {
// modelMatrix transforms the coordinates local to the model into world space
vec4 mvPos = modelViewMatrix * vec4( position, 1.0 );

// normalMatrix is a matrix that is used to transform normals from object space to view space.
vNormal = normalize( normalMatrix * normal );

// vector pointing from camera to vertex in view space
eyeVector = normalize(mvPos.xyz);

gl_Position = projectionMatrix * mvPos;
}

gl_Position here is still calculated using the standard formula, it’s normally done within one line but I separated it into two steps because I’d like to assign the normalized value of mvPos to the eyeVector. Together with vNormal these 2 varyings will be used in fragment shader.

fragment.glsl:

// reference from https://youtu.be/vM8M4QloVL0?si=CKD5ELVrRm3GjDnN
varying vec3 vNormal;
varying vec3 eyeVector;
uniform float atmOpacity;
uniform float atmPowFactor;
uniform float atmMultiplier;

void main() {
// Starting from the rim to the center at the back, dotP would increase from 0 to 1
float dotP = dot( vNormal, eyeVector );
// This factor is to create the effect of a realistic thickening of the atmosphere coloring
float factor = pow(dotP, atmPowFactor) * atmMultiplier;
// Adding in a bit of dotP to the color to make it whiter while the color intensifies
vec3 atmColor = vec3(0.35 + dotP/4.5, 0.35 + dotP/4.5, 1.0);
// use atmOpacity to control the overall intensity of the atmospheric color
gl_FragColor = vec4(atmColor, atmOpacity) * factor;
}

For the method applied in the fragment shader, I took reference from a pretty nice ThreeJS Earth tutorial. His version is more of a cartoonish style, but I think how he simulated the atmosphere is realistic enough.

The idea is to render the back side of this atmosphere mesh only, use additive blending so it looks transparent, and quickly increase the coloring starting from the rim of the sphere towards its center.

You could be confused as to why dotP would increase from 0 to 1 towards the center, but remember since we’re rendering the back side, the vNormal gradually points towards the camera instead of away of the camera.

atmOpacity is used to control the opacity of the atmospheric coloring, atmMultiplier is the factor multiplied to the coloring so you can used it to make the color more or less intense. atmPowFactor is used to control how quick the atmospheric coloring changes. Notice that I also add dotP to the red and green components of the atmColor so that as the atmophere “intensifies”, the color turns whiter to give out a more realisitic coloring.

Now back to the code changes to be made in index.js:

// 1. import the glsl shaders
import vertexShader from "./shaders/vertex.glsl"
import fragmentShader from "./shaders/fragment.glsl"
...
const params = {
...
// 2. add tweakable params to be passed into our shaders
atmOpacity: { value: 0.7 },
atmPowFactor: { value: 4.1 },
atmMultiplier: { value: 9.5 },
}
...
let app = {
async initScene() {
...
// 3. add the atmosphere mesh before adding this.group to scene
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
},
// notice that by default, Three.js uses NormalBlending, where if your opacity of the output color gets lower, the displayed color might get whiter
blending: THREE.AdditiveBlending, // works better than setting transparent: true, because it avoids a weird dark edge around the earth
side: THREE.BackSide // such that it does not overlays on top of the earth; this points the normal in opposite direction in vertex shader
})
this.atmos = new THREE.Mesh(atmosGeo, atmosMat)
this.group.add(this.atmos)
...
// 4. add gui controls for the new params
gui.add(params.atmOpacity, "value", 0.0, 1.0, 0.05).name("atmOpacity")
gui.add(params.atmPowFactor, "value", 0.0, 20.0, 0.1).name("atmPowFactor")
gui.add(params.atmMultiplier, "value", 0.0, 20.0, 0.1).name("atmMultiplier")
...
},
...
}

There are quite a number of predefined numbers here e.g. 12.5 radius for the atmosphere, 4.1 atmPowFactor, 9.5 atmMultiplier, etc…There are no definite answers, this is just one of the combinations that I’ve tested to work well and give pretty good looking results. So feel free to tweak all these numbers to work best for you!

Blue marble looking pretty sexy by now!

8. Final touch — Galactic background

This final effect is quite of a personal taste and I’ve spent quite a lot of time trying to make it look cooler. Feel free to insert any skybox you think is awesome for the background! I’ll be using an equirectangular image for a simpler implementation.

// 1. import the skybox's equirectangular image
import GaiaSky from "./assets/Gaia_EDR3_darkened.png"
...
let app = {
async initScene() {
...
// 2. load the skybox image as a texture and apply to scene.background
const envMap = await loadTexture(GaiaSky)
envMap.mapping = THREE.EquirectangularReflectionMapping
await updateLoadingProgressBar(0.7)

scene.background = envMap
...
},
...
}

And there you have it, our Mother Earth simulated in Three.js!

The back looks pretty sexy too

--

--

Franky Hung

Founder of Arkon Digital. I’m not a code fanatic, but I’m always amazed by what code can do. The endless possibilities in coding is what fascinates me everyday.