My take on shaders: Parallax effect (Part II)

Welcome to the second part of the tutorial on Parallax effects! Now, initially I intended for this tutorial to only have two parts, with which I so naively open on the previous post. However, there’s probably going to be a third part on the series, the subject of which I won’t reveal now, for some dramatic tension.

The subject of this part was what I initially intended to achieve and it led me to stumble upon the previous and next tutorials, for which I’m really grateful. It’s the effect that got me so excited about parallax, as I mentioned in the backstory of the previous post, and it’s the very same effect covered in that tutorial I mentioned before, but without the Shader Graph:

To pick up where I left off, the tough part for me was to get the view direction of the camera in tangent space. It sounds simple enough to google, but you’d be surprised at how hard it was to actually get that result. A source that put me on the right track, however, was this thread.

When I saw that, I felt kinda dumb, because it’s not really something terribly novel, and it just makes sense. And to be honest, it’s really common in a bunch of shaders online, even on Unity’s documentation, but I didn’t get that this was what I needed, cause most sources don’t say that this can be used explicitly for view direction too. Plus, it used to look a bit too complicated for me.

Before I dive into the code, I’d like to go through the algorithm of getting the view direction in an object’s tangent space, so that when I demonstrate that part in-code, it wouldn’t seem so strange.

View direction in tangent space

Transforming a vector from one space to another is something that we actually do all the time, and this case shouldn’t be too special. The core concept is to construct a matrix that when multiplied with a vector, it transforms it in a way that fits the space we want.

We constantly do that when we want to get, for example, the world space position of an object’s vertex. We use this matrix multiplication:

o.worldPos = mul(unity_ObjectToWorld, v.vertex); 

The “unity_ObjectToWorld” is just a built-in matrix provided by Unity that transforms a vector, well, from object to world space. Keep in mind that while this is technically a multiplication, the “*” operator doesn’t work with matrices and vectors and we need the “mul” method. I think that in GLSL you can multiply matrices and vectors with the “*” operator, so keep that in mind when converting a shader for Unity.

Let’s now check how we can construct the matrix we want to use:

  1. First we need the normal, tangent and bitangent (a.k.a. binormal) of the current vertex. We can get the normal and tangent by declaring them in the appdata struct for unlit shaders, or by using appdata_tan or appdata_full for surface shaders.
  2. We get the bitangent by applying the cross product operator on the normal and tangent vectors. We also multiply the cross product by the w component of the tangent vector (either 1 or -1), in case the bitangent needs to be flipped.
  3. The matrix we want can be defined simply as:
float3x3 tangentMatrix = float3x3(tangent,bitangent,normal);

We can then just multiply our view direction with this matrix and get the view direction in tangent space! For that we can use the “mul” method, or dot products which (I think) is a bit faster. So we can either write this:

float3 viewDirTangent = mul(tangentMatrix, viewDir);

Or this:

float3 viewDirTangent = float3(
dot(viewDir, tangent.xyz),
dot(viewDir, bitangent.xyz),
dot(viewDir, normal.xyz)
);

The shader

With the tricky part out of the way, the shader will figuratively be a piece of cake. Let’s check it out:

Shader "Custom/LayeredParallax" {
	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0

		_ParallaxMap("Parallax map", 2D) = "white" {}
		_Iterations("Iterations", float) = 5
		_OffsetScale("Offset scale", float) = 0
	}
	SubShader {
		Tags { "RenderType"="Opaque" "DisableBatching"="True"}
		LOD 200

		CGPROGRAM
		// Physically based Standard lighting model, and enable shadows on all light types
		#pragma surface surf Standard fullforwardshadows vertex:vert

		// Use shader model 3.0 target, to get nicer looking lighting
		#pragma target 3.0

		sampler2D _MainTex;
		sampler2D _ParallaxMap;

		struct Input {
			float2 uv_MainTex;
			float2 uv_ParallaxMap;
			float3 viewDirTangent;
		};

		half _Glossiness;
		half _Metallic;
		fixed4 _Color;
		float _OffsetScale;
		float _Iterations;

		// Add instancing support for this shader. You need to check 'Enable Instancing' on materials that use the shader.
		// See https://docs.unity3d.com/Manual/GPUInstancing.html for more information about instancing.
		// #pragma instancing_options assumeuniformscaling
		UNITY_INSTANCING_BUFFER_START(Props)
			// put more per-instance properties here
		UNITY_INSTANCING_BUFFER_END(Props)

		void vert (inout appdata_full v, out Input o) {
            UNITY_INITIALIZE_OUTPUT(Input,o);
           	float4 objCam = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos, 1.0));
			float3 viewDir = v.vertex.xyz - objCam.xyz;
			float tangentSign = v.tangent.w * unity_WorldTransformParams.w;
			float3 bitangent = cross(v.normal.xyz, v.tangent.xyz) * tangentSign;
			o.viewDirTangent = float3(
				dot(viewDir, v.tangent.xyz),
				dot(viewDir, bitangent.xyz),
				dot(viewDir, v.normal.xyz)
			);
        }

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float parallax = 0;
			for (int j = 0; j < _Iterations; j ++) {
				float ratio = (float) j / _Iterations;
				parallax +=  tex2D(_ParallaxMap, IN.uv_ParallaxMap + lerp(0, _OffsetScale, ratio) * normalize(IN.viewDirTangent)) * lerp(1, 0, ratio);
			}
			parallax /= _Iterations;
			fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
			o.Albedo = c.rgb + parallax;
			// Metallic and smoothness come from slider variables
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

I named that parallax technique as “Layered Parallax”, cause it adds multiple layers of a texture under the surface, not unlike ogres.

The properties I added are 3, in lines 8-10: “_ParallaxMap”, the texture that we want to use for the parallax effect (not as a guide but just for the layers underneath); “_Iterations”, corresponding to the number of layers; and “_OffsetScale”, which handles how deep the layers will go.

For the “_ParallaxMap” texture, a grayscale image will do the trick. For the thumbnail, for example, the texture I used was this:

Since I’m using the vertex position in object space, it’s wise to use “DisableBatching”=”True” in the tags block, in order not to get your vertex positions messed up if you have more than one object with the same material.

As this effect uses a vertex shader to calculate the view direction in tangent space, in line 18 I add the “vertex:vert” tag, to let the shader know that it will use the “vert” function as the vertex shader. Later, in the “Input” struct I add another set of UV coordinates for the parallax texture and a float3 field for the view direction in tangent space.

I then go ahead and write the vertex shader. Since I want to modify the “Input” struct (as I’ll be storing the view direction in there) I have to add it in the parameters of the “vert” method. Also, since I’ll only modify the view direction field, I first use “UNITY_INITIALIZE_OUTPUT(Input,o);” so that the shader initializes the rest of the struct’s fields on its own.

Afterwards, the rest of the vertex shader is me applying the aforementioned algorithm to get the camera’s view direction in tangent space. Specifically, in line 47 I get the camera’s position in object space by multiplying its world space position with the “unity_WorldToObject” matrix (not to be confused with the “unity_ObjectToWorld” matrix).

In this case, getting the view direction in object space worked better, but the same principle can be applied using world space, depending on the desired effect.

The view direction in object space is calculated in line 48 by subtracting the camera’s object space position from the vertex position. In line 49 I calculate the sign of the bitangent using both the w component of the tangent and the w component of “unity_WorldTransformParams”, which accounts for negative scaling of the object.

Finally, in line 50 I calculate the bitangent and in line 51 I apply the transformation to store the view direction in tangent space so that I can use it in the surface shader.

There, in lines 60-63 I use a loop where for each iteration I sample the “_ParallaxMap” texture. The UV’s of the sampling are offset towards the view direction in tangent space by an amount determined by the number of the current iteration. So, on the first iteration the texture won’t be offset at all and just sit on the surface, while on the last iteration, the texture would be offset by the amount of “_OffsetScale” from the surface. I also multiply the result by a number from 1 to 0 depending again from the iteration. Therefore the deeper we go, the fainter the layer gets, giving the impression that the layers fade to black due to the depth. This is all calculated on the right-hand side of line 62.

I then add the result of each iteration to a field called “parallax”, which in line 64 I divide by the number of iterations so that it goes from a range of 0.0 to 1.0.

I then simply add the result to the albedo in line 66. Depending on the input texture or the effect you want to achieve, another blending method could be more suitable. For example, instead of just adding the result, the original tutorial by Binary Impact suggests using overlay blending.

And I think that’s it for this part! Hopefully you’ll make some really cool materials with this effect. At least I know I’ll try to ^^

Also it’s worth mentioning that both this and the previous parallax effect can easily be done in an vertex-fragment shader as well! As long as you do the same steps to get the view direction in tangent space in the vertex shader, you’re all set!

See you in the next one!




Disclaimer

The code in the shader tutorials is under the CC0 license, so you can freely use it in your commercial or non-commercial projects without the need for any attribution/credits.

These posts will never go behind a paywall. But if you really really super enjoyed this post, consider buying me a coffee, or becoming my patron to get exciting benefits!

Become a Patron!

Or don't, I'm not the boss of you.