Advanced shaders in Unity

3. Parallax mapping

When making a game it's really important to use as few triangles as possible. But what are you going to do if you for example have a brick wall and want to make those bricks more realistic and can't add more triangles? One solution is to add a normal map, but the result is still kinda flat. Another solution is to use parallax mapping. This is a comparison between bricks with parallax mapping and bricks without parallax mapping:

Comparison between with parallax and without

Parallax mapping is a shader technique where you need to be able to write shaders on your own because you can't attach a parallax mapping shader made for bricks to simulate a landscape because you have to really tweak each shader to achive the best result. But the basic idea is the same. I'm not going to repeat what's already written, so if you want to read long articles about parallax mapping you can for example read this one: Learn OpenGL - Parallax Mapping. But I am going to tell you the basic idea:

  • Like in the previous tutorials we are going to fire rays to simulate geometry where there is none.
  • We are going to fire the rays not in global space, not in object space, but in tangent space. If you haven't heard about tangent space, you can say that two of the axis in tangent space are aligned with the u and v coordinates and the third axis is the normal to the surface.
  • In the previous tutorials we used simple ray/geometry intersections to find where the ray has hit something. We can't do that here because the geometry is described with a grayscale texture. So we have to fire a ray with constant step size and each step we have to check if the ray is below the geometry.
  • When the ray is below the geometry, then we need to find the intersection point by using a weighted average of the last point on the ray, which was above the surface. This will most likely not be the exact answer, so you might have to change the step size to get a more accurate answer.

If you are lost, maybe this image might explain it better:

The basic idea behind parallax mapping

Source

What we are going to do here is to simulate a landscape. If you are making a game where you are looking out of an aircraft window, then it's unnecessary to build the exact ground geometry with meshes, so you might as well just use a quad and parallax mapping. What you need is just a qaud, a noise texture, a mountain texture, and a grass texture. Google the mountain and grass textures, but you can use this seamless noise texture:

Seamless noise texture

This is the shader code

//Parallax occlusion mapping shader that creates a landscape in a quad
Shader "Volume/MountainInQuad" 
{
	Properties 
	{
		_NoiseTex("Noise texture", 2D) = "white" {}
		_GrassTex("Grass (RGB)", 2D) = "white" {}
		_StoneTex("Stone (RGB)", 2D) = "white" {}
		_WaterColor("Water (RGB)", Color) = (1,1,1,1)
		_Height("Height", Range(0.0001,5)) = 1.0
	}
	
	SubShader 
	{
		Tags { "RenderType"="Opaque" }
		LOD 200
		
		CGPROGRAM
		
		#pragma surface surf Lambert vertex:vert
		#pragma target 3.0


		//Input
		sampler2D _NoiseTex;
		sampler2D _GrassTex;
		sampler2D _StoneTex;
		float4 _WaterColor;
		float _Height;


		struct Input 
		{
			//What Unity can give you
			float2 uv_NoiseTex;

			//What you have to calculate yourself
			float3 tangentViewDir;
		};


		void vert(inout appdata_full i, out Input o)
		{
			UNITY_INITIALIZE_OUTPUT(Input, o);

			//Transform the view direction from world space to tangent space			
			float3 worldVertexPos = mul(unity_ObjectToWorld, i.vertex).xyz;
			float3 worldViewDir = worldVertexPos - _WorldSpaceCameraPos;

			//To convert from world space to tangent space we need the following
			//https://docs.unity3d.com/Manual/SL-VertexFragmentShaderExamples.html
			float3 worldNormal = UnityObjectToWorldNormal(i.normal);
			float3 worldTangent = UnityObjectToWorldDir(i.tangent.xyz);
			float3 worldBitangent = cross(worldNormal, worldTangent) * i.tangent.w * unity_WorldTransformParams.w;

			//Use dot products instead of building the matrix
			o.tangentViewDir = float3(
				dot(worldViewDir, worldTangent),
				dot(worldViewDir, worldNormal),
				dot(worldViewDir, worldBitangent)
				);
		}


		//Animate the uv coordinates so the landscape is moving
		float2 animateUV(float2 texturePos)
		{
			texturePos.x += _Time[0] * 5;

			return texturePos;
		}


		//Get the height from a uv position
		float getHeight(float2 texturePos)
		{
			texturePos = animateUV(texturePos);

			//Multiply with 0.2 to make the landscape flatter
			float4 colorNoise = tex2Dlod(_NoiseTex, float4(texturePos * 0.2, 0, 0));

			//Calculate the height at this uv coordinate
			//Just use r because r = g = b  because color is grayscale
			//(1-color.r) because black should be low
			//-1 because the ray is going down so the ray's y-coordinate will be negative
			float height = (1 - colorNoise.r) * -1 * _Height;

			return height;
		}


		//Combine stone and grass depending on grayscale color
		float4 getBlendTexture(float2 texturePos, float height)
		{						
			texturePos = animateUV(texturePos);

			//To make it look nice by making the texture a little bigger
			float textureSize = 0.4;

			float4 colorGrass = tex2Dlod(_GrassTex, float4(texturePos * textureSize, 0, 0));
			float4 colorStone = tex2Dlod(_StoneTex, float4(texturePos * textureSize, 0, 0));

			//Height is negative so convert it to positive, also invert it so mountains are high and not the grass
			//Divide with _Height because this height is actual height and we need it in 0 -> 1 range
			float colorGrayscale = 1 - (abs(height) / _Height);

			//Combine grass and stone depending on height
			float4 mixedColor = lerp(colorGrass, colorStone, colorGrayscale);

			//Water
			if (colorGrayscale < 0.4)
			{
				mixedColor = tex2Dlod(_NoiseTex, float4(texturePos * 0.2, 0, 0)) * _WaterColor;
			}

			return mixedColor;
		}


		//Get the texture position by interpolation between the position where we hit terrain and the position before
		float2 getWeightedTexPos(float3 rayPos, float3 rayDir, float stepDistance)
		{
			//Move one step back to the position before we hit terrain
			float3 oldPos = rayPos - stepDistance * rayDir;

			float oldHeight = getHeight(oldPos.xz);

			//Always positive
			float oldDistToTerrain = abs(oldHeight - oldPos.y);

			float currentHeight = getHeight(rayPos.xz);

			//Always negative
			float currentDistToTerrain = rayPos.y - currentHeight;

			float weight = currentDistToTerrain / (currentDistToTerrain - oldDistToTerrain);

			//Calculate a weighted texture coordinate
			//If height is -2 and oldHeight is 2, then weightedTex is 0.5, which is good because we should use 
			//the exact middle between the coordinates
			float2 weightedTexPos = oldPos.xz * weight + rayPos.xz * (1 - weight);

			return weightedTexPos;
		}


		void surf (Input IN, inout SurfaceOutput o) 
		{
			//Where is the ray starting? y is up and we always start at the surface
			float3 rayPos = float3(IN.uv_NoiseTex.x, 0, IN.uv_NoiseTex.y);

			//What's the direction of the ray?
			float3 rayDir = normalize(IN.tangentViewDir);

			//Find where the ray is intersecting with the terrain with a raymarch algorithm
			int STEPS = 300;
			float stepDistance = 0.01;

			//The default color used if the ray doesnt hit anything
			float4 finalColor = 1;

			for (int i = 0; i < STEPS; i++)
			{
				//Get the current height at this uv coordinate
				float height = getHeight(rayPos.xz);

				//If the ray is below the surface
				if (rayPos.y < height)
				{
					//Get the texture position by interpolation between the position where we hit terrain and the position before
					float2 weightedTex = getWeightedTexPos(rayPos, rayDir, stepDistance);
					
					float height = getHeight(weightedTex);

					finalColor = getBlendTexture(weightedTex, height);
					
					//We have hit the terrain so we dont need to loop anymore	
					break;
				}
				
				//Move along the ray
				rayPos += stepDistance * rayDir;
			}

			//Output
			o.Albedo = finalColor.rgb;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

Add this shader to a material, add the material to the quad, and it should look like this:

Parallax mountain

With the same basic ideas you can make other cool shaders, like a hole opening up (click for YouTube video):


Mysterious hole YouTube video

To make the hole, I used two textures: a crack texture and a "dust" texture (which is a texture with a dust speckle in the center). Then I merged the textures in the shader, and added a a slider determining how strong the dust texture should be. By moving the slider, the hole is closing/opening up. The code is available on GitHub.

Parallax hole shader texture