Author Topic: July 21, 2017 - Custom LOD system  (Read 1480 times)

ArenMook

  • Administrator
  • Hero Member
  • *****
  • Thank You
  • -Given: 337
  • -Receive: 1171
  • Posts: 22,154
  • Toronto, Canada
    • View Profile
July 21, 2017 - Custom LOD system
« on: July 21, 2017, 07:06:46 PM »
As most know, Unity has a built-in LOD (level of detail) system. When specified on a root model it can be used to swap renderers based on the size of the model on the screen. For most cases this is quite fine -- and increasing the scale of the object will automatically made the transition happen from farther away. This also means that smaller objects, such as rocks on the ground, will fade out much sooner than say, entire buildings. Makes sense, and it's pretty easy to fine-tune the fade distance while in the editor.



But wait, what if the objects have to fade out at the same distance? What if you have a complex building made up of several parts -- the foundation, the walls, and a bunch of platforms on top, like just about any structure in Sightseer? With the Unity's LOD system, there are two immediate issues. First, Sightseer's renderers are not available until they are dynamically generated at run-time, meaning their size is not known until a bunch of smaller objects get merged together into one larger one in order to save on draw calls and CPU overhead (culling). Since the dimensions are not available, it's not possible to fine-tune the fade distance, and due to varying sizes of objects, since Unity's LOD is based on the final object's dimensions rather than distance, it means they will fade out at different times.

I noticed it right away in Sightseer with trees even before player outposts were introduced. Trees are split into groups by fixed size cells, and all the trees inside each cell are merged into draw calls. Some cells may be full of trees, while others can only have a couple. Since the dimensions of the final renderer vary greatly, this caused some groups of trees to fade in at the horizon, while others wouldn't appear until the player got very close, even though they were adjacent to each other in the world.

The issue only got worse when player outposts were introduced. Player outposts are made from dozens and sometimes even hundreds of small objects -- foundations, walls, and many other types of props -- and Sightseer's code groups them together by material, then merges them into fewest draw calls possible on (a separate thread as to not impact performance). The end result: a variety of renderer sizes, all of which should fade in and out together. With Unity's LOD system that simply wasn't possible. I had player outposts appear piece by piece as I drove towards them -- often with objects on top of foundations appearing to float in mid-air. Not good.

Another issue I ran into with LODGroup is that since it's based on the size of the object on the screen, it effectively means that as the camera moves around the vehicle in 3rd-person view, or zooms in/out, objects near the player's vehicle would swap their LOD levels, or even fades in/out. This is not ideal for Sightseer, and I imagine for other 3rd person games. Objects fading in and out while the camera moves around a stationary vehicle looks jarring at best. Furthermore it hurts performance as the LOD checks have to be performed all the time. It's actually the same issue I've ran into with Unity's grass, but more on that in a separate post

At first, I experimented with trying to hack the LODGroup to work based on distance. I experimented with what happens when it's added before the renderers, and was actually successful in getting the trees to fade out when I wanted them to. Unfortunately the same trick didn't seem to work with the player outposts. I never did figure out why...

Eventually I decided to write my own system. The most basic example of a LOD system is to have a script on the renderer that checks the distance between the player's avatar and the object itself, and enables/disables the renderer based on that. It's simple and controllable -- but of course this basic approach doesn't include any kind of fading in or out.

As I delved into Unity's code that handles fading between different LOD levels (the same code that fades between renderers), I actually discovered another downside of Unity's LOD system: it requires a texture! Behold, ApplyDitherCrossFade function from UnityCG's include file:
  1.     void ApplyDitherCrossFade(half3 ditherScreenPos)
  2.     {
  3.         half2 projUV = ditherScreenPos.xy / ditherScreenPos.z;
  4.         projUV.y = frac(projUV.y) * 0.0625 /* 1/16 */ + unity_LODFade.y; // quantized lod fade by 16 levels
  5.         clip(tex2D(_DitherMaskLOD2D, projUV).a - 0.5);
  6.     }
As you can see, it samples a dithering texture in order to calculate dithering -- something that is trivial to do via code instead. With the number of texture registers being limited at 16 total, that actually hurts quite a bit. Although to be fair, I'm guessing most games won't run into this particular limitation.

When working on my own LOD system I decided to simply add LOD support to all of the Tasharen's shaders. Sightseer doesn't use Unity's shaders due to some issues explained in a previous post, so adding dithering was a trivial matter -- but let's go over it step by step.

First, we need a function that will compute the screen coordinates for dithering. This is Unity's ComputeDitherScreenPos function from UnityCG.cginc:
  1. half3 ComputeDitherScreenPos(float4 clipPos)
  2. {
  3.         half3 screenPos = ComputeScreenPos(clipPos).xyw;
  4.         screenPos.xy *= _ScreenParams.xy * 0.25;
  5.         return screenPos;
  6. }
That function accepts the clipped vertex position -- something everyone already calculates in the Vertex Shader:
  1. o.vertex = UnityObjectToClipPos(v.vertex)
Simply save the coordinates, passing them to the fragment shader:
  1. o.dc = ComputeDitherScreenPos(o.vertex);
The next step is to take these coordinates in the fragment shader, do some magic with them and clip() the result, achieving a dithering effect for fading in the geometry pixel by pixel.
  1. void DitherCrossFade(half3 ditherScreenPos)
  2. {
  3.         half2 projUV = ditherScreenPos.xy / ditherScreenPos.z;
  4.         projUV.xy = frac(projUV.xy + 0.001) + frac(projUV.xy * 2.0 + 0.001);
  5.         half dither = _Dither - (projUV.y + projUV.x) * 0.25;
  6.         clip(dither);
  7. }
Instead of using an expensive texture sample like Unity does, I use the frac() function to achieve a similar looking effect. The only notable part of the entire function is the "_Dither" value -- a uniform that's basically the fade alpha. In fact, you can use the main color's alpha instead to make it possible to fade out solid objects!

Here's the entire shader, for your convenience.
  1. Shader "Unlit/Dither Test"
  2. {
  3.         Properties
  4.         {
  5.                 _MainTex ("Texture", 2D) = "white" {}
  6.                 _Dither("Dither", Range(0, 1)) = 1.0
  7.         }
  8.  
  9.         SubShader
  10.         {
  11.                 Tags { "RenderType" = "Opaque" }
  12.                 LOD 100
  13.  
  14.                 Pass
  15.                 {
  16.                         CGPROGRAM
  17.                         #pragma vertex vert
  18.                         #pragma fragment frag
  19.  
  20.                         #include "UnityCG.cginc"
  21.  
  22.                         struct appdata
  23.                         {
  24.                                 float4 vertex : POSITION;
  25.                                 float2 uv : TEXCOORD0;
  26.                         };
  27.  
  28.                         struct v2f
  29.                         {
  30.                                 float4 vertex : SV_POSITION;
  31.                                 float2 uv : TEXCOORD0;
  32.                                 float3 dc : TEXCOORD1;
  33.                         };
  34.  
  35.                         sampler2D _MainTex;
  36.                         float4 _MainTex_ST;
  37.                         fixed _Dither;
  38.  
  39.                         half3 ComputeDitherScreenPos(float4 clipPos)
  40.                         {
  41.                                 half3 screenPos = ComputeScreenPos(clipPos).xyw;
  42.                                 screenPos.xy *= _ScreenParams.xy * 0.25;
  43.                                 return screenPos;
  44.                         }
  45.  
  46.                         void DitherCrossFade(half3 ditherScreenPos)
  47.                         {
  48.                                 half2 projUV = ditherScreenPos.xy / ditherScreenPos.z;
  49.                                 projUV.xy = frac(projUV.xy + 0.001) + frac(projUV.xy * 2.0 + 0.001);
  50.                                 half dither = _Dither - (projUV.y + projUV.x) * 0.25;
  51.                                 clip(dither);
  52.                         }
  53.                        
  54.                         v2f vert (appdata v)
  55.                         {
  56.                                 v2f o;
  57.                                 o.vertex = UnityObjectToClipPos(v.vertex);
  58.                                 o.dc = ComputeDitherScreenPos(o.vertex);
  59.                                 o.uv = TRANSFORM_TEX(v.uv, _MainTex);
  60.                                 return o;
  61.                         }
  62.                        
  63.                         fixed4 frag (v2f i) : SV_Target
  64.                         {
  65.                                 DitherCrossFade(i.dc);
  66.                                 return tex2D(_MainTex, i.uv);
  67.                         }
  68.                         ENDCG
  69.                 }
  70.         }
  71. }
So how does the fading between two renderers happen, you may wonder? It's simple: both are drawn for a time it takes for them to fade in/out. You may think "omg, but that's twice the draw calls!", and while that's true, it's only for a short time and doing so doesn't affect the fill rate due to the clip(). Basically the pixels that are drawn by one renderer should be clipped by the other. Here is the modified version of the shader with an additional property: "Dither Side":
  1. Shader "Unlit/Dither Test"
  2. {
  3.         Properties
  4.         {
  5.                 _MainTex ("Texture", 2D) = "white" {}
  6.                 _Dither("Dither", Range(0, 1)) = 1.0
  7.                 _DitherSide("Dither Side", Range(0, 1)) = 0.0
  8.         }
  9.  
  10.         SubShader
  11.         {
  12.                 Tags { "RenderType" = "Opaque" }
  13.                 LOD 100
  14.  
  15.                 Pass
  16.                 {
  17.                         CGPROGRAM
  18.                         #pragma vertex vert
  19.                         #pragma fragment frag
  20.  
  21.                         #include "UnityCG.cginc"
  22.  
  23.                         struct appdata
  24.                         {
  25.                                 float4 vertex : POSITION;
  26.                                 float2 uv : TEXCOORD0;
  27.                         };
  28.  
  29.                         struct v2f
  30.                         {
  31.                                 float4 vertex : SV_POSITION;
  32.                                 float2 uv : TEXCOORD0;
  33.                                 float3 dc : TEXCOORD1;
  34.                         };
  35.  
  36.                         sampler2D _MainTex;
  37.                         float4 _MainTex_ST;
  38.                         fixed _Dither;
  39.                         fixed _DitherSide;
  40.  
  41.                         inline half3 ComputeDitherScreenPos(float4 clipPos)
  42.                         {
  43.                                 half3 screenPos = ComputeScreenPos(clipPos).xyw;
  44.                                 screenPos.xy *= _ScreenParams.xy * 0.25;
  45.                                 return screenPos;
  46.                         }
  47.  
  48.                         inline void DitherCrossFade(half3 ditherScreenPos)
  49.                         {
  50.                                 half2 projUV = ditherScreenPos.xy / ditherScreenPos.z;
  51.                                 projUV.xy = frac(projUV.xy + 0.001) + frac(projUV.xy * 2.0 + 0.001);
  52.                                 half dither = _Dither.x - (projUV.y + projUV.x) * 0.25;
  53.                                 clip(lerp(dither, -dither, _DitherSide));
  54.                         }
  55.                        
  56.                         v2f vert (appdata v)
  57.                         {
  58.                                 v2f o;
  59.                                 o.vertex = UnityObjectToClipPos(v.vertex);
  60.                                 o.dc = ComputeDitherScreenPos(o.vertex);
  61.                                 o.uv = TRANSFORM_TEX(v.uv, _MainTex);
  62.                                 return o;
  63.                         }
  64.                        
  65.                         fixed4 frag (v2f i) : SV_Target
  66.                         {
  67.                                 DitherCrossFade(i.dc);
  68.                                 return tex2D(_MainTex, i.uv);
  69.                         }
  70.                         ENDCG
  71.                 }
  72.         }
  73. }
  74.  
For the renderer that's fading in, pass the dither amount and leave the _DitherSide at 0. For the renderer that's fading out, pass (1.0 - dither amount), and 1.0 for the _DitherSide. I recommend using Material Property Blocks. In fact, in Sightseer I wrote an extension that lets me do renderer.AddOnRender(func), where "func" receives a MaterialpropertyBlock to modify:
  1. using UnityEngine;
  2.  
  3. /// <summary>
  4. /// Simple per-renderer material block that can be altered from multiple sources.
  5. /// </summary>
  6.  
  7. public class CustomMaterialBlock : MonoBehaviour
  8. {
  9.         Renderer mRen;
  10.         MaterialPropertyBlock mBlock;
  11.  
  12.         public OnWillRenderCallback onWillRender;
  13.         public delegate void OnWillRenderCallback (MaterialPropertyBlock block);
  14.  
  15.         void Awake ()
  16.         {
  17.                 mRen = GetComponent<Renderer>();
  18.                 if (mRen == null) enabled = false;
  19.                 else mBlock = new MaterialPropertyBlock();
  20.         }
  21.  
  22.         void OnWillRenderObject ()
  23.         {
  24.                 if (mBlock != null)
  25.                 {
  26.                         mBlock.Clear();
  27.                         if (onWillRender != null) onWillRender(mBlock);
  28.                         mRen.SetPropertyBlock(mBlock);
  29.                 }
  30.         }
  31. }
  32.  
  33. /// <summary>
  34. /// Allows for renderer.AddOnRender convenience functionality.
  35. /// </summary>
  36.  
  37. static public class CustomMaterialBlockExtensions
  38. {
  39.         static public CustomMaterialBlock AddOnRender (this Renderer ren, CustomMaterialBlock.OnWillRenderCallback callback)
  40.         {
  41.                 UnityEngine.Profiling.Profiler.BeginSample("Add OnRender");
  42.                 var mb = ren.GetComponent<CustomMaterialBlock>();
  43.                 if (mb == null) mb = ren.gameObject.AddComponent<CustomMaterialBlock>();
  44.                 mb.onWillRender += callback;
  45.                 UnityEngine.Profiling.Profiler.EndSample();
  46.                 return mb;
  47.         }
  48.  
  49.         static public void RemoveOnRender (this Renderer ren, CustomMaterialBlock.OnWillRenderCallback callback)
  50.         {
  51.                 UnityEngine.Profiling.Profiler.BeginSample("Remove OnRender");
  52.                 var mb = ren.GetComponent<CustomMaterialBlock>();
  53.                 if (mb != null) mb.onWillRender -= callback;
  54.                 UnityEngine.Profiling.Profiler.EndSample();
  55.         }
  56. }
In the end, while Sightseer's LOD system ended up being a lot more advanced and made to support colliders as well as renderers (after all, expensive concave mesh colliders don't need to be active unless the player is near), at its core the most confusing part was figuring out how to manually fade out renderers. I hope this helps someone else in the future!
« Last Edit: July 21, 2017, 07:17:40 PM by ArenMook »