Fig 1. Terrain generated on GPU.
It's a kind of method which generates complex terrains on GPU (also known as Procedural Terrains).
Given a low-poly mesh and height map, we can do displacement mapping on original mesh on GPU.
With this, we can reduce CPU overhead on Input-Assembler Stage in render pipeline. But we can still get a decent terrain we want.
Displacement
Pretty simple, we use a height map which stores the intensity of displacement.Apply it to our mesh and translate vertices along with its normal direction.
Fig 2. Displacement mapping with a height map.
// Displacement Code
// Since we are using height map on vertex-stage or domain stage(talk about this later)
// we can't use tex2D() or Sample().
// So we call tex2Dlod here (or you can use Load())
float3 Displacement(float3 vNormal, float2 uv, float heightScale)
{
float4 heightData = tex2Dlod(_HeightTex, float4(uv, 0, 0));
heightData.xyz = normalize(heightData) * vNormal * heightScale;
return heightData;
}
i.PosL += Displacement(i.NormalL, TRANSFORM_TEX(i.TexC, _HeightTex), _HeightMapScale);
We calculate displacement mapping on local space.
You can also calculate it on world space if you want.
But here comes a problem: Normal vector is wrong after displacement mapping.
We've changed position of vertices, but normal vectors are still the same as original.
To solve this problem, we copy height map and import it as normal map. (You can use other tools for converting height map to normal map of course.)
Unity will calculate normal vectors automatically. And we can put it into our shader.
Fig 3. Import height map as normal map.
With this normal map, we can adjust our vertex normal vectors and make it fits the vertices which are done with displacement mapping.
// since we displacement the geometry, we need to adjust normal
// sample new normal from height normal map
// calculate new tangent by using gram schmidt process
float3 normalHeight = UnpackNormal(tex2Dlod(_HeightBumpTex, float4(o.TexHeight, 0, 0))).xyz;
normalHeight = Vec3TsToWsNormalized(normalHeight, o.NormalW, o.TangentW, o.BinormalW);
o.TangentW.xyz = normalize(o.TangentW.xyz - (normalHeight* dot(o.TangentW.xyz, normalHeight)));
o.BinormalW = cross(normalHeight, o.TangentW) * o.TangentW.w;
o.NormalW = normalHeight;
In other words, we calculate normal mapping twice now. You need to do bump mapping with these new vectors to get correct lighting.
Tessellation
Terrain in Fig 2 is still not as good as Fig 1. Because we are doing displacement in vertex shader stage. The quality of displacement mapping depends on the quantity of vertices. We can get better results with more vertices. But it will increase CPU overhead on Input-Assembler Stage.
So here comes the Tessellation.
Like the MSDN said, now we have two additional shaders: hull shader & domain shader.
With these feature, we can split our meshes to smaller pieces!
We can even adjust tessellation factor based on distance between object and camera for dynamic LOD.
Fig 4. Tessellation on meshes.
Hull Shader: To tell hardware how we tessellate our meshes.
Domain Shader: It's just like vertex shader after tessellation. Move all calculations to here from vertex shader.
Remember to add definition in Unity's shader
#pragma hull MainHs
#pragma domain MainDs
// ------------------- Main Hull Shader ------------------- //
// a struct which describes tessellation factor
// EdgeTess: Defines the tessellation amount on each edge of a patch.
// InsideTess: Defines the tessellation amount within a patch surface.
struct PatchTess
{
float EdgeTess[3] : SV_TessFactor;
float InsideTess : SV_InsideTessFactor;
};
PatchTess ConstantHS(InputPatch<VertexOut, 3> patch, uint patchID : SV_PrimitiveID)
{
// tessellation factor calc
float4 centerW = mul(unity_ObjectToWorld, float4(0, 0, 0, 1.0f));
float distance = length(centerW.xyz - _WorldSpaceCameraPos.xyz);
PatchTess pt;
// multiply tessellation factor by distance between object position and camera
// this is for dynamic LOD.
// _TessllationDistance: If distance between object and camera is larger than this value, it starts to "fade" meshes by decreasing tessellation factors.
pt.EdgeTess[0] = clamp(_TessellationFactor * (_TessllationDistance / distance), 1.0f, _TessellationFactor);
pt.EdgeTess[1] = clamp(_TessellationFactor * (_TessllationDistance / distance), 1.0f, _TessellationFactor);
pt.EdgeTess[2] = clamp(_TessellationFactor * (_TessllationDistance / distance), 1.0f, _TessellationFactor);
pt.InsideTess = clamp(_TessellationFactor * (_TessllationDistance / distance), 1.0f, _TessellationFactor);
// Note that if you set any tessellation value to zero, this triangle will be culled.
// Which means you can do some early-culling here!
return pt;
}
// hull shader
[domain("tri")] // Unity only supports triangle patch, so set domain as tri
[partitioning("integer")] // tells the tessellator how it is to interpret our tessellation factors,
integer should be enough
[outputtopology("triangle_cw")] // output topology, we use clockwise triangles here
[outputcontrolpoints(3)] // output control points, since Unity only supports triangle so we set this 3
[patchconstantfunc("ConstantHS")] // a callback function which determines tessellation factors.
VertexOut MainHs(InputPatch<VertexOut, 3> p,
uint i : SV_OutputControlPointID)
{
return p[i]; // return input patch directly, tessellation will be finished when passed to domain shader
}
Domain shader comes after Hull shader:
// ------------------- Main Domain Shader ------------------- //
[domain("tri")]
DomainOut MainDs(PatchTess patchTess,
float3 domainLocation : SV_DomainLocation,
const OutputPatch<VertexOut, 3> tri)
{
// interpolation tessllation data
VertexOut i = (VertexOut)0;
i.PosL = tri[0].PosL * domainLocation.x + tri[1].PosL * domainLocation.y + tri[2].PosL * domainLocation.z;
i.TexC = tri[0].TexC * domainLocation.x + tri[1].TexC * domainLocation.y + tri[2].TexC * domainLocation.z;
i.NormalL = tri[0].NormalL * domainLocation.x + tri[1].NormalL * domainLocation.y + tri[2].NormalL * domainLocation.z;
i.TangentL = tri[0].TangentL * domainLocation.x + tri[1].TangentL * domainLocation.y + tri[2].TangentL * domainLocation.z;
// call displacement
i.PosL += Displacement(i.NormalL, TRANSFORM_TEX(i.TexC, _HeightTex), _HeightMapScale);
// data transfer
DomainOut o = (DomainOut)0;
o.PosH = UnityObjectToClipPos(i.PosL);
o.PosL = i.PosL;
o.TexAlbedo = TRANSFORM_TEX(i.TexC, _MainTex);
o.TexBump = TRANSFORM_TEX(i.TexC, _NormalTex);
o.TexHeight = TRANSFORM_TEX(i.TexC, _HeightTex);
o.NormalW = UnityObjectToWorldNormal(i.NormalL);
o.TangentW = float4(UnityObjectToWorldDir(i.TangentL.xyz), i.TangentL.w);
o.BinormalW = cross(o.NormalW, o.TangentW) * i.TangentL.w;
// since we displacement the geometry, we need to adjust normal
float3 normalHeight = UnpackNormal(tex2Dlod(_HeightBumpTex, float4(o.TexHeight, 0, 0))).xyz;
normalHeight = Vec3TsToWsNormalized(normalHeight, o.NormalW, o.TangentW, o.BinormalW);
o.TangentW.xyz = normalize(o.TangentW.xyz - (normalHeight* dot(o.TangentW.xyz, normalHeight)));
o.BinormalW = cross(normalHeight, o.TangentW) * o.TangentW.w;
o.NormalW = normalHeight;
return o;
}
With tessellation, we get a beautiful terrain!
But there is still a problem we need to deal with.
Tri Planar Mapping
If we apply displacement mapping with a high scale value, some meshes will be stretched too much.
And this makes texture mapping get stretched, too.
Fig 5. Texture is stretched too much!
To solve this, artists need to adjust texture coordinate and remake texture they used to fit new texture coordinate....It's too complicate!
Alternatively, we can solve this problem by using Tri Planar Mapping.
This method uses world space position of vertices for sampling (Of course we can use local space position.), and samples texture three times (along xyz axis). At last, we blend these three sampling as our final result.
float3 GetTriPlanarBlend(float3 _wNorm)
{
// in wNorm is the world-space normal
float3 blending = abs(_wNorm);
blending = (blending - 0.2) * 7; // apply a tiling and offset on blending value for preventing artifacts from averaging
blending = normalize(max(blending, 0.00001)); // Force weights to sum to 1.0
float b = (blending.x + blending.y + blending.z);
blending /= float3(b, b, b);
return blending;
}
float4 SampleTriplanarTexture(sampler2D tex, float3 worldPos, float3 blending)
{
// sample texture three times along xyz axis
float4 xaxis = tex2D(tex, worldPos.yz).rgba;
float4 yaxis = tex2D(tex, worldPos.xz).rgba;
float4 zaxis = tex2D(tex, worldPos.xy).rgba;
float4 final = xaxis * blending.x + yaxis * blending.y + zaxis * blending.z;
return final;
}
// sample albedo
// note that you should do tri planar sampling on normal, specular, and other kind of maps.
float4 albedo = (float4)0;
#if(S_TRI_PLANAR)
albedo = SampleTriplanarTexture(_MainTex, i.PosL * _TriPlanarSize, triPlanarBlend);
#else
albedo = tex2D(_MainTex, i.TexAlbedo);
#endif
After tri planar mapping, texture mapping looks better!
Fig 6. Tri Planar Mapping
Note that this method may decrease performance on some GPU since it samples texture three times.
And it samples texture with position coordinates. The result won't be the same as texture coordinates.
Drawbacks
While proposed method gives a good results for us.
There has some drawbacks when using proposed method.
1. Bounding Box
Since Unity calculates bounding box from original meshes we set. The bounding box will be wrong after displacement. (This is why many games only apply displacement mapping for a little details.) We can solve this by getting generated meshes from GPU by using Stream-Output Stage.
But geometry shader created by Unity doesn't support Stream-Output Stage.
For a precise result, we need to customize a geometry shader with native graphic plugin by calling ID3D11Device::CreateGeometryShaderWithStreamOutput() and render every objects which use this shader. (TOO COMPLICATE!)
For an approximate result, we simply write a script to setup bounding box manually.
Fig 7. Set bounding box manually.
2. Collider
The problem is the same as bounding box. After displacement mapping, we need to adjust our colliders or calculate physics on GPU.
3. Static Lightmap
Static lightmap may be wrong if we scale displacement mapping too much. It may not be a problem if we use directional light for baking lightmap (With directional light, only intensity matters.). If we use point light or spot light, some vertices may have incorrect lighting result due to the distance between a point and light is changed.
Summary
Tessellation Displacement & Tri Planar Mapping provides a way to build our terrains.
It can be used for reducing CPU overhead, and is suitable for adding some small details on our meshes.
If we want to build a large terrain, there are some drawbacks we need to consider.
Demo
You can download demo project here.
Remember to open it with Unity 5.5+ !