Tangent space to world space normal conversion tool

This section of the guide describes the tangent space to world space normal conversion tool. This tool is composed of a C# script and a shader. The tool runs offline from inside the Unity editor and it does not affect the runtime performance of your game.

Profiling the Ice Cave demo showed that there was a bottleneck in the arithmetic pipeline. To reduce the load, the Ice Cave demo uses world space normal maps, instead of tangent space normal maps, for the static geometry.

Tangent space normal maps are useful for animated and dynamic objects but require extra computations to correctly orient the sampled normal.

Most of the geometry in the Ice Cave demo is static, therefore, the normal maps are converted into world space normal maps. This conversion ensures that normals sampled from their maps are already correctly oriented in world space ready for use by the shader. This change is possible because the Ice Cave demo lighting is computed in a custom shader, whereas the Unity standard shader uses tangent space normal maps.

The conversion tool is composed of:

  • A C# script that adds a new option in the editor
  • A shader that performs the conversion

The tool runs offline from inside the Unity editor and it does not affect the runtime performance of your game.

WorldSpaceNormalsCreators C# script

  • The WorldSpaceNormalsCreators C# script 
    using UnityEngine;
    using UnityEditor;
    using System.Collections;
    public class WorldSpaceNormalsCreator : ScriptableWizard
    {
    	public GameObject _currentObj;
    	private Camera _renderCamera;
    	void OnWizardUpdate()
    	{
    		helpString = "Select object from which generate the world space normals";
    		if (_currentObj != null)
    		{	
    			isValid = true;
    		}
    		else
    		{
    			isValid = false;
    		}
    	}
    	void OnWizardCreate ()
    	{
    		// Set antialiasing
    		QualitySettings.antiAliasing = 4;
    		Shader wns = Shader.Find ("Custom/WorldSpaceNormalCreator");
    		GameObject go = new GameObject( "WorldSpaceNormalsCam", typeof(Camera) );
    		//Set the new camera to perform orthographic projection
    		_renderCamera = go.GetComponent();
    		_renderCamera.orthographic = true;
    		_renderCamera.nearClipPlane = 0.0f;
    		_renderCamera.farClipPlane = 10f;
    		_renderCamera.orthographicSize = 1.0f;
    		//Save the current object layer and set it to an unused one
    		int prevObjLayer = _currentObj.layer;
    		_currentObj.layer = 30; //0x40000000
    		//Set the replacement shader for the camera
    		_renderCamera.SetReplacementShader (wns,null);
    		_renderCamera.useOcclusionCulling = false;
    		//Rotate the camera to look at the object to avoid frustum culling
    		_renderCamera.transform.rotation = Quaternion.LookRotation(_currentObj.transform.position - _renderCamera.transform.position); 
    		MeshRenderer mr = _currentObj.GetComponent();
    		Material[] materials = mr.sharedMaterials;
    		foreach (Material m in materials)
    		{
    			Texture t = m.GetTexture("_BumpMap");
    			if (t == null)
    			{
    				Debug.LogError("the material has no texture assigned named Bump Map");
    				continue;
    			}
    			//Render the world space normal maps to a texture
    			Shader.SetGlobalTexture ("_BumpMapGlobal", t);
    			RenderTexture rt = new RenderTexture(t.width,t.height,1);
    			_renderCamera.targetTexture = rt;
    			_renderCamera.pixelRect = new Rect(0,0,t.width,t.height);
    			_renderCamera.backgroundColor = new Color( 0.5f, 0.5f, 0.5f);
    			_renderCamera.clearFlags = CameraClearFlags.Color;
    			_renderCamera.cullingMask = 0x40000000;
    			_renderCamera.Render();
    			Shader.SetGlobalTexture ("_BumpMapGlobal", null);
    			Texture2D outTex = new Texture2D(t.width,t.height);
    			RenderTexture.active = rt;
    			outTex.ReadPixels(new Rect(0,0,t.width,t.height), 0, 0);
    			outTex.Apply();
    			RenderTexture.active = null;
    			//Save it to PNG
    			byte[] _pixels = outTex.EncodeToPNG();
    	System.IO.File.WriteAllBytes("Assets/Textures/GeneratedWorldSpaceNormals/" + t.name + "_WorldSpace.png", _pixels);
    		}
    		_currentObj.layer = prevObjLayer;
    		DestroyImmediate(go);
    	}
    	[MenuItem("GameObject/World Space Normals Creator")]
    	static void CreateWorldSpaceNormals ()
    	{
    		ScriptableWizard.DisplayWizard("Create World Space Normal",
    		typeof(WorldSpaceNormalsCreator),"Create");
    	}
    }
    

Let’s look at how you can use The WorldSpaceNormalsCreators C# script.

You must place the C# script in the Unity Assets/Editor directory. Placing the script in the Assets/Editor directory enables the script to add a new option to the GameObject menu in the Unity editor. If the Assets/Editor directory does not exist, you must create it. The following code shows you how to add a new option in the Unity editor:

[MenuItem("GameObject/World Space Normals Creator")]
static void CreateWorldSpaceNormals ()
{
	ScriptableWizard.DisplayWizard("Create World Space Normal",
	typeof(WorldSpaceNormalsCreator),"Create");
}

The following image shows the GameObject menu option that the script adds:

The WorldSpaceNormalsCreator class that is defined in the C# script derives from the Unity ScriptableWizard class, and accesses some of the ScriptableWizard members. Derives from the ScriptableWizard class can be used to create an editor wizard. Editor wizards are typically opened using a menu item.

In the OnWizardUpdate code, the helpString variable holds a help message that is displayed in the window that the wizard creates.

The isValid member is used to define when all the correct parameters are selected and the Create button is available. In this case, the _currentObj member is checked to ensure it points to a valid object.

The fields of the Wizard windows are the public members of the class. In this case, only the _currentObj is public, so the Wizard window only has one field.

The following image shows the Custom Wizard window:

When an object is selected and the Create button is clicked, the OnWizardCreate() function is called.

The OnWizardCreate() function performs the main work of the conversion.

To convert the normal, the tool creates a temporary camera that renders the new World space normal to a RenderTexture. To render the new World space normal, the camera is set to orthographic mode and the layer of the object is changed to an unused level. This means it can render the object on its own, even if it is already part of the scene.

The following code shows how the camera is set up:

// Set antialiasing
QualitySettings.antiAliasing = 4;
Shader wns = Shader.Find ("Custom/WorldSpaceNormalCreator");
GameObject go = new GameObject( "WorldSpaceNormalsCam", typeof(Camera) );
_renderCamera = go.GetComponent ();
_renderCamera.orthographic = true;
_renderCamera.nearClipPlane = 0.0f;
_renderCamera.farClipPlane = 10f;
_renderCamera.orthographicSize = 1.0f;
int prevObjLayer = _currentObj.layer;
_currentObj.layer = 30; //0x4000000

The script sets a replacement shader that executes the conversion:

_renderCamera.SetReplacementShader (wns,null);
_renderCamera.useOcclusionCulling = false;

The camera is pointed at the object, preventing the object being removed from rendering during frustum culling:

_renderCamera.transform.rotation = Quaternion.LookRotation (_currentObj.transform.position - _renderCamera.transform.position);

For each material that is assigned to the object, the script locates the _BumpMap texture. This texture is set as source texture for the replacement shader using the shader global functions.

The clear color is set to (0.5,0.5,0.5). This is because normals pointing at negative directions must be represented, as shown in the following code:

foreach (Material m in materials)
{
	Texture t = m.GetTexture("_BumpMap");
	if ( t == null )
	{
		Debug.LogError("the material has no texture assigned named Bump Map");
		continue;
	}
	Shader.SetGlobalTexture ("_BumpMapGlobal", t);
	RenderTexture rt = new RenderTexture(t.width,t.height,1);
	_renderCamera.targetTexture = rt;
	_renderCamera.pixelRect = new Rect(0,0,t.width,t.height);
	_renderCamera.backgroundColor = new Color( 0.5f, 0.5f, 0.5f);
	_renderCamera.clearFlags = CameraClearFlags.Color;
	_renderCamera.cullingMask = 0x40000000;
	_renderCamera.Render();
	Shader.SetGlobalTexture ("_BumpMapGlobal", null);
	After the camera renders the scene, the pixels are read back and saved as a PNG image.
	Texture2D outTex = new Texture2D(t.width,t.height);
	RenderTexture.active = rt;
	outTex.ReadPixels(new Rect(0,0,t.width,t.height), 0, 0);
	outTex.Apply();
	RenderTexture.active = null;
	byte[] _pixels = outTex.EncodeToPNG();
	System.IO.File.WriteAllBytes("Assets/Textures/GeneratedWorldSpaceNormals/"+t.name +"_WorldSpace.png",_pixels);
}

The camera culling mask uses a binary mask that is represented in hexadecimal format, to specify what layers to render.

In this case layer 30 was used:

_currentObj.layer = 30;

The hexadecimal is 0x40000000 because its 30th bit is set to 1.

WorldSpaceNormalCreator shader

The following code shows the WorldSpaceNormalCreator shader in use:

Shader "Custom/WorldSpaceNormalCreator" {
	Properties {
	}
		SubShader {
		Cull off
		Pass
		{
			CGPROGRAM
			#pragma target 3.0
			#pragma glsl
			#pragma vertex vert
			#pragma fragment frag
			#include "UnityCG.cginc"
			uniform sampler2D _BumpMapGlobal;
			struct vin
			{
				half4 tex : TEXCOORD0;
				half3 normal : NORMAL;
				half4 tangent : TANGENT;
			};
			struct vout
			{
				half4 pos : POSITION;
				half2 tc : TEXCOORD0;
				half3 normalInWorld : TEXCOORD1;
				half3 tangentWorld : TEXCOORD2;
				half3 bitangentWorld : TEXCOORD3;
			};
			vout vert (vin input )
			{
				vout output;
				output.pos = half4(input.tex.x*2.0 - 1.0,((1.0-input.tex.y)*2.0 - 1.0), 0.0, 1.0);
				output.tc = input.tex;
				output.normalInWorld = normalize(mul(half4(input.normal, 0.0), _World2Object).xyz);
				output.tangentWorld = normalize(mul(_Object2World, half4(input.tangent.xyz, 0.0)).xyz);
				output.bitangentWorld = normalize(cross(output.normalInWorld, output.tangentWorld) * input.tangent.w);
				return output;
			}
			float4 frag( vout input ) : COLOR
			{
				half3 normalInWorld = half3(0.0,0.0,0.0);
				half3 bumpNormal = UnpackNormal(tex2D(_BumpMapGlobal, input.tc));
				half3x3 local2WorldTranspose = half3x3(
				input.tangentWorld,
				input.bitangentWorld,
				input.normalInWorld);
				normalInWorld = normalize(mul(bumpNormal, local2WorldTranspose));
				normalInWorld = normalInWorld*0.5 + 0.5;
				return half4(normalInWorld,1.0);
			}
			ENDCG
		}
	}
}

Let’s look at the shader itself.

The shader code that implements the conversion is quite straightforward. Instead of using the actual vertex position, it uses the texture coordinate of the vertex, projecting the object onto a 2D plane as is done for texturing.

To make the OpenGL pipeline work correctly, the UV coordinatesare moved from the standard [0,1] range to the [-1,1] range and the Y coordinate is inverted. The Z coordinate is not used, so it can be set to 0 or any value within the near and far clip planes. This is shown in the following code:

output.pos = half4(input.tex.x*2.0 - 1.0,((1.0-input.tex.y)*2.0 - 1.0), 0.0, 1.0);
output.tc = input.tex;

The normal, tangent, and bitangents are computed in the vertex shader and passed to the fragment shader to execute the conversion. This is shown in the following code:

output.normalInWorld = normalize(mul(half4(input.normal, 0.0), _World2Object).xyz);
output.tangentWorld = normalize(mul(_Object2World, half4(input.tangent.xyz, 0.0)).xyz);
output.bitangentWorld = normalize(cross(output.normalInWorld, output.tangentWorld) * input.tangent.w);

The fragment shader:

  1. Converts the normal from tangent space to world space
  2. Scales the normal to the [0,1] range
  3. Outputs the normal to the new texture

The preceding steps are shown in the following code:

half3 normalInWorld = half3(0.0,0.0,0.0);
half3 bumpNormal = UnpackNormal(tex2D(_BumpMapGlobal, input.tc));
half3x3 local2WorldTranspose = half3x3( input.tangentWorld,
input.bitangentWorld,
input.normalInWorld);
normalInWorld = normalize(mul(bumpNormal, local2WorldTranspose));
normalInWorld = normalInWorld*0.5 + 0.5;
return half4(normalInWorld,1.0);

The following image shows a tangent space normal map before processing:

The following image shows a world space normal map that is generated by the tool:

Previous Next