Wednesday, 9 October 2013

Terrain building with three.js

In my last blog post, we converted a digital elevation model (DEM) to a WebGL-friendly format (i.e. easy to transfer and parse by JavaScript in the browser). In this blog post, we'll use the elevation data to build a terrain mesh with three.js

First we need to transfer the terrain data to the browser. The elevation values are stored in a binary file as 16-bit unsigned integers. This page explains how you can send and receive binary data using JavaScript typed arrays. I've created a TerrainLoader by adapting the XHRLoader. You can also use this function:

function loadTerrain(file, callback) {
  var xhr = new XMLHttpRequest();
  xhr.responseType = 'arraybuffer';'GET', file, true);
  xhr.onload = function(evt) {    
    if (xhr.response) {
      callback(new Uint16Array(xhr.response));

Loading elevation data with the TerrainLoader is easy:

var terrainLoader = new THREE.TerrainLoader();
terrainLoader.load('../assets/jotunheimen.bin', function(data) {

The loader will return the elevation data as an array:

[37068, 38613, 39605, 40451, 39655, 38843, 38843, 38857, 39042, 39316, 40165, 40738, 40369, 39483, 37175, 34492, 32436, 31600, 33473, 37514, ...]

To preserve as much detail as possible, we were scaling the floating point elevation values (0-2470) to the full range of a 16-bit unsigned integer (0-65535). You'll find the elevation value with this formula: 

var height = Math.round(value / 65535 * 2470);

[1397, 1455, 1493, 1525, 1495, 1464, 1464, 1465, 1471, 1482, 1514, 1535, 1521, 1488, 1401, 1300, 1223, 1191, 1262, 1414, ...]

From heights stored in an array to a triangle mesh.

Creating a triangle mesh from our elevation data is straightforward, and three.js does the heavy work for us. Use THREE.PlaneGeometry to create a ground plane:

var geometry = new THREE.PlaneGeometry(60, 60, 9, 9);

The first two arguments are the width and height of the plane geometry. The next two arguments are the number of width and height segments. It will create this triangle mesh (click to see in WebGL):

The vertices are at the corners of the triangles. Each vertex has a X/Y/Z position. The red line is the X axis,  the green line is the Y axis and blue line is the Z axis (height). 

The width and height segments should be the width and height of your elevation grid minus one. The binary file we created is 200 x 200, so our plane geometry is 199 x 199:

var geometry = new THREE.PlaneGeometry(60, 60, 199, 199);

three.js stores all vertices in an array (geometry.vertices) similar to our array of height values (row by row, top to bottom). This makes it very easy to change the Z position:

for (var i = 0, l = geometry.vertices.length; i < l; i++) {
  geometry.vertices[i].z = data[i] / 65535 * 25;

If we render this geometry we get this result (click to see in WebGL):
Jotunheimen wireframe

var geometry = new THREE.PlaneGeometry(60, 60, 199, 199);

for (var i = 0, l = geometry.vertices.length; i < l; i++) {
  geometry.vertices[i].z = data[i] / 65535 * 10;

var material = new THREE.MeshPhongMaterial({
  color: 0xdddddd, 
  wireframe: true

var plane = new THREE.Mesh(geometry, material);

Besseggen wireframe

So we got our 3D landscape with just a few lines of code (available on GitHub). In the next blog post we'll add some texture to make it more realistic.

Romsdalseggen ridge in August. 


Anonymous said...

Hi Bjorn,
Great series of posts - looking forward to the next one on texturing the 3d model.

I couldn't get the examples to work and was getting an error (using Firefox):
"InvalidStateError: An attempt was made to use an object that is not, or is no longer, usable" besseggen.html:98

Managed to resolve it by putting the GET line before the responseType line in the Terrain Loader: 'GET', url, true );
request.responseType = 'arraybuffer';


Bjørn Sandvik said...

Thanks for your firefox fix anonymous! :-)

Anonymous said...

Hi Bjorn,

I've also really benefited from your post, so thank you.
Did you consider using shaders for the texturing, and if so why did you decide against it?

Bjørn Sandvik said...

I don't know enough about shaders to be able to use it. Please share your experiences if you give it a try!

Kevin Ring said...

Great series, thanks for posting! I'm curious if you've seen Cesium? Here's a terrain demo. We'd love to have you as a contributor!

Bjørn Sandvik said...

Hi Kevin,

I'm very impressed by your work on terrain rendering in Cesium! If I should recreate this example in Cecium, what would be the best/easiest way to add heightmaps and textures? Should I install a Cecium Terrain Server or implement my own TerrainProvider interface? It would be nice if I could just pre-render a set of heightmap/texture tiles and serve them directly. Is it possible?


Kevin Ring said...

Hey Bjorn,
Yes, you have the right idea. To get terrain data into Cesium, you can either create a tileset in a format supported by Cesium, or you can implement a custom TerrainProvider to access the terrain in whatever format it is in.

The tile format expected by CesiumTerrainProvider is documented here. Cesium also supports loading terrain from an ArcGIS Server or VR-TheWorld server out of the box, but unless you happen to have one of those already you're probably better off with the CesiumTerrainProvider.

If you'd prefer to write your own terrain provider, the best way to learn how to do that is to take a look at the source code for CesiumTerrainProvider.

The situation is similar for texturing. You can either use one of the already-supported imagery providers, or write a custom implementation of the ImageryProvider interface.

Let me know if you have any questions!


Bjørn Sandvik said...

Looks good Kevin! Would be even better if we had a tool like gdal2tiles to generate the .terrain tiles from a DEM. Any plans for a tool like this?

Kevin Ring said...

There are some folks working on modifying to generate terrain tiles. See here.

My own goals are a bit more ambitious. I'm working on a terrain server that allows users to easily import their own terrain data and process it for use with Cesium. The terrain tiles created by this server will be triangle meshes rather than heightmaps, which allows for both faster and more accurate terrain rendering.

The format of these mesh tiles will be open and published as well, but the server itself will be a paid product. This is a way for AGI (the company I work for) to recoup some of the massive investment it has made in Cesium development.

We welcome and encourage other solutions from the community, though!

Bjørn Sandvik said...

Yes, streaming terrain tiles as TINs will be more efficient. Please notify me when the tile format is ready - or make a post on your blog.

Kevin Ring said...

Will do!

Vincent said...

Hi Bjorn,

thank you for all your posts. I try to redo the whole thing by myself. Actually I was able to build the binary file with GDAL but I have some trouble with the the webGL grid generation.

I downloaded the file master ZIP, so I have something like that on my desktop. but when I try to execute the html with chrome only "plane.html" is working well.

Do I need to modify jotunheimen.html and besseggen.html for a desktop execution ?

I'm not a java expert and I don't know how to see in which line the code is breaking. Should I use something like Eclipse ?

Thanks for your answers.

Vincent said...

Hi again,

Since my last message I have learned more about JavaScript and I'm sorry for the confusing with the Java language.

I also identified the problem for a desktop execution. When I run it, the firefox console give me this report for besseggen.html and this one when I run jotunheimen.html. So the error comes from the line request.send( null ); one time in the besseggen.html JavaScript and another time in the TerrainLoader JavaScript.

I have read here and here that the BAD_URI error was added into the XMLHttpRequest object by browser editor to improve web security.

So, perhaps you have any ideas to fix the problem or to load the data in the browser ?

Michal Seidl said...

very nice examples. But what strategy should be used to support much larger datasets?

Is there any limit for number of faces? I tried to increase mesh size and FF crashed. It looks like some WebGL or GPU limit was reached. But which one and how to work around?

I saw some level of detail strategy but they are quite complicated and need support on server side.

Is it possible to increase data amount by writing special shaders or configuring GPU buffers or ...


Bjørn Sandvik said...

Vincent: You need to access your files through a web server.

Michael: You need some sort of LoD-algorithm, and I haven't found a decent solution for three.js yet. Have a look at this blog post.

Steven Sabo said...

I'm making great use of this method to render underwater bathymetry that I've collected!

I was wondering if there is a way to update the geometry that we have imported with the TerrainLoader. I've tried calling plane and replacing the geometry but because it is inside of it's own function it isn't a defined variable in the global context. Do you know of any method to update the height value of the grid?

I ask because I have a time series and would love to show year by year changes in the undersea landscape (slumping, deposition, etc.).