making a webgl globe

In this tutorial we will build a globe using webgl with atmospheric scattering, a day/night cycle, and clouds. This tutorial should be suitable for anyone with some programming experience using javascript.

We'll learn how to:

We'll be using:

along with some other handy npm packages along the way. To do this tutorial you will need to install node.js and npm.

First, we'll need a new project directory with an empty package.json:

mkdir coolglobe
cd coolglobe
echo '{}' > package.json

mesh

Now we can install a package for creating sphere meshes:

npm install --save icosphere

Try this in the REPL:

$ node
> require('icosphere')(1)

To exit the REPL, type CTRL+D.

The icosphere package generates a mesh that describes a sphere at some precision (in our case, 1). The mesh is an object that has an array of positions and an array of cells. It should look something like this:

{
  cells: [
    [ 3, 0, 2 ],
    [ 4, 1, 0 ],
    [ 5, 2, 1 ],
    [ 0, 1, 2 ],
    // ...
  ],
  positions: [
    [ -0.8090169943749475, 0.5, 0.3090169943749474 ],
    [ -0.5, 0.3090169943749474, 0.8090169943749475 ],
    [ -0.3090169943749474, 0.8090169943749475, 0.5 ],
    [ -0.5257311121191336, 0.85065080835204, 0 ],
    // ...
  ]
}

Each position a 3-element point in 3D and each cell describes a triangle using indicies into the positions array. Here we're only going to be using triangles, but 2-element cell arrays can describe edges and 1-element arrays can describe points. This data structure is called a simplicial complex. This format is common in the regl/stackgl ecosystem.

Now that we have some data to plot, let's set up a regl scene with a camera.

var regl = require("regl")()
var camera = require('regl-camera')(regl, { minDistance: 1, distance: 3 })
var icosphere = require('icosphere')

var draw = earth(regl)

regl.frame(function () {
  regl.clear({ color: [0,0,0,1], depth: true })
  camera(function () { draw() })
})

function earth (regl) {
  var mesh = icosphere(3)
  return regl({
    frag: `
      precision mediump float;
      void main () {
        gl_FragColor = vec4(0,1,1,1);
      }
    `,
    vert: `
      precision mediump float;
      uniform mat4 projection, view;
      attribute vec3 position;
      void main () {
        gl_Position = projection * view * vec4(position,1);
      }
    `,
    attributes: {
      position: mesh.positions
    },
    elements: mesh.cells,
  })
}

Save this code to a file, main.js. Now install some packages:

npm install -g budo
npm install --save regl regl-camera

and now you can run budo to compile this code automatically when you change a file:

budo main.js

Let's cover what's going on piece by piece.

We load some modules and set up a camera at distance 3 that won't come closer than distance 1 from the origin:

var regl = require('regl')()
var camera = require('regl-camera')(regl, { minDistance: 1, distance: 3 })
var icosphere = require('icosphere')

We get back a draw function from our earth() function:

var draw = earth(regl)

We set up a requestAnimationFrame loop that will run at ~60fps if it can. Inside the loop, we first set the background color to black and clear the depth buffer. Then we run the camera() scope which defines a projection matrix, a view matrix, and an eye vector which will be available as uniform variables inside our draw function:

regl.frame(function () {
  regl.clear({ color: [0,0,0,1], depth: true })
  camera(function () { draw() })
})

shaders

The next part of the code deals with shaders. Shaders are written in GLSL, language similar to C. There are 3 types of special shader variables in GLSL that you can use to pass information between javascript and your GLSL programs and between the vertex and fragment shaders: uniform, attribute, and varying.

Getting back to our earth function, first we setup a mesh for the globe. Then we return the result of the regl() function, which is another function that will draw our scene.

function earth (regl) {
  var mesh = icosphere(3)
  return regl({
    /* ... */
  })
}

The vertex positions are passed in as attributes and the elements are used to make triangles out of the vertices:

  attributes: {
    position: mesh.positions
  },
  elements: mesh.cells,

The vertex shader multiplies projection and view matrices passed in as uniforms by the position vec3 passed in as an attribute.

  vert: `
    precision mediump float;
    uniform mat4 projection, view;
    attribute vec3 position;
    void main () {
      gl_Position = projection * view * vec4(position,1);
    }

The fragment shader colors each pixel a solid color: cyan.

  frag: `
    precision mediump float;
    void main () {
      gl_FragColor = vec4(0,1,1,1);
    }
  `,

normals

Now we can modify the shader to apply some lighting based on the surface normals. Spheres are unique in that the surface normals are the normalized vertex coordinates on the surface.

var regl = require('regl')()
var camera = require('regl-camera')(regl, { minDistance: 1, distance: 3 })
var icosphere = require('icosphere')

var draw = earth(regl)

regl.frame(function () {
  regl.clear({ color: [0,0,0,1], depth: true })
  camera(function () { draw() })
})

function earth (regl) {
  var mesh = icosphere(3)
  return regl({
    frag: `
      precision mediump float;
      varying vec3 vpos;
      void main () {
        gl_FragColor = vec4(normalize(vpos),1);
      }
    `,
    vert: `
      precision mediump float;
      uniform mat4 projection, view;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * vec4(position,1);
      }
    `,
    attributes: {
      position: mesh.positions
    },
    elements: mesh.cells
  })
}

We've used a new glsl feature varying. When you declare a variable as varying, you define what the variable will be in your vertex shader for each vertex and the values you get in your fragment shader for the varying variable will be linearly interpolated based on the projected coordinates.

scattering

We can use a glslify module to calculate atmospheric scattering to color our sphere. Install the glsl-atmosphere package and glslify:

npm install --save glsl-atmosphere glslify

To use glslify, load the glslify package and wrap the template strings with glsl. You will also need to run budo with -t glslify:

budo main.js -- -t glslify

With some carefully chosen parameters, a new uniform sunpos, and the eye position from regl-camera, we have a sphere with realistic atmospheric scattering:

var regl = require('regl')()
var camera = require('regl-camera')(regl, { minDistance: 1, distance: 3 })
var icosphere = require('icosphere')
var glsl = require('glslify')

var draw = earth(regl)

regl.frame(function () {
  regl.clear({ color: [0,0,0,1], depth: true })
  camera(function () { draw() })
})

function earth (regl) {
  var mesh = icosphere(3)
  return regl({
    frag: glsl`
      precision mediump float;
      #pragma glslify: atmosphere = require('glsl-atmosphere')
      uniform vec3 eye, sunpos;
      varying vec3 vpos;
      void main () {
        vec3 pos = normalize(vpos);
        vec3 vscatter = atmosphere(
          eye-pos, // ray direction
          pos*6372e3, // ray origin
          sunpos, // sun position
          22.0, // sun intensity
          6372e3, // planet radius (m)
          6472e3, // atmosphere radius (m)
          vec3(5.5e-6,13.0e-6,22.4e-6), // rayleigh scattering
          21e-6, // mie scattering
          8e3, // rayleight scale height
          1.2e3, // mie scale height
          0.758 //  mie scattering direction
        );
        gl_FragColor = vec4(vscatter,1);
      }
    `,
    vert: glsl`
      precision mediump float;
      uniform mat4 projection, view;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * vec4(vpos,1);
      }
    `,
    attributes: {
      position: mesh.positions
    },
    elements: mesh.cells,
    uniforms: {
      sunpos: function (context) {
        var t = context.time, r = 10
        return [Math.cos(t)*r,0,Math.sin(t)*r]
      }
    }
  })
}

textures

Next, we can put some imagery on our globe. NASA's visible earth catalog

Let's download these files:

curl -o day.jpg http://eoimages.gsfc.nasa.gov/images/imagerecords/74000/74418/world.topo.200408.3x5400x2700.jpg
curl -o night.jpg http://eoimages.gsfc.nasa.gov/images/imagerecords/79000/79765/dnb_land_ocean_ice.2012.3600x1800.jpg
curl -o clouds.jpg http://eoimages.gsfc.nasa.gov/images/imagerecords/57000/57747/cloud_combined_2048.jpg

These files are larger than we need:

$ imgsize *.jpg
clouds.jpg: width="2048" height="1024"
day.jpg: width="5400" height="2700"
night.jpg: width="3600" height="1800"

So we can resize them with the image magick convert command:

for x in *.jpg; do convert $x -resize 800x $x; done

You can download these downsampled files from this article:

Now we can use resl to load these assets into our demo. Once we get the assets in resl's onDone callback, we set them up as textures with regl.texture().

You can pass in textures as sampler2D uniforms and you can read samples from the textures with texture2D(texture,x,y). x and y for texture2D are floats between 0 and 1. When you set mag: 'linear' in regl.texture(), the gaps between pixels are interpolated linearly.

For our globe, we will pass in day, night, and clouds sampler2D uniforms in the fragment shader based on a calculated lon and lat. The fiddly magic numbers in the calculation are because the cylindrical projections omit extreme latitudes, taking slightly more off antarctica than the arctic ocean.

For now, we'll take our existing atmospheric scattering calculation and add the scattering to the average of each of our texture samples in the fragment shader:

var regl = require('regl')()
var camera = require('regl-camera')(regl, { minDistance: 1, distance: 3 })
var icosphere = require('icosphere')
var glsl = require('glslify')
var resl = require('resl')

resl({
  manifest: {
    day: { type: 'image', src: 'day.jpg' },
    night: { type: 'image', src: 'night.jpg' },
    clouds: { type: 'image', src: 'clouds.jpg' }
  },
  onDone: onloaded
})

function onloaded (assets) {
  var draw = earth(regl, {
    textures: {
      day: regl.texture({ data: assets.day, mag: 'linear' }),
      night: regl.texture({ data: assets.night, mag: 'linear' }),
      clouds: regl.texture({ data: assets.clouds, mag: 'linear' })
    }
  })
  regl.frame(function () {
    regl.clear({ color: [0,0,0,1], depth: true })
    camera(function () { draw() })
  })
}

function earth (regl, opts) {
  var mesh = icosphere(3)
  return regl({
    frag: glsl`
      precision mediump float;
      #pragma glslify: atmosphere = require('glsl-atmosphere')
      uniform vec3 eye, sunpos;
      uniform sampler2D day, night, clouds;
      varying vec3 vpos;
      void main () {
        vec3 pos = normalize(vpos);
        vec3 vscatter = atmosphere(
          eye-pos, // ray direction
          pos*6372e3, // ray origin
          sunpos, // sun position
          22.0, // sun intensity
          6372e3, // planet radius (m)
          6472e3, // atmosphere radius (m)
          vec3(5.5e-6,13.0e-6,22.4e-6), // rayleigh scattering
          21e-6, // mie scattering
          8e3, // rayleight scale height
          1.2e3, // mie scale height
          0.758 //  mie scattering direction
        );
        float lon = mod(atan(pos.x,pos.z)*${1/(2*Math.PI)},1.0);
        float lat = asin(-pos.y*0.79-0.02)*0.5+0.5;
        vec3 tday = texture2D(day,vec2(lon,lat)).rgb;
        vec3 tnight = texture2D(night,vec2(lon,lat)).rgb;
        vec3 tclouds = texture2D(clouds,vec2(lon,lat)).rgb;
        gl_FragColor = vec4(vscatter + (tday+tnight+tclouds)/3.0, 1);
      }
    `,
    vert: glsl`
      precision mediump float;
      uniform mat4 projection, view;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * vec4(vpos,1);
      }
    `,
    attributes: {
      position: mesh.positions
    },
    elements: mesh.cells,
    uniforms: {
      sunpos: function (context) {
        var t = context.time, r = 10
        return [Math.cos(t)*r,0,Math.sin(t)*r]
      },
      day: opts.textures.day,
      night: opts.textures.night,
      clouds: opts.textures.clouds
    }
  })
}

day/night cycle

Before, we blended together the day, night, and cloud textures uniformly to test that textures are working properly. Now we'll show the day texture on the lit side and the night texture on the shaded side. We will also darken the clouds on the night side of the planet.

var regl = require('regl')()
var camera = require('regl-camera')(regl, { minDistance: 1, distance: 3 })
var icosphere = require('icosphere')
var glsl = require('glslify')
var resl = require('resl')

resl({
  manifest: {
    day: { type: 'image', src: 'day.jpg' },
    night: { type: 'image', src: 'night.jpg' },
    clouds: { type: 'image', src: 'clouds.jpg' }
  },
  onDone: onloaded
})

function onloaded (assets) {
  var draw = earth(regl, {
    textures: {
      day: regl.texture({ data: assets.day, mag: 'linear' }),
      night: regl.texture({ data: assets.night, mag: 'linear' }),
      clouds: regl.texture({ data: assets.clouds, mag: 'linear' })
    }
  })
  regl.frame(function () {
    regl.clear({ color: [0,0,0,1], depth: true })
    camera(function () { draw() })
  })
}

function earth (regl, opts) {
  var mesh = icosphere(3)
  return regl({
    frag: glsl`
      precision mediump float;
      #pragma glslify: atmosphere = require('glsl-atmosphere')
      uniform vec3 eye, sunpos;
      uniform sampler2D day, night, clouds;
      varying vec3 vpos;
      void main () {
        vec3 pos = normalize(vpos);
        vec3 vscatter = atmosphere(
          eye-pos, // ray direction
          pos*6372e3, // ray origin
          sunpos, // sun position
          22.0, // sun intensity
          6372e3, // planet radius (m)
          6472e3, // atmosphere radius (m)
          vec3(5.5e-6,13.0e-6,22.4e-6), // rayleigh scattering
          21e-6, // mie scattering
          8e3, // rayleight scale height
          1.2e3, // mie scale height
          0.758 //  mie scattering direction
        );
        float lon = mod(atan(pos.x,pos.z)*${1/(2*Math.PI)},1.0);
        float lat = asin(-pos.y*0.79-0.02)*0.5+0.5;
        vec3 tday = texture2D(day,vec2(lon,lat)).rgb;
        vec3 tnight = texture2D(night,vec2(lon,lat)).rgb;
        vec3 tclouds = texture2D(clouds,vec2(lon,lat)).rgb;
        float light = length(vscatter);
        vec3 c = vscatter*0.2 + tday*light
          + tclouds*(light*0.5+(1.0-light)*2e-4)
          + pow(tnight,vec3(8.0))*pow(max(0.0,1.0-light),8.0);
        gl_FragColor = vec4(pow(c,vec3(1.0/2.2)),1);
      }
    `,
    vert: glsl`
      precision mediump float;
      uniform mat4 projection, view;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * vec4(vpos,1);
      }
    `,
    attributes: {
      position: mesh.positions
    },
    elements: mesh.cells,
    uniforms: {
      sunpos: function (context) {
        var t = context.time, r = 10
        return [Math.cos(t)*r,0,Math.sin(t)*r]
      },
      day: opts.textures.day,
      night: opts.textures.night,
      clouds: opts.textures.clouds
    }
  })
}

pole hack

The imagery we obtained from NASA is in a cylindrical projection, so it pinches at the poles and looks bad.

To fix this, we can add a hazy blob over the poles and mask out clouds. This equation will mostly be 1 but transitions smoothly to 0 when we get very close to the poles:

float polar = pow(cos(pow(vpos.y,32.0)),32.0);
> for (var y = 0.8; y < 1; y += 0.02) console.log(y.toFixed(2),
... Math.pow(Math.cos(Math.pow(y,32)),32))
0.80 0.9999899566866057
0.82 0.9999512251250819
0.84 0.9997719942462097
0.86 0.9989724515977988
0.88 0.995532553589146
0.90 0.9813088831479267
0.92 0.9258243433142543
0.94 0.736404347948391
0.96 0.3047936925061606
0.98 0.009970060943567142

We can multiply the cloud cover (which stretches) by this polar coefficient and also add vec3(1.0-polar)*light*0.1 to the resulting channel to make a hazy blob where the clouds used to be.

var regl = require('regl')()
var camera = require('regl-camera')(regl, {
  minDistance: 1, distance: 3, phi: 1.2 })
var icosphere = require('icosphere')
var glsl = require('glslify')
var resl = require('resl')

resl({
  manifest: {
    day: { type: 'image', src: 'day.jpg' },
    night: { type: 'image', src: 'night.jpg' },
    clouds: { type: 'image', src: 'clouds.jpg' }
  },
  onDone: onloaded
})

function onloaded (assets) {
  var draw = earth(regl, {
    textures: {
      day: regl.texture({ data: assets.day, mag: 'linear' }),
      night: regl.texture({ data: assets.night, mag: 'linear' }),
      clouds: regl.texture({ data: assets.clouds, mag: 'linear' })
    }
  })
  regl.frame(function () {
    regl.clear({ color: [0,0,0,1], depth: true })
    camera(function () { draw() })
  })
}

function earth (regl, opts) {
  var mesh = icosphere(3)
  return regl({
    frag: glsl`
      precision mediump float;
      #pragma glslify: atmosphere = require('glsl-atmosphere')
      uniform vec3 eye, sunpos;
      uniform sampler2D day, night, clouds;
      varying vec3 vpos;
      void main () {
        vec3 pos = normalize(vpos);
        vec3 vscatter = atmosphere(
          eye-pos, // ray direction
          pos*6372e3, // ray origin
          sunpos, // sun position
          22.0, // sun intensity
          6372e3, // planet radius (m)
          6472e3, // atmosphere radius (m)
          vec3(5.5e-6,13.0e-6,22.4e-6), // rayleigh scattering
          21e-6, // mie scattering
          8e3, // rayleight scale height
          1.2e3, // mie scale height
          0.758 //  mie scattering direction
        );
        float lon = mod(atan(pos.x,pos.z)*${1/(2*Math.PI)},1.0);
        float lat = asin(-pos.y*0.79-0.02)*0.5+0.5;
        vec3 tday = texture2D(day,vec2(lon,lat)).rgb;
        vec3 tnight = texture2D(night,vec2(lon,lat)).rgb;
        vec3 tclouds = texture2D(clouds,vec2(lon,lat)).rgb;
        float light = length(vscatter);
        float polar = pow(cos(pow(pos.y,32.0)),32.0);
        vec3 c = vscatter*0.2 + tday*light
          + tclouds*(light*0.5+(1.0-light)*2e-4)*polar
          + vec3(1.0-polar)*light*0.5
          + pow(tnight,vec3(8.0))*pow(max(0.0,1.0-light),8.0);
        gl_FragColor = vec4(pow(c,vec3(1.0/2.2)),1);
      }
    `,
    vert: glsl`
      precision mediump float;
      uniform mat4 projection, view;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * vec4(vpos,1);
      }
    `,
    attributes: {
      position: mesh.positions
    },
    elements: mesh.cells,
    uniforms: {
      sunpos: function (context) {
        var t = context.time, r = 10
        return [Math.cos(t)*r,0,Math.sin(t)*r]
      },
      day: opts.textures.day,
      night: opts.textures.night,
      clouds: opts.textures.clouds
    }
  })
}

This looks acceptable now:

Now finally we'll add a small amount of opposing spin by subtracting time*0.01 from the latitude calculation:

var regl = require('regl')()
var camera = require('regl-camera')(regl, { minDistance: 1, distance: 3 })
var icosphere = require('icosphere')
var glsl = require('glslify')
var resl = require('resl')

resl({
  manifest: {
    day: { type: 'image', src: 'day.jpg' },
    night: { type: 'image', src: 'night.jpg' },
    clouds: { type: 'image', src: 'clouds.jpg' }
  },
  onDone: onloaded
})

function onloaded (assets) {
  var draw = earth(regl, {
    textures: {
      day: regl.texture({ data: assets.day, mag: 'linear' }),
      night: regl.texture({ data: assets.night, mag: 'linear' }),
      clouds: regl.texture({ data: assets.clouds, mag: 'linear' })
    }
  })
  regl.frame(function () {
    regl.clear({ color: [0,0,0,1], depth: true })
    camera(function () { draw() })
  })
}

function earth (regl, opts) {
  var mesh = icosphere(3)
  return regl({
    frag: glsl`
      precision mediump float;
      #pragma glslify: atmosphere = require('glsl-atmosphere')
      uniform sampler2D day, night, clouds;
      uniform vec3 eye, sunpos;
      uniform float time;
      varying vec3 vpos;
      void main () {
        vec3 pos = normalize(vpos);
        vec3 vscatter = atmosphere(
          eye-pos, // ray direction
          pos*6372e3, // ray origin
          sunpos, // sun position
          22.0, // sun intensity
          6372e3, // planet radius (m)
          6472e3, // atmosphere radius (m)
          vec3(5.5e-6,13.0e-6,22.4e-6), // rayleigh scattering
          21e-6, // mie scattering
          8e3, // rayleight scale height
          1.2e3, // mie scale height
          0.758 //  mie scattering direction
        );
        float lon = mod(atan(vpos.x,vpos.z)*${1/(2*Math.PI)}-time*0.01,1.0);
        float lat = asin(-vpos.y*0.79-0.02)*0.5+0.5;
        vec3 tday = texture2D(day,vec2(lon,lat)).rgb;
        vec3 tnight = texture2D(night,vec2(lon,lat)).rgb;
        vec3 tclouds = texture2D(clouds,vec2(lon,lat)).rgb;
        float light = length(vscatter);
        float polar = pow(cos(pow(vpos.y,32.0)),32.0);
        vec3 c = vscatter*0.2 + tday*light
          + tclouds*(light*0.5+(1.0-light)*2e-4)
          + vec3(1.0-polar)*light*0.5
          + pow(tnight,vec3(8.0))*pow(max(0.0,1.0-light),8.0);
        gl_FragColor = vec4(pow(c,vec3(1.0/2.2)),1);
      }
    `,
    vert: glsl`
      precision mediump float;
      uniform mat4 projection, view;
      attribute vec3 position;
      varying vec3 vpos;
      void main () {
        vpos = position;
        gl_Position = projection * view * vec4(vpos,1);
      }
    `,
    attributes: {
      position: mesh.positions
    },
    elements: mesh.cells,
    uniforms: {
      sunpos: function (context) {
        var t = context.time*0.5, r = 10
        return [Math.cos(t)*r,0,Math.sin(t)*r]
      },
      time: regl.context('time'),
      day: opts.textures.day,
      night: opts.textures.night,
      clouds: opts.textures.clouds
    }
  })
}

That will do for now. In future articles, we may cover:

For posterity, here are the versions of the packages used in this tutorial and some npm scripts to build your code:

{
  "dependencies": {
    "gl-mat4": "^1.1.4",
    "glsl-atmosphere": "^2.0.0",
    "glslify": "^6.0.0",
    "icosphere": "^1.0.0",
    "regl": "^1.2.1",
    "regl-camera": "^1.1.0",
    "resl": "^1.0.2"
  },
  "browserify": {
    "transform": [ "glslify" ]
  }
}