I’ve always had a fascination with maps. I’ve done some mapping work with mapbox and tilemill which are two awesome products. I’ve also written a KML parser for 3dsMax which lets you exchange shapes and models between Google Earth and 3dsMax. Wouldn’t it be great to build real 3D terrains too? So one day I had a quiet moment and started creating 3D meshes based on remote sensing. Eventually this triggered me to venture out of 3dsMax and create my first ever C# library. This is part one of a three part tutorial. It’s about reading binary srtm data, using .net in maxscript and building a simple C# library from scratch. Have fun!
This article and part two and three have also appeared on the Artur Leao’s website youcandoitvfx.
Digital terrain data comes in many flavors. There are also many sources of digital terrain data: local, national and global, commercial and free. Usually the larger the database the coarser the data. Depending on the goal you’re aiming for, you need to select your data source. I went with the Shuttle Radar Topography Mission (SRTM) data for this tutorial. It’s free, has an almost global coverage and last but not least has a very simple binary file-structure. Read more about SRTM here. Many other data sources use the GeoTiff format which is a bit harder to parse. But after this project might be actually within reach.
SRTM data format
The SRTM files contain a grid of altitude samples. These samples are spaced about 30 meters (SRTM1: for the US) or 90 meters (SRTM3: for the rest of us) apart. I’ll focus on the SRTM3 data in the rest of this tutorial. The SRTM data is chopped into square tiles of 1201 by 1201 samples. This means an SRTM3 tile covers about 108 by 108 kilometers of earth. Each tile overlaps one row or column with its neighbour. The location on earth of each tile is determined by its filename. Filenames refer to the latitude and longitude of the lower left corner of the tile, e.g. N37W105 has its lower left corner at 37 degrees north latitude and 105 degrees west longitude. You can get the SRTM data files here.
Here’s documentation on the SRTM data format.
The general idea is to read the samples of the binary data files and apply these to a mesh of 1201 by 1201 vertices. Each sample in the file determines the height of a vertex. The result is a digital terrain. Maxscript can read binary data, so it should be pretty easy. I’ll provide the code here and discuss.
Code sample, pure maxscript
There are three methods: one reads and interprets the data from the file, one builds a basic mesh and one applies the heights to the mesh.
( function fn_readBytes strFilePath &outputmessage = ( /*<FUNCTION> Description reads all bytes in a binary file, creates integers while swapping the endian type Arguments <string> strFilePath: the file we're reading <value by reference> outputmessage: a message we're reporting to Return <return_type> Function returns (anything?). <FUNCTION>*/ local theStream = fopen strFilePath "rb" --open a binary filestream local fileSize = getFileSize strFilePath --read two bytes, swap their places and make an integer local theInt = [0,0] local arrInt = for i = 1 to fileSize by 2 collect ( theInt.x = ReadByte theStream #unsigned theInt.y = ReadByte theStream #unsigned bit.or (bit.shift theInt.x 8) theInt.y --shifting a byte converts the data from big endian to little endian ) FClose theStream format "File has % bytes, % integers read\n" fileSize arrInt.count to:outputmessage arrInt ) function fn_buildMesh segments:1200 segmentSize:90 = ( /*<FUNCTION> Description builds a mesh. the mesh will be arranged in such a way that the first vertex is at the top left and the last vertex is the bottom right Arguments <integer> segments: the amount of width and length segments <integer> segmentSize: the size of a single segment Return <mesh object> the newly created mesh object <FUNCTION>*/ --build a planar mesh local theMesh = Editable_mesh wirecolor:(color 80 70 0) setMesh theMesh\ width:(segments*segmentSize)\ length:-(segments*segmentSize)\ --a negative length puts the first vertex at the top left. This matches nicely with the data widthsegs:segments\ lengthsegs:segments --flip the normals because we set the length to a negative value addModifier theMesh (Normalmodifier flip:true) convertToMesh theMesh update theMesh theMesh ) function fn_applyHeights theMesh arrHeight = ( /*<FUNCTION> Description applies the heights to the vertices in the mesh Arguments <mesh object> theMesh: the mesh we're editing <array> arrHeight: an array of integers we'll use as heights Return <FUNCTION>*/ local meshvert = undefined local arrVert = for i = 1 to arrHeight.count collect ( meshvert = getVert theMesh i meshvert.z = arrHeight[i] meshvert ) setMesh theMesh vertices:arrvert update theMesh ) gc() local st = timeStamp() local mem = heapFree local msg = "" as stringstream local strFile = @"<your folder>\S23W068.hgt" local theMesh = fn_buildMesh segments:1200 segmentSize:90 local arrInt = fn_readBytes strFile &msg fn_applyHeights theMesh arrInt format "Time: % ms, memory: %\n" (timestamp()-st) (mem-heapfree) format "%" (msg as string) )
While parsing the data I’ve done a few things to make it work. First, each sample consists of two bytes in the file. Two bytes combined make up a 16 bit integer. According to the file specs it’s a signed integer which means the values run from -32768 to +32767 meters. Another thing: according to the spec the data is provided as “Big endian”. Effectively this means we need to swap the order of each pair of bytes before converting it to the integer. More about that here on wikipedia. The method fn_readBytes deals with this.
The data file is a long series of bytes, 1201*1201*2=2884802 bytes to be precise. They’re stored in row major order, first the bytes of row 1, then row 2 etcetera. The first value in the file is at the north-west corner of the grid, the second value is its neighbour to the east, and so on.
We need to match it to a grid of 1201*1201=1442401 vertices in the mesh. The vertices are arranged in a grid. When building the mesh, 3dsMax arranges the vertices also in row major order. But in the mesh, the first vertex is located at the south-west corner. This means the first row in the file maps to the last row in the mesh. We need to put the first vert of the mesh at the top left corner to save ourselves a headache later on. This is done by creating a mesh object with a negative length. this puts the first vert at the right position. It also flips the normals of the mesh, but this is easily solved.
Finally we’re editing the mesh object with the heights we got from the file. The setMesh method is really great. You can just feed it the things you want to edit and the rest stays the same. We only want to change the vertices, so that’s what I’m passing into the setMesh method. It’s a really simple procedure since we’ve made sure before the vertex order in the mesh lines up with the data order in the file.
The first run is pure maxscript. It needs about 7 seconds and a whole lot of memory to build a 1.5 mln vert mesh. For a one time operation this is workable, but if you need to create terrains like these multiple times it’s too slow. Besides that, the mesh is one big chunk which slows max down. It would be a good idea to slice the mesh into multiple chunks to improve viewport performance which is sluggish to say the least.
There’s also an issue with some spikes. According to the spec, unknown values should be -32768. In our case, the spikes are pointing upward.