Blending and Unity Terrain: how to get rid of intersections and stop making your eyes hurt

In order to get a realistic world inside the game, it is necessary to take into account the interaction of various landforms with each other and with other models. And if the visible intersection lines between the 3D models spoil the image’s integrity, it’s worth considering how to eliminate them. The most common case of such lines, which may be familiar to many, is the intersection of billboards of particles with opaque geometry.

image

Another example is the disturbing natural composition of the intersection of rocks and vegetation with the surface of the landscape in “outdoors” scenes.

image

In addition to various smoothing methods (SSAA, MSAA, CSAA, FXAA, NFAA, CMAA, DLAA, TAA, etc.), which albeit mitigate the defiant appearance of such intersection lines, but do not completely correct the situation, there are more effective techniques. We will consider them.

Depth blending


Unity has a built-in solution to eliminate visible intersections between transparent particles and opaque geometry called soft particles. The shaders that support this effect further enhance the transparency of the particles, depending on how small the difference between the depth of the particle fragment and the depth of the opaque geometry is.

image
The principle of operation of soft particles

Obviously, for the correct operation of soft particles, a depth buffer is required. In the case of deferred shading, the depth buffer is formed at the stage of rendering full-screen buffers, and taking into account the MRT (Multiple Render Targets, not Magnetic Resonance Tomography), its presence is not expressed in additional computational costs.

In the case of forward shading and using the Unity Legacy Pipeline, an extra pass was required to render the opaque geometry to the depth buffer [1] . This pass is activated by assigning the appropriate value to the Camera.depthTextureMode property. This property is not available in the inspector window, but is available in the API [2] .

Now you can implement your own version of the Scriptable Render Pipeline with forward shading, which with the help of MRT can simultaneously render both the depth buffer and the color buffer.


Eliminating intersection lines in shaders that support soft particles

In general, there are no technical obstacles to using the depth blending method to eliminate visible intersections of 3D models with the landscape:

View code
// Blending with depth buffer

#include "UnityCG.cginc"

float BlendStart;
float BlendEnd;
UNITY_DECLARE_DEPTH_TEXTURE(_CameraDepthTexture);

struct v2f
{
    // ...

    half4 projPos : TEXCOORD0;
};

v2f vert(appdata v)
{
    v2f o;
    UNITY_INITIALIZE_OUTPUT(v2f,o);

    // ...

    o.projPos = ComputeScreenPos(o.pos);
    COMPUTE_EYEDEPTH(o.projPos.z);

    // ...

    return o;
}

fixed4 frag(v2f i) : COLOR
{     
    fixed4 result = 0;
      
    // ... 

    float depth = SAMPLE_DEPTH_TEXTURE_PROJ(_CameraDepthTexture, UNITY_PROJ_COORD(i.projPos));
    float sceneZ = LinearEyeDepth(depth);
    float partZ = i.projPos.z;
    float fade = saturate( sceneZ - partZ );
    result.a = smoothstep( BlendStart, BlendEnd, fade );

    // ... 
       
    return result; 
}


However, this approach has several disadvantages.

The first drawback is related to performance. Depth blending works at the stage of hardware pipe blending, that is, immediately after the rasterization and calculation of the fragment shader. At this stage, the result of the execution of the fragment shader is mixed with the result recorded in the output buffer [3] [4] [5] according to the formula predefined by the calls to the API [6] [7] [8] [9] .

This is the least progressive part of any hardware pipeline in the sense that it works exactly like its predecessor worked twenty years ago. The GPU reads the value from memory, mixes it with the value of the fragment shader, and writes it back to memory.

There is also a difference in whether to use depth blending for fully transparent or partially transparent 3D models. Transparent - for example, particle billboards - even without blending in depth, the entire render is transparent. In the case of opaque 3D models, real, tangible, visible transparency when blending in depth will be endowed with only a very small number of fragments, while the vast majority of them will remain opaque. But the latter does not mean at all that blending will not be used for their rendering - it will simply work idle.

The second drawback is related to how the color for mixing is selected. In short, then all the fragments that are mixed in a particular screen pixel lie on one ray emanating from the world position of the camera and passing through the world position of this screen pixel. This, in turn, means that with any change in the position or orientation of the camera, parallax will be observed: fragments of the 3D model located closer to the camera will move faster than fragments of the landscape located further from the camera [10] [11] . This is especially noticeable when viewed from close range with constant lateral displacement of the camera.


Lateral parallax when moving the camera: fragments of the 3D model are shifted to a greater distance compared to fragments of the landscape


Lateral parallax when moving the camera: when fixing the camera on a fragment of the landscape, it becomes noticeable how quickly the fragments of the model move.

When the camera is rotated, parallax is observed immediately along two axes of the screen coordinates. However, in dynamics this is less evident than lateral parallax.


Azimuthal parallax when the camera is shifted: it is more difficult for the brain to recognize the parallax pattern when the fragments are shifted along two

axes.But most noticeably, the appearance of the blending in depth changes depending on the angle at which the observer looks at the surface of the landscape. The blending zone becomes almost invisible when the direction of view is perpendicular to the normal to the landscape surface, but the size of this zone increases rapidly if you tilt the camera down.


Changing the width of the blending zone when tilting the camera

in depth Blending might be a good option for eliminating the intersection lines of 3D models with the landscape, if not for the abundance of artifacts that accompany it. This method is more suitable for particle effects that are not static and, as a rule, do not contain highly detailed textures, therefore, parallax effects are not observed in their case.


Height Map Blending


Another option for implementing landscape blending is to use a height map, which Unity provides access to through the TerrainData API [12] .

Knowing the position of the Terrain object and the dimensions of the terrain indicated in TerrainData, and having a "height map" on hand, you can calculate the height of the terrain at any point specified in world coordinates.


Terrain parameters required for sampling the height map

// Setting up a heightmap and uniforms to use with shaders... 

Shader.SetGlobalTexture(Uniforms.TerrainHeightmap, terrain.terrainData.heightmapTexture);
Shader.SetGlobalVector(Uniforms.HeightmapScale, terrain.terrainData.heightmapScale);
Shader.SetGlobalVector(Uniforms.TerrainSize, terrain.terrainData.size);
Shader.SetGlobalVector(Uniforms.TerrainPos, terrain.transform.position);

Well, now, after calculating the height of the landscape, you can also calculate the uv coordinates in the shader to sample the map of the heights of the landscape in world coordinates.

// Computes UV for sampling terrain heightmap... 

float2 TerrainUV(float3 worldPos)
{
    return (worldPos.xz - TerrainPos.xz) / TerrainSize.xz;
}

In order to be able to use the same code in fragment and vertex shaders, the tex2Dlod function is used for sampling. In addition, the height map does not have mip levels, so sampling it with the tex2D function, which automatically calculates the mip level, is basically meaningless.

// Returns the height of terrain at a given position in world space... 

float TerrainHeight(float2 terrainUV)
{
    float heightmapSample = tex2Dlod(TerrainHeightmap, float4(terrainUV,0,0));
    return TerrainPos.y + UnpackHeightmap(heightmapSample) * HeightmapScale.y * 2;
}

You can try to reproduce the elimination of intersections through transparency without using a depth buffer. This does not solve other problems associated with this method, but makes it possible to verify the operability of blending using a height map.

View code
// Blending with terrain heightmap

#include "UnityCG.cginc"

float BlendStart;
float BlendEnd;

sampler2D_float TerrainHeightmap; 
float4 HeightmapScale;
float4 TerrainSize;
float4 TerrainPos;        

struct v2f
{
   // ...

   float3 worldPos : TEXCOORD0;
   float2 heightMapUV : TEXCOORD1;

   // ...
};


v2f vert(appdata v)
{
    v2f o;
    UNITY_INITIALIZE_OUTPUT(v2f,o);
   
    // ...
    
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.heightMapUV = TerrainUV(o.worldPos);

    // ...

    return o;
}

fixed4 frag(v2f i) : COLOR
{
    fixed4 result = 0;

    // ... 

    half height = TerrainHeight(i.heightMapUV);
    half deltaHeight = i.worldPos.y - height;
    result.a = smoothstep( BlendStart, BlendEnd, deltaHeight );

    // ... 
       
    return result; 
}





Depth blending and elevation blending. The width of the blending zone differs with the same shader parameters.

The illustrations use identical blending parameters for both methods. The width of the blending zones is visually different, since blending with a height map does not depend on the angle between the observer’s gaze and the normal to the landscape.

Blending with a height map is at least in one respect better than blending in depth: it corrects the dependence of blending that is visible to the naked eye on the angle at which the camera looks at the landscape. Unfortunately, the parallax effect will still be observed.


Landscaping reconstruction blending


To get rid of parallax, you need to mix a fragment of the 3D model with a fragment of the landscape that is vertically below it (the color selection for mixing in this case does not depend on the position and orientation of the camera).


How to fix parallax: choosing a landscape fragment for blending

Of course, we are talking here more about a virtual landscape fragment. Depending on the position of the camera, a situation is possible when a fragment of the landscape, with which it is necessary to mix a fragment of a 3D model, will not even fall into the field of view of the camera. A similar problem exists in the rendering of local reflections in the screen space (SSLR). It consists in the fact that it is impossible to render the reflection of a fragment that is not on the screen [13] .

In the case of the landscape, the color of the virtual fragment can be reconstructed with high accuracy using auxiliary textures provided by the Unity API: normal map [14] , light map [15] , weighted textures for blending layers [16] , and textures included in composition of the layers [17] .


Reconstruction of a fragment of the landscape

All the textures that make up the landscape are sampled according to the same UV as the height map. In the case of layers, the coordinates for sampling are adjusted by the tiling parameters specified for a particular layer [18] [19] .

View code
// Blending with reconstructed terrain fragments

#include "UnityCG.cginc"

float BlendStart;
float BlendEnd;

sampler2D_float TerrainHeightmapTexture;
sampler2D_float TerrainNormalTexture;
sampler2D TerrainAlphaMap;

float4 HeightmapScale;
float4 TerrainSize;
float4 TerrainPos;
Float4 TerrainLightmap_ST;

UNITY_DECLARE_TEX2D(TerrainSplatMap0);
UNITY_DECLARE_TEX2D_NOSAMPLER(TerrainNormalMap0);
half4 TerrainSplatMap0_ST;

UNITY_DECLARE_TEX2D(TerrainSplatMap1);
UNITY_DECLARE_TEX2D_NOSAMPLER(TerrainNormalMap1);
half4 TerrainSplatMap1_ST;

UNITY_DECLARE_TEX2D(TerrainSplatMap2);
UNITY_DECLARE_TEX2D_NOSAMPLER(TerrainNormalMap2);
half4 TerrainSplatMap2_ST;

UNITY_DECLARE_TEX2D(TerrainSplatMap3);
UNITY_DECLARE_TEX2D_NOSAMPLER(TerrainNormalMap3);
half4 TerrainSplatMap3_ST;

struct v2f
{
   // ...

   float3 worldPos : TEXCOORD0;
   float2 heightMapUV : TEXCOORD1;
#if defined(LIGHTMAP_ON)
   float2 modelLightMapUV : TEXCOORD2;
   float2 terrainLightMapUV : TEXCOORD3;
#endif

   // ...
};


v2f vert(appdata v)
{
    v2f o;
    UNITY_INITIALIZE_OUTPUT(v2f,o);
   
    // ...
    
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.heightMapUV = TerrainUV(o.worldPos);

#if defined(LIGHTMAP_ON)
    o.modelLightMapUV = v.texcoord1.xy * unity_LightmapST.xy + unity_LightmapST.zw;
    o.terrainLightMapUV = o.heightMapUV * TerrainLightmap_ST.xy + TerrainLightmap_ST.zw;
#endif

    // ...

    return o;
}
half3 TerrainNormal(float2 terrainUV)
{
    return tex2Dlod( TerrainNormalTexture, float4(terrainUV,0,0) ).xyz * 2.0 - 1.0;
}

half4 TerrainSplatMap(float2 uv0, float2 uv1, float2 uv2, float2 uv3, half4 control)
{
    half4 splat0 = UNITY_SAMPLE_TEX2D_SAMPLER(TerrainSplatMap0, TerrainSplatMap0, uv0);
    half4 splat1 = UNITY_SAMPLE_TEX2D_SAMPLER(TerrainSplatMap1, TerrainSplatMap1, uv1);
    half4 splat2 = UNITY_SAMPLE_TEX2D_SAMPLER(TerrainSplatMap2, TerrainSplatMap2, uv2);
    half4 splat3 = UNITY_SAMPLE_TEX2D_SAMPLER(TerrainSplatMap3, TerrainSplatMap3, uv3);         
    half4 result = splat0 * control.r + 
                   splat1 * control.g + 
                   splat2 * control.b + 
                   splat3 * control.a;
    return result;
}

half3 TerrainNormalMap(float2 uv0, float2 uv1, float2 uv2, float2 uv3, half4 control)
{
    half4 n0 = UNITY_SAMPLE_TEX2D_SAMPLER(TerrainNormalMap0, TerrainSplatMap0, uv0);
    half4 n1 = UNITY_SAMPLE_TEX2D_SAMPLER(TerrainNormalMap1, TerrainSplatMap1, uv1);
    half4 n2 = UNITY_SAMPLE_TEX2D_SAMPLER(TerrainNormalMap2, TerrainSplatMap2, uv2);
    half4 n3 = UNITY_SAMPLE_TEX2D_SAMPLER(TerrainNormalMap3, TerrainSplatMap3, uv3);
    half3 result = UnpackNormalWithScale(n0, 1.0) * control.r +
                   UnpackNormalWithScale(n1, 1.0) * control.g +
                   UnpackNormalWithScale(n2, 1.0) * control.b +
                   UnpackNormalWithScale(n3, 1.0) * control.a;
    result.z += 1e-5;
    return result;
}

half3 TerrainLightmap(float2 uv, half3 normal)
{
#if defined(LIGHTMAP_ON)
#if defined(DIRLIGHTMAP_COMBINED)
    half4 lm = UNITY_SAMPLE_TEX2D(unity_Lightmap, uv);
    half4 lmd = UNITY_SAMPLE_TEX2D_SAMPLER(unity_LightmapInd, unity_Lightmap, uv);
    half3 result = DecodeLightmapRGBM(lm, unity_Lightmap_HDR);
    result = DecodeDirectionalLightmap(result, lmd, normal);
#else
    half4 lm = UNITY_SAMPLE_TEX2D(unity_Lightmap, uv);
    half3 result = DecodeLightmapRGBM(lm, unity_Lightmap_HDR);
#endif                
#else
    half3 result = UNITY_LIGHTMODEL_AMBIENT.rgb;
#endif
    return result;
}

fixed4 frag(v2f i) : COLOR
{
    fixed4 result = 0;

    // ...

    // compute model color and put it to the result

    // ... 

    // reconstruction of terrain fragment

    float2 splatUV0 = TRANSFORM_TEX(i.heightMapUV, TerrainSplatMap0);
    float2 splatUV1 = TRANSFORM_TEX(i.heightMapUV, TerrainSplatMap1);
    float2 splatUV2 = TRANSFORM_TEX(i.heightMapUV, TerrainSplatMap2);
    float2 splatUV3 = TRANSFORM_TEX(i.heightMapUV, TerrainSplatMap3);

    half4 control = tex2D(_TerrainAlphaMap, i.heightMapUV);
    half4 terrainColor = TerrainSplatMap(splatUV0, splatUV1, splatUV2, splatUV3, control);

    half3 terrainSurfaceNormal = TerrainNormal(i.heightMapUV);
    half3 terrainSurfaceTangent = cross(terrainSurfaceNormal, float3(0,0,1));
    half3 terrainSurfaceBitangent = cross(terrainSurfaceTangent, terrainSurfaceNormal);

    half3 terrainNormal = TerrainNormalMap(splatUV0, splatUV1, splatUV2, splatUV3, control);
    terrainNormal = terrainNormal.x * terrainSurfaceTangent + 
                    terrainNormal.y * terrainSurfaceBitangent + 
                    terrainNormal.z * terrainSurfaceNormal;
    
    half3 terrainLightmapColor = TerrainLightmap(i.heightMapUV, terrainNormal);
    terrainColor *= terrainLightmapColor;

    // blend model color & terrain color

    half height = TerrainHeight(i.heightMapUV);
    half deltaHeight = i.worldPos.y - height;
    half blendingWeight = smoothstep(BlendStart, BlendEnd, deltaHeight);

    result.rgb = lerp(result.rgb, terrainColor, blendingFactor);
       
    return result; 
}


So, blending with reconstruction of landscape fragments fixes all the problems typical for depth blending and blending with a height map, including parallax.



Landscaping reconstruction blending


Terrain fragment reconstruction performance


At this point, it's time to ask, what is this kind of compromise worth? At first glance, the resource intensity of reconstructing landscape fragments far exceeds the resource intensity of alpha blending. For reconstruction it is necessary to perform with a dozen additional read operations from memory. For alpha blending, you only need one read operation from memory and one write operation to memory.

In reality, everything will depend on the features of the hardware platform. Fragment reconstruction is supported by texture compression, mip-mapping, GPU core processing power and specific hardware pipeline optimizations (early depth rejection). And against the alpha-blending the fact already mentioned above will play that it is the least progressive part of any GPU.

However, there is always room for optimization. For example, in the case of reconstruction of the color of the landscape, the need for this reconstruction is only for a narrow strip of fragments of the 3D model located no higher than a certain height above the surface of the landscape.

Dynamic branching in shaders can give poorly predictable performance results, but there are two points that should be taken into account:

  1. Skipping unnecessary calculations in branching by a condition should be done if this condition is not satisfied in a significant part of cases.
  2. . , ( , ), GPU. ― (branch granularity), , , , , . , , . , GPU , , . , GPU, , 1 (PowerVR SGX).


Visualization of different degrees of coherence

In the case of reconstruction of fragments, both of these points are taken into account: the branching condition in most cases will allow to cut off the implementation of resource-intensive operations for reconstructing the color of the landscape, and this condition is coherent, with the exception of a very small number of fragments (in the illustration, these are fragments that lie on the border between the “red” and “green” zones).


Coherence of reconstruction of landscape fragments It

remains to add a few comments regarding this blending method:

  1. Unity provides all the necessary textures only if the landscape has Draw Instanced mode enabled [20] , otherwise the normal map will not be available, which, in turn, will not allow you to correctly reconstruct landscape lighting for blending.
  2. Unity API , (base map) . - .
  3. , API (, Metal 16 ).
  4. 3D- , Terrain, SRP.
  5. 3D- , 3D- .
  6. , «» , «» . , «» , . «» .






When designing 3D models, it is impossible to take into account the variety of terrain reliefs with which these models are supposed to be used. Often, 3D models have to be deeply “sunk” in the landscape or rotated in order to hide the protruding parts, or vice versa - to show the hidden ones that should be visible. “Warming” models limits their applicability, and if 3D models are rendered earlier than the landscape, it also leads to an overdraw effect. The turn, in turn, is also far from suitable for all 3D models (for example, not for houses and trees).


To hide the protruding elements of the 3D model, it must be “drowned” in the landscape

Snapping is a term familiar to users of graphic editors. This is a function that allows control points to “stick” to the nodes of the spatial grid, and in 3D editors to the faces and surfaces of other objects. Snapping to the map of the heights of the landscape in the vertex shader can greatly simplify the design of scenes.


3D model without snapping. 3D model with vertex snapping. 3D model with vertex snapping and blending. 3D model with vertex snapping, blending and static lighting

The main difficulty in implementing snapping is that you need to figure out which vertices of the 3D model you need to snap to the height map, and which are not worth it. Vertexes contain only information about the local nature of the surface (which is not enough) and do not contain any information about its topology (which is necessary).

As in other application cases, this problem is easiest to solve at the modeling stage by directly implementing the necessary parameters in the vertices. As such a parameter, you should choose an intuitive attribute - for example, the weighting factor for snapping (and not the distance to the border of an open surface, as we would like for flexibility).


Weighting Coding for Snapping

View code
// Per-vertex snapping with terrain heightmap

#include "UnityCG.cginc"

sampler2D_float TerrainHeightmapTexture;

float4 HeightmapScale;
float4 TerrainSize;
float4 TerrainPos;

struct v2f
{

   // ...

   float3 worldPos : TEXCOORD0;
   float2 heightMapUV : TEXCOORD1;

   // ...

};

v2f vert(appdata v)
{
    v2f o;
    UNITY_INITIALIZE_OUTPUT(v2f,o);
   
    // ...
    
    o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
    o.heightMapUV = TerrainUV(o.worldPos);
    float snappingWeight = v.color.r;                
    half height = TerrainHeight( o.heightMapUV );                
    o.worldPos.y = lerp( o.worldPos.y, height, snappingWeight );
    o.pos = UnityWorldToClipPos( half4( o.worldPos, 1 ) );

    // ...

    return o;
}


The applicability of vertex snapping is limited by the general correspondence between the terrain and the surface of the 3D model. To compensate for their significant differences, it is necessary to apply other, more resource-intensive methods - for example, use 3D models with skinning.


Conclusion


The main idea that should be taken out of the article: any sufficiently complex and potentially scalable shader needs basic data. And the task of the developer is to understand how the graphic system can be operated: what data it provides, how it can be combined with each other and how to use it in shaders.

In the general case, we can conclude that the only option to overcome the framework by which the possibilities of graphic effects are limited is to combine the results of various shaders.


References



All Articles