Thursday, August 13, 2020

Light the Universal Render Pipeline to Resemble the built-in Unity render

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.

Thursday, July 30, 2020

Fixing Git after Moving a File Type to LFS

We ran into a problem a while back where we had a Git repo that had been in use for a while and then we decided one of the common file types should be in the LFS.  The obvious way to do this was to update the filters in the .gitattributes file in the root:

*.ext filter=lfs diff=lfs merge=lfs

However, after doing this, the various Git clients (command line, Source Tree, etc.) didn't actually move all the *.ext files into the LFS.  Any new commits we made that touched those files would automatically move them, but there were plenty of files that didn't get updated.  We noticed this because Git would notice some of these other files (even though they hadn't been modified) and give us the dreaded "Encountered X file(s) that should have been pointers, but weren't" error whenever doing Git operations.

A little Goolging led us to this method of getting the rest of the files into the LFS:

git lfs migrate import --no-rewrite path/to/file.ext

This seemed pretty straightforward.  We wrote a Python script that did an os.walk() across our project and compared it against the results of git lfs ls-files to see which of the new files had yet to be added to the LFS.  This came up with a couple hundred files for us.  We had the script also run the import command above and with that we had the remaining files where they needed to be.  Push to remote, and we're set.

We were not set.

When we attempted to view the Git repo in Source Tree and also Git Fork, the program either crashed or hung spinning on a load.  Somehow we had broken the Git repo.  We reviewed the logs in the command line client and noticed that the timestamps on all the import commands were December 31st, 1969.  Effectively a zero value in the date/time field for the commit, which seemed to confuse the graphical Git clients.  Back to Googling.

This StackOverflow answer from Ortomala Lokni provided a potential solution.  We needed to modify the process a bit for the Windows machines we were on.  Basically, the date setting needed to be two commands: set the committer date as an environment variable once:

set GIT_COMMITTER_DATE="2020-07-19T00:00:00"

Then we could proceed with the individual commit updates during the rebase:

git commit --amend --date="2020-07-19T00:00:00"

Of course, a major inconvenience here is that we still have the .gitattributes filter set to *.ext files should be in the LFS.  And when we do the rebase process, it takes the files out of the LFS which goes against the filter specification, and thus we can't amend the commit because of the filter error.  Luckily, Git has the ability to override the .gitattributes list temporarily by placing a file called "attributes" (no extension) in the ".git/info" folder in the project.  In that file we place the override entry:

*.ext !filter !diff !merge

This effectively makes Git ignore that file's LFS filter so the rebase can proceed without errors.  However, once the rebase was complete, we had to remove that override file again before resuming normal operations.

Friday, July 17, 2020

Forager!

Forager is a fun and colorful open world exploration, farming and crafting game!  We worked with HopFrog and Humble to update Forager on both Switch and PS4, bringing all the new content over the last several months to existing console players.  Plus, we brought the game to Xbox One, where it's now available via Xbox Game Pass!




If you're a fan of Zelda-style exploration, or enjoy building up your farm in Stardew Valley, you should give Forager a look.  If you prefer your PC, it's also available on Steam!

Thursday, May 7, 2020

Wintermoor Tactics Club!

Now available on Steam!  Inspired by Final Fantasy Tactics, Wintermoor features a fun story and great turn-based tactical battles with interesting character abilities that really make you think.  We worked with the folks at EVC and Versus Evil to help with UI work and platform support, and you'll find the game on consoles in a few weeks.


Meanwhile, if you prefer PC, snag it on Steam here!

Friday, May 1, 2020

Neversong!

Neversong is a beautiful and atmospheric platformer that explores themes of loss and life.  Help Peet find his way out of a nightmare dream world!  Discover what led to its creation!  Fight bird monsters with a baseball bat!  It's published by Serenity Forge, and we helped bring it to Apple Arcade platforms.  Available now!

Neversong, the narrative-driven adventure game set in a stylish nightmare, launches on Apple Arcade

If you're an Apple Arcade subscriber, you can get Neversong for your iOS or macOS device via the app store, via these convenient links!

Saturday, March 21, 2020

Free Controller Prompts

I spent the weekend making a full set of button prompts for all the current console controllers, and figured I'd release this for free for anyone who's got a use for them.  These should be fully compliant for certification if you're shipping a console game, based on our experience there.  They're high contrast and sharp looking and should fit in with any kind of modern or minimalist game-feel.

These should give you everything you need for Switch, PS4, Xbox One, Xbox 360, and PS1/2/3 prompting.  There's also a couple blanks for style-matching keyboard icons if you need them.

The package includes vector and high-res raster artwork (both as a power-of-two sheet and as a set of individual 128x128 images).  There's a preview at the OpenGameArt listing if you're interested, or get the zip file directly here.

EDIT April 5th: Now includes the full keyboard, as well.