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:
- set up a basic webgl scene with a camera
- load texture assets into a scene
- write shaders to calculate atmospheric scattering and read textures
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.
- Vertex shaders run first for each vertex in the mesh and fragment shaders run after the vertex shader for each pixel in the output image.
- Uniform variables are available in both the fragment and the vertex shader and are the same (uniformly) for every vertex and pixel.
- Attribute variables are available in the vertex shader and are available for each vertex.
- Varying variables are a way to pass information from the vertex shader to the fragment shader. Varying variables are linearly interpolated based on the projected coordinates.
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
- day: http://visibleearth.nasa.gov/view.php?id=74418
- night: http://visibleearth.nasa.gov/view.php?id=79765
- clouds: http://visibleearth.nasa.gov/view.php?id=57747
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:
- specular maps and ggx lighting
- clickable outlines and labels
- animation
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" ]
}
}