I wanted to create the simple double-sided force field shader but stumbled upon a problem with the double-sided rendering of transparent objects.
You can see that the Cull Off shader has some artifacts. On the other hand, the double-sided mesh with the Cull Back shader doesn’t have these artifacts.
I’ve decided to do some research about the ways to render objects from both sides. I want to look into how they work with transparent objects and what approaches can be used in LWRP.
There are several ways to render meshes from both sides:
- A single-pass Cull Off shader with normals flip on VFACE, if lighting is needed
- Two-pass shader, the first pass with Cull Front, the second with Cull Back
- Two Materials Basic material and material with Cull Front and flipped normals
- Double-sided mesh
All variants except the fourth require shader modifications
Front face and Back face
Usually, models are opaque and closed so when you fly into the model and look from the inside, you won’t be able to see the model because of its Face culling. Even from the outside of the model, a lot of triangles are invisible because they are facing the other way.
Face Culling is the process that optimizes rendering by not rendering faces that are invisible from the current viewpoint of the camera. This process separates faces into Front faces – the faces that are considered visible and Back faces – which are not visible faces.
Whether the face is front or back is controlled by Winding order. This order first emerges when you create the model and write the triangles array. This array stores indices to vertexes in the vertex array. Winding order can be clockwise or counter-clockwise.
Imagine two triangles in their triangles arrays, the left has indices in Clockwise order (1->2->3) and the right has indices in Counter-Clockwise (1->3->2). They are both projected on the screen like the image below.

Then because Unity uses a clockwise winding order for the front faces only the left triangle is visible from this viewpoint. But when you look at the same triangles from the opposite side their projection on the screen changes and now only the right triangle is visible.

A single-pass Cull Off shader
This approach can be used in LWRP. Using Cull Off in the shader disables back-face culling so you see all faces regardless of the viewpoint of the camera.
With Opaque or AlphaTest shaders, this approach works well(because of the depth writing). But because triangles in the mesh rendered in the order in which they are stored in triangles array, with transparent shaders sometimes the back face triangles render over the front face triangles.
You can check these artefacts with the simple shader graph that shows different colour based on the type of face.

The result of this shader on unity’s built-in sphere looks like this:
Double-sided mesh
This approach is better for transparent objects because you can render all back facing triangles first and then all front facing triangles. Making the mesh double-sided is possible in any modelling software, but why not do it in Unity. To create a double-sided mesh you can combine the original mesh with its inverted copy.
Inverted mesh generation
To generate the inverted mesh you need to make several modifications to the original mesh.
First, you need to change the Winding order by reversing the triangles array.
triangles = mesh.triangles.Reverse().ToArray()
Second, to preserve the correct lighting calculation you need to flip normals and tangents. The tangent flip is important because otherwise, you will see flipped lighting on the inverted side of the mesh.
Vector3[] normals = mesh.normals; Vector3[] invertedNormals = new Vector3[normals.Length]; for (int i = 0; i < invertedNormals.Length; i++) { invertedNormals[i] = -normals[i]; } Vector4[] tangents = mesh.tangents; Vector4[] invertedTangents = new Vector4[tangents.Length]; for (int i = 0; i < invertedTangents.Length; i++) { invertedTangents[i] = tangents[i]; invertedTangents[i].w = -invertedTangents[i].w; }
The left quad has the same tangents on both sides. The right quad has flipped tangents on the inverted side. When you look at both quads’ original sides, the lighting on both quads looks identical.

If you rotate the quads by 180 degrees, the lighting on the right quad looks the same. But on the left quad, it’s now incorrect. It’s like the light source direction has flipped.

Combining the inverted and original meshes
Unity has the build-in method for meshes combining.
Mesh CombineMeshes(Mesh mesh, Mesh invertedMesh) { CombineInstance[] combineInstancies = new CombineInstance[2] { new CombineInstance(){mesh = invertedMesh, transform = Matrix4x4.identity}, new CombineInstance(){mesh = mesh, transform = Matrix4x4.identity} }; if (_combineOrder == CombineOrder.OriginalThenInverted) { combineInstancies = combineInstancies.Reverse().ToArray(); } Mesh combinedMesh = new Mesh(); combinedMesh.CombineMeshes(combineInstancies); return combinedMesh; }
The order of combination matters because triangles ate rendered in the order in which they are defined in the triangles array. For the transparent sphere, for example, you would want to first render the inverted mesh (insides of the sphere) and then over the insides render the original mesh (outsides).
To distinguish the inverted mesh faces from the original mesh faces in the combined mesh, you can write zero for the original and one for the inverted in the UV3.
Mesh ChangeUV3(Mesh mesh, float value) { Mesh result = Instantiate(mesh); Vector2[] uv3 = new Vector2[mesh.vertexCount]; for (int i = 0; i < uv3.Length; i++) { uv3[i] = new Vector2(value, value); } result.uv3 = uv3; return result; }
Based on this distinction you check that for transparent meshes this approach doesn’t produce artefacts.
This shader graph returns a green colour for the inverted mesh faces, and a red colour for the original mesh faces.
The result of this shader on the double-sided mesh generated from Unity’s built-in sphere looks like this:
As you see there aren’t any artefacts. With alpha set to one, only red (outside faces) are visible. With alpha set to 0.5, you can see that green (inside faces) and red (outside faces) have blended into an orange colour.
Saving the combined mesh
You can save the combined mesh using Unity’s create asset method. It’s important to note that the path string must end on “.asset” otherwise you wouldn’t be able to assign the combined mesh to Mesh Filter’s mesh field.
void SaveMesh(string path, Mesh mesh) { if (!Directory.Exists(_outputPath)) { Directory.CreateDirectory(_outputPath); } AssetDatabase.CreateAsset(mesh, path); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); }
Conclusion
The double-sided generator is simple and clear from artifacts way that can be used to create transparent meshes.
You can get the article’s code there.
The full source code of a double-sided generator window
using UnityEngine; using UnityEditor; using System.Linq; using System.IO; public class DoubleSidedMeshGeneratorWindow : EditorWindow { Object _originalMeshObject; string _outputPath = "Assets/Meshes/"; enum CombineOrder { InvertedThenOriginal, OriginalThenInverted } CombineOrder _combineOrder; [MenuItem("Meshes/Double-Sided Mesh Generator")] public static void OpenWindow() { GetWindow<DoubleSidedMeshGeneratorWindow>("Double-Sided Mesh Generator"); } void OnGUI() { _originalMeshObject = EditorGUILayout.ObjectField(_originalMeshObject, typeof(Mesh), true); _outputPath = EditorGUILayout.TextField("Output path", _outputPath); _combineOrder = (CombineOrder)EditorGUILayout.EnumPopup("Combine order", _combineOrder); EditorGUILayout.Space(); if (GUILayout.Button("Generate inverted")) { Mesh originalMesh = _originalMeshObject as Mesh; if (originalMesh != null) { Mesh invertedMesh = CreateInvertedMesh(originalMesh); string path = GetInvertedMeshPath(originalMesh.name, invertedMesh); SaveMesh(path, invertedMesh); } } if (GUILayout.Button("Generate double-sided")) { Mesh originalMesh = _originalMeshObject as Mesh; if (originalMesh != null) { Mesh invertedMesh = CreateInvertedMesh(originalMesh); Mesh combinedMesh = CombineMeshes(originalMesh, invertedMesh); string path = GetDoubleSidedMeshPath(originalMesh.name, combinedMesh); SaveMesh(path, combinedMesh); } } if (GUILayout.Button("Generate double-sided with UV3 = 1 for inverted mesh")) { Mesh originalMesh = _originalMeshObject as Mesh; if (originalMesh != null) { Mesh invertedMesh = CreateInvertedMesh(originalMesh); Mesh combinedMesh = CombineMeshes(ChangeUV3(originalMesh, 0), ChangeUV3(invertedMesh, 1)); string path = GetDoubleSidedMeshPath(originalMesh.name, combinedMesh, true); SaveMesh(path, combinedMesh); } } } Mesh CreateInvertedMesh(Mesh mesh) { Vector3[] normals = mesh.normals; Vector3[] invertedNormals = new Vector3[normals.Length]; for (int i = 0; i < invertedNormals.Length; i++) { invertedNormals[i] = -normals[i]; } Vector4[] tangents = mesh.tangents; Vector4[] invertedTangents = new Vector4[tangents.Length]; for (int i = 0; i < invertedTangents.Length; i++) { invertedTangents[i] = tangents[i]; invertedTangents[i].w = -invertedTangents[i].w; } return new Mesh { vertices = mesh.vertices, uv = mesh.uv, normals = invertedNormals, tangents = invertedTangents, triangles = mesh.triangles.Reverse().ToArray() }; } Mesh ChangeUV3(Mesh mesh, float value) { Mesh result = Instantiate(mesh); Vector2[] uv3 = new Vector2[mesh.vertexCount]; for (int i = 0; i < uv3.Length; i++) { uv3[i] = new Vector2(value, value); } result.uv3 = uv3; return result; } Mesh CombineMeshes(Mesh mesh, Mesh invertedMesh) { CombineInstance[] combineInstancies = new CombineInstance[2] { new CombineInstance(){mesh = invertedMesh, transform = Matrix4x4.identity}, new CombineInstance(){mesh = mesh, transform = Matrix4x4.identity} }; if (_combineOrder == CombineOrder.OriginalThenInverted) { combineInstancies = combineInstancies.Reverse().ToArray(); } Mesh combinedMesh = new Mesh(); combinedMesh.CombineMeshes(combineInstancies); return combinedMesh; } void SaveMesh(string path, Mesh mesh) { if (!Directory.Exists(_outputPath)) { Directory.CreateDirectory(_outputPath); } AssetDatabase.CreateAsset(mesh, path); AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); } string GetDoubleSidedMeshPath(string name, Mesh mesh, bool uv3Modified = false) { string uv3 = (uv3Modified) ? "_UV3_" : "_"; string order = (_combineOrder == CombineOrder.InvertedThenOriginal) ? "I_O_" : "O_I_"; return _outputPath + "DoubleSidedMesh_" + name + uv3 + order + mesh.GetInstanceID() + ".asset"; } string GetInvertedMeshPath(string name, Mesh mesh) { return _outputPath + "InvertedMesh_" + name + mesh.GetInstanceID() + ".asset"; } }