Rivers. In older tile-based games, rivers were the blue squares, lighter than the dark blue ocean tiles, and "bluer" than the greyish swamp tiles. Nowadays, in modern isometric games, rivers are a bit more detailed, showing rather realistic banks, ripples on the surface of the water, etc... I've always had a tough time trying to create river tiles manually for my isometric projects. However, by using a few procedural methods to take a lot of the grunt work out of it, we can achieve fairly respectable results in a short amount of time.
In this entry, I'm only going to cover creation of a single straight stretch of river; however, the other sections (corners, intersections, etc...) follow by extension.
Our river begins as a function. Each segment of the river (corners, straights, etc...) is represented by areas which are river bed and areas which are river bank. We start by creating a mathematical function to delineate areas. We want to smoothly graduate from riverbank to river bed. So, for the straight sections, we'll go with this function for the river cross-section:
function river_bed_func(x,y, center_y, width)
Now, let's whip up a quick function to create a 2D array and fill it with a chunk of river bed:
function build_river_bed(size, width)
for x=0,size-1,1 do
for y=0, size-1,1 do
To get an idea of what we're working with here, let's go ahead and see what this cross-section is going to look like in action:
The basic idea is there. The neat thing about using a function to define the shape of the river-bed is that we can remap the output to a curve to tweak the profile of the river bed cross-section. Let's go ahead and apply a curve to sort of "flatten" the river cross section a bit, giving it a broader, flatter bottom and steeper sides:
You can tinker with the curve to get any profile of riverbed you desire.
So now that we have the basic cross-section of the river, we need to make it look more natural. As it is, everything is straight and smooth and... well... very "un-riverlike". So let's go ahead and noise it up a bit. In the file "utilities.lua" is a function just perfect for our needs.
function perturb_array_by_fractal(buffer, fractal, power, x1, y1, x2, y2, seed1, seed2)
This function accepts the array to act upon (buffer) a noise fractal module, a value to scale the amount of turbulence applied, a set of ranges for x and y specifying the area of noise (on Z=0) to map for the noise source, and a pair of random seeds. The seeds are used by the seamless noise generator utilities used internally by the function. By saving the seeds in this manner, we can re-use the seeds on a different buffer later (which we shall be doing) and be assured that the same turbulence will be applied.
We'll start by first setting up a basic noise fractal as the turbulence source:
This sets up a Fractional Brownian Motion (FBM) fractal source, with 3D gradient noise functions as the basis. The fractal classes in ANL allow quite a bit of flexibility. Each octave of a fractal can be any of the general basis functions (value noise, gradient, simplex, cellular), or any module chain of combined basis functions. However, for something as simple as this, we really only need a basic Perlin noise pattern, hence the call to buildSimple which sets up a fractal with all octaves of a specified type, in this case 3D gradient noise.
Now, let's go ahead and perturb our cross section:
riverbed=perturb_array_by_fractal(riverbed, fbm, 4, 0, 0, 4, 4, seed1, seed2)
We can play with the x and y ranges to modify the frequency of the noise applied to the array, and we can play with the power value to increase or decrease the amount that the turbulence affects the source. Now, let's go ahead and see what affect this had on our river bed:
That looks a bit more riverlike, eh?
Now, for the moment, we are going to be done with our river bed. That part is just about finished, so now we are going to turn to the color-map portion of the project. We need a texture to map onto the river bed. This part is actually pretty easy. First, we are going to select two base color maps: one of the river bed and one for the banks. How you obtain these colormaps is really up to you. They can be cobbled from real-world photos, they can be hand-drawn, they can be procedurally generated, etc... For the purposes of this entry, we'll just use a couple cobbled from photos. Here they are (links to .TGA versions):
The gravel image will be for the river bed. Now, let's use these images to construct the texture.
First, we need to go back to our river-bed cross-section. We'll go ahead and construct another array using the riverbed function, and resample it with our curve, then perturb it. Only this time, we'll want a larger array (for more detail in the texture), so we'll need to account for the difference in array sizes.
riverbed_texture_blend=perturb_array_by_fractal(riverbed_texture_blend, fbm, 4*256/64, 0, 0, 4, 4, seed1, seed2)
What we did there was we filled an array with the riverbed base function, then mapped it to the curve. When we perturbed it, however, we had to scale the turbulence power by the ration of array sizes. We used a power of 4 in the smaller array, so we need to scale the power up so that the turbulence will have an equivalent effect on the larger array. (As it turns out, the final turb power in this case is 16). We re-use the random seeds from the earlier step so that the turbulence pattern of the texture will match the turbulence pattern shaping the geometry of the river bank.
With this blend map in hand, we can perform a simple blend between the two textures into another image, and save it out for our texture. We can use another useful utility function:
function blend_images_by_array(img1, img2, blend)
This function takes 2 images and an array to blend by, and returns another image that is a linear interpolation of the 2 source images using the values stored in the blend array. Where the blend array is 0, img1 is selected, and where the array is 1, img2 is selected.
riverbed_texture=blend_images_by_array(img1, img2, riverbed_texture_blend)
Let's see our final result:
We are just about done. One final step remains before we move on to the dessert course: let's take the sized 64 array we created earlier, and output it as a Wavefront .OBJ file. I chose .OBJ because of the simplicity of writing an exporter for it. In the utilities file is a simple script for dumping an array to a .OBJ file.
function save_buffer_mesh(name, buffer, heightscale, wrap)
This function constructs the mesh using heightscale to scale the z coordinate. The mesh is exported to be 1 unit in size in the x and y axes, and must be resized in the 3D editor. The wrap parameter determines whether or not the mesh is designed to wrap around (tile with itself). This utility function is suitable for the purposes of this demonstration; however, when constructing a full set of tiles we would need to construct the normal arrays for the mesh a bit more carefully, to account for adjacent pieces and strange wrappings. Let's go ahead and export a mesh:
save_buffer_mesh("riverbed_mesh.obj", riverbed, 0.5, true)
This concludes the procedural portion of the evening's entertainment. From here, we need to fire up Blender (or your 3D package of choice). Once in Blender, we need to set up our isometric camera and lighting parameters. I typically start by deleting the pre-created camera and default cube and light. Create a new camera, set it to orthographic, and arrange it so that it rotates around the x axis by ~-30 degrees, and the Z axis by 45. (This creates a close approximation of the 2:1 tile ratio found in most 2D isometric games). I perform all lighting using Sun-type lamps (to avoid shading/lighting artifacts on images meant to tile in an isometric engine). Typically, I set up the Key light to point along the y axis toward the origin, and down at perhaps a 60 degree angle, and the Fill light to point straight along the x axis toward the origin. However, lighting is up to you.
At any rate, once the stage is set, we import the .OBJ and scale it up to desired size. What we are looking for here is for the mesh to fully fill the viewport, left to right, so that the corners just touch the edge of the image when rendered. We will be snipping a piece out of the final render, and it is important to render correctly so the piece lines up just right.
Scale up the tile, fit it to the viewport, center it on the 3D grid, and adjust the Scale parameter of the camera until the tile fills the camera's view. Then, create a new material, add a texture to it, and for the texture, load the "riverbed_texture.tga" image that we created. The exported .OBJ contains UV coordinates, but for the purposes of this, we can just use a default Flat mapping and it works just the same. Turn down the Specular color and adjust the lighting until you have something you like. When done, you should have a render that looks like this:
Now, select the tile and duplicate it, slide it over (in Blender, hold CTRL as you move it so it snaps to grid) and align it with the existing piece. Duplicate again, and align it adjacent with the other side. The render should look like this:
Now we need water. Doing water is, of course, fairly engine specific. Some engines may support a dynamic water layer that allows water animation. Others may support water animation as a side effect of animated tiles in general. We'll not worry about that for now; you can sort that out later. For now, we're just going to add a static, partially transparent water layer. First, create a basic Plane mesh, and size it identical to the size of the riverbed tile. Make sure it is exactly centered over the tile, and adjust the Z translation of the plane so that it cuts your riverbank tile mesh at the desired water height. Once you have positioned your water level, create a new material and load up a water texture. Here is a basic one we can use (procedurally created in a long-ago article):
In Blender, you want to select the ZTransp button and deselect the Traceable button under the Links and Pipeline settings of the water material, so that it correctly layers over the tile. Adjust the alpha transparency of the water material until you have a value you like. Once this is done, duplicate the water plane twice, translating each copy (again, using CTRL to snap to grid) so that the planes line up with the adjacent tile copies. The final render should look something like:
We're almost done. At this stage, you may want to do some things to decorate the tile a bit. Add some rocks and stuff in the water, maybe some vegetation and debris at the waterline. Try to dress it up, make it look good, hide the proceduralism of it. While it looks pretty good as it is, the devil, as they say, is in the details. For this purpose, it is good to keep a library of little widget objects: pieces of rock, bits of vegetation, little doodads and doohickeys that you can scatter around.
At this point, we are just about done. The final step is to "snip" out the diamond-shaped isometric tile and test it out. You can use Blender to create a mask to help with the snipping. Construct a flat white, un-shaded plane and align it with the top of the riverbed tile mesh, as exactly aligned with the top as you can. Move the tiles out of the way and just render the white plane, against a black background, so that the final render is of a white diamond on black:
Note that this isn't the pixel-perfect way of constructing a mask; typically, you have to hand-edit the mask to get a perfect 2:1 ratio, due to floating-point jitter in the render, but for our purposes it should be sufficient.
Import the render of the tiles and the mask into the Gimp and use the mask as a selection mask to "snip" out a diamond piece of the render. This is your isometric water tile.
For giggles, create a new image in Gimp and copy/paste this tile a few times, aligning it so that the images tile, and you can see how well the tile works.
Now, of course, as with any good procedural system, there are a thousand ways this can be tweaked. You can tweak the curves use to create the cross-section and blend mask, you can tweak the type and strength of the turbulence, you can tweak the water texture and transparency. You can tweak the base texture images. You can add additional layers of noise as surface roughness for the bank and bed areas. And so forth. The above procedure is just a good starting point, to get you up and running quickly.