Thursday, August 13, 2020

Light the Universal Render Pipeline to Resemble the standard Unity renderer

We're working to port a PC Unity title to the mobile platforms, and it's decently expensive on the GPU end.  We did some experimenting and found that we could get a significant speed increase by moving to Unity 2019.3 and the Universal Render Pipeline.  For the most part, doing this conversion went okay.  We were able to upgrade materials and write some scripts of our own to translate others.  The major sticking point was the lighting.  The game uses a lot of spot lights and point lights.  We didn't want to go and tweak the range and intensity of all of them if we could help it, so it made sense to investigate whether we could modify the URP to get its lighting behavior to more closely match the built-in Unity renderer where these lights had already been set up.

TLDR

Modify the distance attenuation calculation in Lighting.hlsl:

float DistanceAttenuation(float distanceSqr, half2 distanceAttenuation)
{
  // Reconstruct the light range from the Unity shader arguments
  float lightRangeSqr = rcp(distanceAttenuation.x);

#if !SHADER_HINT_NICE_QUALITY
  lightRangeSqr = -1.0 * lightRangeSqr / 0.36;
#endif

  // Calculate the distance attenuation to approximate the built-in Unity curve
  return rcp(1 + 25 * distanceSqr / lightRangeSqr);
}

Modify the intensity calculation in ForwardLights.cs:

void InitializeLightConstants(NativeArray<VisibleLight> lights, int lightIndex, out Vector4 lightPos, out Vector4 lightColor, out Vector4 lightAttenuation, out Vector4 lightSpotDir, out Vector4 lightOcclusionProbeChannel)
{
  // .... Unity code

  // We're squaring the intensity because it's a closer match to the built-in Unity renderer
  lightColor = lightData.light.color * lightData.light.intensity * lightData.light.intensity;

  // .... Unity code
}

Background

All this work is being done in Unity 2019.3.10f1 with the Universal Render Pipeline package 7.3.1.  Before we could get to actually doing the modifications, we needed to make sure the modifications would stick around, since just installing the URP package would cause any local changes to be reverted each time Unity started.  This forum post gave us the direction we needed.  Moving the contents of the URP package into the Unity Packages folder let us add them to our source control so we could track our modifications relative to the original version from Unity.

Distance Attenuation

Unity does point out that the lighting distance attenuation is different between the built-in renderer and the Universal Render Pipeline.  That gave us the lead we needed for what to search for.  This forum thread discusses doing what we talked about and suggests a formula to approximate what the built-in Unity renderer does.  The formula they came up with is this (where "d" is the distance from the light to the surface):
1 / (1 + 25 * d^2)
The actual distance attenuation is calculated in the URP shader include <path to URP package>/ShaderLibrary/Lighting.hlsl, in the function DistanceAttenuation.  Since it's using proper physical light behavior, the attenuation is more simply:
1 / d^2
Unity does some additional smoothing math to make sure the attenuation goes all the way to 0 at the edge of the light range.  However, that's dependent on the inverse square calculation of the attenuation.  If we change that, the smoothing no longer works right.  Just plugging in the provided squared distance value to the new formula causes all the lights to be much, much darker.  We weren't sure if this was because the formula was wrong or because we were doing our inputs to the formula wrong.  We had to do some isolated testing.

We set up two test projects in Unity 2019.3.10: one with the built-in Unity renderer, and one with the Universal Render Pipeline.  Both projects were set to use linear color space.  We then made a simple scene in each, matched as closely as possible.  One point light, white color, range 10, intensity 1.  A standard cube, scaled on the X/Y plane so it would fill the screen and have a surface for the light.  Material on the cube is the standard Unity shader (or URP lit shader), color black.


From here, we moved the light position between 0.5 and 10 units away from the surface so we could see how the color value changed as the distance changed.  We got eleven screenshots that we could sample colors from:

This results in the following values:

 Light Distance | Built-in Unity Renderer Color (0-255)
 0.5            | 125
   1            | 116
   2            |  94
   3            |  75
   4            |  61
   5            |  51
   6            |  44
   7            |  39
   8            |  34
   9            |  24
  10            |  13

Which look like this:


Plugged into a program to find an equation to fit the curve, it lines up almost exactly with the formula we were looking at before (1 / (1 + 25 * d^2)).  So the formula is right, which means our input was wrong.

The square distance given to the distance attenuation function is raw.  It's not scaled to match the light range.  The most obvious thing to try was to bring the distance back into range.  Any distance value greater than the light range should automatically attenuate to 0.  So rather than have the "d" in the formula be the distance, let's have it be the percentage of the light range that this distance covers (distance divided by light range).  If the distance is greater than the light range, that should just make the final attenuation that much smaller, since it's a reciprocal.

Looking in <path to URP package>/Runtime/ForwardLights.cs in the method InitializeLightConstants, we see the values sent to the shader are calculated as:

float lightRangeSqr = lightData.range * lightData.range;
float fadeStartDistanceSqr = 0.8f * 0.8f * lightRangeSqr;
float fadeRangeSqr = (fadeStartDistanceSqr - lightRangeSqr);
float oneOverFadeRangeSqr = 1.0f / fadeRangeSqr;
float lightRangeSqrOverFadeRangeSqr = -lightRangeSqr / fadeRangeSqr;
float oneOverLightRangeSqr = 1.0f / Mathf.Max(0.0001f, lightData.range * lightData.range);

// On mobile and Nintendo Switch: Use the faster linear smoothing factor (SHADER_HINT_NICE_QUALITY).
// On other devices: Use the smoothing factor that matches the GI.
lightAttenuation.x = Application.isMobilePlatform || SystemInfo.graphicsDeviceType == GraphicsDeviceType.Switch ? oneOverFadeRangeSqr : oneOverLightRangeSqr;

lightAttenuation.y = lightRangeSqrOverFadeRangeSqr;

We are looking at lightAttenuation.x specifically.  That's what gets passed into the shader function as distanceAttenuation.  In the case of non-mobile platforms, inverting it gives you the square light range.

float lightRangeSqr = rcp(distanceAttenuation.x);

However, for mobile platforms, the value is further altered so they can do a faster calculation in the original attenuation function and get a smooth attenuation at the edge of the light range.  Reversing the equations for fade start distance and fade range, you wind up with the extra adjustment for mobile only:

lightRangeSqr = -1.0 * lightRangeSqr / 0.36;

Now that we have a valid square light range, we can use it to divide the square distance and come up with a normalized value that we can plug into the original formula:

rcp(1 + 25 * distanceSqr / lightRangeSqr);

If we then repeat the point light test that we did for the built-in Unity renderer, we can record a similar set of values, and the comparison between the two comes out to:


The lines match almost exactly.  There is a slight difference in the last 20% of the light range.  Unity does a smoother fade to 0, but this formula does not take that into account.  Most of the lights in our game have their range extend beyond what's visible to the player, so we decided it wasn't worth correcting this.

After verifying the distance attenuation was solid, we made sure to check the angle attenuation for spot lights.  We rotated the light around the cube and checked color values with the built-in Unity renderer and the URP with the corrected distance attenuation.  Luckily, these values matched up properly, and no further adjustments were needed.

Intensity

After validating the distance and angle attenuation were correct, we still saw a decent amount of difference between the lighting in our game before and after the Universal Render Pipeline conversion.  We decided to investigate the intensity.  Using the same test setup as before, we kept the light at a fixed distance (2 units) and varied the intensity from 0 - 3. (Color values are 0-255, URP Color is measures with the distance attenuation fix in place.)

Light Intensity | Built-in Unity Renderer Color | URP Color
  3             | 255                           | 154
2.7             | 250                           | 147
2.4             | 224                           | 139
2.1             | 196                           | 131
1.8             | 169                           | 122
1.5             | 141                           | 113
1.2             | 112                           | 102
0.9             |  84                           |  89
0.6             |  55                           |  74
0.3             |  29                           |  54
  0             |  13                           |  14

Graphed, this looks like:


This was unexpected, but after experimenting for a bit, the best correction we were able to find was to square the intensity value when applying it.  The light intensity is computed into the color already by the Unity engine in UnityEngine.Rendering.VisibleLight.finalColor.  However, all that's doing appears to be multiplying Light.color by Light.intensity.  So back in ForwardLights.cs, we modified the method InitializeLightConstants to define the lightColor output parameter as:

lightColor = lightData.light.color * lightData.light.intensity * lightData.light.intensity;

Returning to the testing, this seemed to match the built-in renderer values fairly closely for the same test case.