Explanation of Technique
If you look at any piece of land in the real world you'll notice that it's made of a number of different materials. Rocks, dirt, foliage, etc. You'll also notice that the materials at some levels are different than those at higher or lower levels and that some materials don't show up on slopes over a certain degree (like foliage on steep slopes.) Our purpose here is to mimic this aspect of nature to some degree.
The Material
Terrain shader material |
<Material>
<Shader source="terrain.shader.xml"/>
<TexUnit unit="0" map="heightmap.tga" allowCompression="false" />
<TexUnit unit="1" map="dirt.jpg" />
<TexUnit unit="2" map="grass.jpg" />
<TexUnit unit="3" map="rock.jpg" />
<TexUnit unit="4" map="snow.jpg" />
<Uniform name="sunDir" a="1.0" b="-1.0" c="0.0" />
<!-- a=min height, b=max height, c=min slope, d=max slope -->
<Uniform name="rockData" a="0.0" b="520.0" c="0.16" d="1.0" />
<Uniform name="snowData" a="240.0" b="520.0" c="0.0" d="0.7" />
<Uniform name="dirtData" a="0.0" b="270.0" c="0.0" d="0.83" />
<Uniform name="grassData" a="0.0" b="256.0" c="0.0" d="0.66" />
<!-- a=grass, b=dirt, c=rock, d=snow -->
<Uniform name="mapStrength" a="1.0" b="0.4" c="0.28" d="0.86" />
</Material>
|
The Material Explained
There's nothing fancy going on here of course. I got the values for the data variables and map strength just by fiddling around with them in a small test program.
The Vertex Shader
Terrain vertex shader |
uniform vec4 terBlockParams;
attribute float terHeight;
varying float height;
varying vec3 triTexCoords;
void main( void )
{
vec4 newPos = vec4(gl_Vertex.x * terBlockParams.z + terBlockParams.x, terHeight,
gl_Vertex.z * terBlockParams.z + terBlockParams.y, gl_Vertex.w);
vec4 pos = calcWorldPos(newPos);
triTexCoords = newPos.xyz;
height = pos.y;
gl_Position = gl_ModelViewProjectionMatrix * pos;
}
|
Explanation of the Vertex Shader
Some of you may have noticed that this is awfully similar to the example shader that comes with the terrain extension. The only differences here are a.) We're now using a vec3 to hold the texture coordinates which will be required for the triplanar texturing, and b.) We're saving the height of the vertex for the height/slope based portion of the fragment shader.
The Fragment Shader
Terrain fragment shader |
uniform vec4 sunDir;
// tex0=heightmap, tex1=dirt, tex2=grass, tex3=rock, tex4=snow
uniform sampler2D tex0, tex1, tex2, tex3, tex4;
// .x = min height, .y = max height, .z = min slope, .w = max slope
uniform vec4 snowData;
uniform vec4 rockData;
uniform vec4 dirtData;
uniform vec4 grassData;
// .x=grass, .y=dirt, .z=rock, .w=snow
uniform vec4 mapStrength;
varying float height;
varying vec3 triTexCoords;
vec3 light = -normalize( sunDir.xyz );
// this comes from the book Real-Time 3D Terrain Engines Using C++ And DirectX
float computeWeight(float value, float minExtent, float maxExtent)
{
float weight = 0.0;
if(value >= minExtent && value <= maxExtent)
{
float range = maxExtent - minExtent;
weight = value - minExtent;
// convert to [0, 1] based on its distance to midpoint of the extents
weight *= 1.0 / range;
weight -= 0.5;
weight *= 2.0;
// square result for non-linear falloff
weight *= weight;
// invert and bound check
weight = 1.0 - abs(weight);
weight = clamp(weight, 0.001, 1.0);
}
return weight;
}
void main( void )
{
vec4 texel = texture2D(tex0, triTexCoords.xz) * 2.0 - 1.0;
// Use max because of numerical issues
float ny = sqrt(max(1.0 - texel.b*texel.b - texel.a*texel.a, 0.0));
vec3 normal = vec3(texel.b, ny, texel.a);
// Wrap lighting for sun
float l = max( dot( normal, light ), 0.0 ) * 0.5 + 0.5;
// slope: 1.0 = steep, 0.0 = flat
float slope = 1.0 - ny;
vec4 weights = vec4(0.0, 0.0, 0.0, 0.0);
// once all 3 lookups have been done for a texture these weights
// say how much of the sum of those lookups to use
weights.x = computeWeight(height, rockData.x, rockData.y) *
computeWeight(slope, rockData.z, rockData.w) * mapStrength.z;
weights.y = computeWeight(height, dirtData.x, dirtData.y) *
computeWeight(slope, dirtData.z, dirtData.w) * mapStrength.y;
weights.z = computeWeight(height, snowData.x, snowData.y) *
computeWeight(slope, snowData.z, snowData.w) * mapStrength.w;
weights.w = computeWeight(height, grassData.x, grassData.y) *
computeWeight(slope, grassData.z, grassData.w) * mapStrength.x;
weights *= 1.0 / (weights.x + weights.y + weights.z + weights.w);
// this comes from the gpu gems 3 article:
// generating complex procedural terrains using the gpu
// used to determine how much of each planar lookup to use
// for each texture
vec3 tpweights = abs(normal);
tpweights = (tpweights - 0.2) * 7.0;
tpweights = max(tpweights, vec3(0.0, 0.0, 0.0));
tpweights /= (tpweights.x + tpweights.y + tpweights.z).xxx;
vec4 finalColor = vec4(0.0, 0.0, 0.0, 1.0);
vec4 tempColor = vec4(0.0, 0.0, 0.0, 1.0);
// dirt
tempColor = tpweights.z * texture2D(tex1, triTexCoords.xy*5.0);
tempColor += tpweights.x * texture2D(tex1, triTexCoords.yz*5.0);
tempColor += tpweights.y * texture2D(tex1, triTexCoords.xz*5.0);
finalColor += weights.y * tempColor;
// grass
tempColor = tpweights.z * texture2D(tex2, triTexCoords.xy*5.0);
tempColor += tpweights.x * texture2D(tex2, triTexCoords.yz*5.0);
tempColor += tpweights.y * texture2D(tex2, triTexCoords.xz*5.0);
finalColor += weights.w * tempColor;
// rock
tempColor = tpweights.z * texture2D(tex3, triTexCoords.xy*5.0);
tempColor += tpweights.x * texture2D(tex3, triTexCoords.yz*5.0);
tempColor += tpweights.y * texture2D(tex3, triTexCoords.xz*5.0);
finalColor += weights.x * tempColor;
// snow
tempColor = tpweights.z * texture2D(tex4, triTexCoords.xy*5.0);
tempColor += tpweights.x * texture2D(tex4, triTexCoords.yz*5.0);
tempColor += tpweights.y * texture2D(tex4, triTexCoords.xz*5.0);
finalColor += weights.z * tempColor;
gl_FragColor = finalColor * l;
}
|
Explanation of the Fragment Shader
The most important part of this shader is in calculating the weights to use not only for each texture but also for each plane(xy, yz and xz.)
The weights variable holds the weights for each individual texture. Height and slope weights are calculated for each texture then multiplied with the strength of the texture which basically tells how important that texture is when it's being blended with others. The last line of the weights calculation makes sure the sum of all the weights is 1.
The tpweights variable holds the weights for each plane. It's based on the normal that was calculated at the start of the function and must also sum to 1.
Once we have all our weights we do a texture lookup using each of the planes in triTexCoords as texture coordinates, multiply each by it's weight, add them all up and then add that sum to the final color.
Final Words
This is quite an expensive shader. You could get away with adding another texture or two to the mix but you'd quickly start limiting your target audience. You may notice this is also a modified version of the example shader from the terrain extension.
Example Results
|