Isometric Voxel Terrain Prototype

I'm currently prototyping a voxel based isometric renderer (for terrain) and it was suprisingly painless to get some neat looking results but it is far from fast and scaleable. But the performance issues are topic for another post and would require further investigations and coding sessions. For now I'd just like to share the results and give and explain a bit how I've done it.

voxel and normalmapping rendering results with different lightning and maps

The renderer works just like a combination of displacement and normal mapping but 2D.
It takes a grayscaled heightmap and uses it to look up how much each pixel needs to be moved up. It also uses a normalmap to calculate the intensity for each pixel based on the current light-direction.

Height- and Normalmap:
Generating a heightmap is pretty easy as you can just open up any drawing tool and create one. It is easy to guess how it will effect the end result; darker pixels are valleys, brighter ones are mountains. But drawing a normalmap by hand doesn't make so much sense anymore. First it is really difficult and second it is most likely never going to fit to your heightmap. Doing it by hand is fun though and deliveres some interessting results, but the most common way is to do it in 3D and is also the way I did it.

I started off with an existing heightmap I found somewhere on the internet but I had no fitting normalmap. So I used the heightmap in 3DMax and generated a poly heavy terrain using displacement mapping. I then arranged a specific lightning and camera setup depicted below.

lightning setup
The camera looks straight down on the terrain and there are 6 lights along each axis facing the object. The lights have different colors for each axis and different intensity depending on what side of the axis they are. The additional white aswell as the negative light sources are necessary to provide even lightning without favoring a specific axis.

The actual color and intensity configuration depends on how the normalmapping algorithm interprets each color. In my case green color marks faces looking up/down, red pixels indicate left/right and blue would be front/back. But blue is negligible because the camera cannot see anything from the back of the object.

If you don't have a heightmap or a more complicated terrain/object not generated by a heightmap you can still easily get one by rendering the z-buffer. You might need to play with the max and min range of the buffer to get a detailed greyscale with as many shades as possible in order to prevent any unwanted jumps when the terrain is generated out of it.

Isometric Projection: 
First I draw myself a big diffuse-map out of multiple small grass textures and transform it along with the height- and normalmap into isometric perspective. You don't really need to do this and could just transform the position of each pixel on the fly when drawing the final terrain but you need a way to project each point into isometric space never the less and a transformation matrix is the key. I'm not really into matrix calculations so I snatched the matrix from somewhere online.
public function getTransformationMatrix():Matrix
 var m:Matrix = new Matrix();
  m.rotate(Math.PI / 4);
  m.scale(1, 0.5);
  m.scale(MathTools.SQRT2, MathTools.SQRT2);     
 return m;
The rotation is easy to see and understand but why do I set scale twice and once with the square root of 2. It is also important to do the roation before the scaling. Taking a look into the Matrix API reveales that I don't really want to o deeper into whats happening but the least I can do is describe how each methode affects the transformation.

The rotation is 45°clockwise around the zero point (top left of the image) and then we scale its height by a half to get the typical isometric look. Theoretical we already have our truly isometric projection and could leave it there. But there is a  problem when you try to align multiple isometric tiles together.

The issue is that when rotating the square you get its diagonal as width and the diagonal of a square with whole-numbered sides is never whole-numbered and therefore difficult to tile. It is always something multiplied by the square root of 2. So to get rid of the square root you multiply it by another one and cancle out the original square root.
diagonal² = side_length² +  side_length²
diagonal² = 2 * side_length²
diagonal = sqrt(2 * side_length²)
diagonal = sqrt(2) * side_length // * sqrt(2) to cancle it out
diagonal * sqrt(2) = 2 * length;
In order to transform any bitmap you just need to create a new bitmap twice the width of the old one and call the draw methode with your original topdown map and the matrix as arguments. If you do not add any translation to your matrix you would end up with only the right half of the projection.
public function projectBitmapData( topdown:BitmapData, ?smoothing:Bool = false ):BitmapData
 var matrix:Matrix = this.getTransformationMatrix();
  matrix.translate( topdown.width, 0 );
 var projection:BitmapData = new BitmapData( topdown.width * 2, topdown.height, true, 0x00000000 ); 
  projection.draw( topdown, matrix, smoothing );
 return projection;
Now that I have all my maps isometric, I iterate over each pixel, skip the once that are zero alpha and perform the voxel and normalmap calculations. But first I need to extract the RGB components for each map and get seperate values for each color channel.

Color Seperation:
Splitting each channel is especially necessary for the normalmap as each channel (r,g,b,a) represents a different axis. I also need each channel for the diffuse map to correctly mix the color in the end and to skip uninteressting zero-alpha pixels. The heightmap is greyscale so I only need one of the channels as all other channels store the same value but I still seperate it aswell.

You can split a color by bitshifting the value to the right depending on what channel you want to get. And assemble it back together shifting it to the left again and OR it together.
public function getColorVector( color:Int ):Vector3D
 var vector:Vector3D = new Vector3D();
  vector.x = (color >> 16) & 0xFF;
  vector.y = (color >> 8) & 0xFF;
  vector.z = color & 0xFF;
 return vector;

public function getColor( vector:Vector3D ):Int
  return (vector.x << 16) | (vector.y << 8) | vector.z;
The first 8 bits represent blue, the next 8 green, then red and the last ones represent alpha. Its the same like Hex but in reverse order and instead of 2 digets there are 8 for each color. So the value of red for example starts at bit 17 and ends at bit 24. To extract only the red part and ignore everything else, you simply remove the first 16 bits by shifting it to the right using the >> operator.

But there might be a non-zero alpha value to the left of the red bits which would increase the value of red. (Maybe even above the allowed 255). To get rid of the alpha channel bits (25 till 32) we use the bitwise AND operation. It only returns the bits that are 1 on both sides of the operator and sets all other bits to 0. For example 0xFFAA22 & 0xFF returns 0x000022. You cannot just bitwise AND 0x0000FF for the blue channel and 0x00FF00 for green, because of the way hex values are actually represented in binary. Both values are the same in binary but the API handles the leading digits in conjunction with colors in a useful way and this is more or less the reason why you can do assign 0x00FF00 as a color and get a green result.

Assembling the color back together works the same way but everything in reverse. I'm not going to elaborate this further, although the bitwise OR might require similar explaination.

illustration of different stages of the algortihm

Normalmapping and Voxel:
Next I calculate the light intensity of each pixel using the normalmap and a predefined vector representing the direction of light. The calculation for normalmapping boils down to the dot product of 2 3D-vectors and luckly Vector3D has that methode already.

The dot product gives me the angle between the two vectors. Because I don't care about the length of any of the vectors and only care about their orientation in space I make sure they are both normalized. Based on the angle I can decide how much light the pixel recieves. If the light vector is normal to the surface the resulting intensity is 0 if it has the same orientation the result is 1.
var lightDirection:Vector3D() = new Vector3D( 120, 45, 45 ); // arbitary values

px_normal.normalize(); // px_normal: getColorVector( normal.getPixel( x, y ) )

var intensity:Float = Math.max( 0, px_normal.dotProduct( lightDirection ) );
Finally I decide where to place each pixel in the output and calculate the resulting color of it. The step that actually makes it 3D. I look up the height of the pixel using the heightmap, calculate the absolute position and set new pixels between the original position and the new one. I do this because otherwise the resulting image would have gaps between pixels with different heights. You can see this at second step in the illustration above.

In order to enhance the illusion of a 3D terrain I darken the pixels more the farther at the bottom they are. This step makes a huge difference in my opinion. You could easily just use this simple way of faking depth and not use normalmapping at all. At last I calculate the final color using the previously calculated intensity, the shading and of course the original color of the texture.
for ( z in 0...px_height.x ) // px_height.x is the red channel of the heightmap
 var y_new:Int = 255 + y - z;      // pixel position, 200 is arbitary  offset, y is of the current pixel  
 var darkening:Float = z / px_height.x;  // darkening bottom pixels 
 // calculate new color:
 var r:Int = px_diffuse.x * darkening * intensity;
 var g:Int = px_diffuse.y * darkening * intensity;
 var b:Int = px_diffuse.z * darkening * intensity;
 // assemble each color channel from material:
 var color:Int = (255 << 24) | (r << 16) | (g << 8) | b;
 output.setPixel32( x, y_new, color );

As you can see, filling the gaps is a huge(!!!1) performance sucker the way I do it, as it draws in the worst case up to 255 additional pixels for each pixel(!) and the majority of them are going to be covered anyway. But as I said: Performance is a topic for another time.

Demo (might timeout, should work standalone)

No comments:

Post a Comment