Search Unity

Custom Lighting in Shader Graph: Expanding your graphs in 2019

July 31, 2019 in Technology | 13 min. read
Share

With the release of Unity Editor 2019.1, the Shader Graph package officially came out of preview! Now, in 2019.2, we’re bringing even more features and functionality to Shader Graph.

What’s Changed in 2019?

Custom Function and Sub Graph Upgrades

To maintain custom code inside of your Shader Graph, you can now use our new Custom Function node. This node allows you to define your own custom inputs and outputs, reorder them, and inject custom functions either directly into the node itself, or by referencing an external file.  

Sub Graphs have also received an upgrade: you can now define your own outputs for Sub Graphs, with different types, custom names, and reorderable ports. Additionally, the Blackboard for Sub Graphs now supports all data types that the main graph supports.

Color Modes and Precision Modes

Using the Shader Graph to create powerful and optimized shaders just got a little easier. In 2019.2, you can now manually set the precision of calculations in your graph, either graph-wide or on a per-node basis. Our new Color Modes make it fast and easy to visualize the flow of Precision, the category of nodes, or display custom colors for your own use! 

See the Shader Graph documentation for more information about these new features.

Sample Project

To help you get started with the new custom function workflow, we’ve created an example project together with step-by-step instructions. Download the project from our repository and follow along! This project will show you how to use the Custom Function node to write custom lighting shaders for the Lightweight Render Pipeline (LWRP). If you want to follow along using a fresh project, make sure you’re using the 2019.2 Editor and  LWRP package version 6.9.1 or higher.

Getting Data from the Main Light

To get started, we need to get information from the main light in our Scene. Start by selecting Create > Shader > Unlit Graph to create a new Unlit Shader Graph. In the Create Node menu, locate the new Custom Function node, and click the gear icon on the top right to open the node menu.

In this menu, you can add inputs and outputs. Add two output ports for Direction and Color, and select Vector 3 for both. If you see an “undeclared identifier” error flag, don’t be worried; this will go away when we start to add our code. In the Type dropdown menu, select String. Update your function name — in this example, we’re using “MainLight”. Now, we can start adding our custom code in the text box.

First, we’re going to use a flag called `#ifdef SHADERGRAPH_PREVIEW`. Because the preview boxes on nodes don’t have access to light data, we need to tell the node what to display on the in-graph preview boxes. `#ifdef` tells the compiler to use different code in different situations. Start by defining your fallback values for the output ports.

#if SHADERGRAPH_PREVIEW
	Direction = half3(0.5, 0.5, 0);
	Color = 1;

Next, we’ll use `#else` to tell the compiler what to do when not in a preview. This is where we actually get our light data. Use the built-in function `GetMainLight()` from the LWRP package. We can use this information to assign the Direction and Color outputs. Your custom function should now look like this:

#if SHADERGRAPH_PREVIEW
	Direction = half3(0.5, 0.5, 0);
	Color = 1;
#else
	Light light = GetMainLight();
	Direction = light.direction;
	Color = light.color;
#endif

Now, it’s a good idea to add this node to a group so that you can mark down what it’s doing. Right-click the node, select Create Group from Selection, and then rename the group title to describe what your node is doing. Here we've entered “Get Main Light”.

Now that we have our light data, we can calculate some shading. We’re going to start with a standard Lambertian lighting, so let's take the dot product of the world normal vector and the light direction. Pass it into a Saturate node, and multiply it by the light color. Plug this into the Color port of the Unlit Master node, and your preview should update with some custom shading!

Using the Custom Function File Mode

Since we now know how to get light data using the Custom Function node, we can expand on our function. Our next function gets attenuation values from the main light in addition to the direction and color. 

As this is a more complicated function, let's switch to file mode, and use an HLSL include file. This lets you author more complicated functions in a proper code editor before injecting it into the graph. This also means that we have one unified location to debug the code from. 

Start by opening the `CustomLighting` include file in the Assets > Include folder of the project. For now, we’ll only focus on the `MainLight_half` function. The function looks like this:

void MainLight_half(float3 WorldPos, out half3 Direction, out half3 Color, out half DistanceAtten, out half ShadowAtten)
{
#if SHADERGRAPH_PREVIEW
   Direction = half3(0.5, 0.5, 0);
   Color = 1;
   DistanceAtten = 1;
   ShadowAtten = 1;
#else
#if SHADOWS_SCREEN
   half4 clipPos = TransformWorldToHClip(WorldPos);
   half4 shadowCoord = ComputeScreenPos(clipPos);
#else
   half4 shadowCoord = TransformWorldToShadowCoord(WorldPos);
#endif
   Light mainLight = GetMainLight(shadowCoord);
   Direction = mainLight.direction;
   Color = mainLight.color;
   DistanceAtten = mainLight.distanceAttenuation;
   ShadowAtten = mainLight.shadowAttenuation;
#endif
}

This function includes some new input and output data, so let’s go back to our Custom Function node and add them. Add two new outputs for DistanceAtten (distance attenuation) and ShadowAtten (shadow attenuation). Then, add the new input for WorldPos (world position). Now that we have our inputs and outputs, we can reference the include file. Change the Type dropdown to File. In the Source input, navigate to the include file, and select the Asset to reference. Now, we need to tell the node which function to use. In the Name box, we’ve entered “MainLight”.

You’ll notice that the include file has `_half` at the end of the function name, but our name option doesn’t. This is because the Shader Graph compiler appends the precision format to each function name. Since we’re defining our own function, we need the source code to tell the compiler which precision format our function uses. In the node, however, we only need to reference the main function name. You can create a duplicate of the function that uses ‘float’ values to compile in float precision mode. The ‘Precision’ Color Mode lets you easily track the precision set for each node in the graph, with blue representing float and red representing half.

We’ll probably want to use this function again somewhere else, and the easiest way to make this Custom Function reusable is to wrap it in a Sub Graph. Select the node and its group, and then right-click to find Convert to Sub-graph. We’ve called ours “Get Main Light”. In the Sub Graph, simply add the required output ports to the Sub Graph output node, and plug the node’s output into the Sub Graph output. Next, we’ll add a world position node to plug into the input.

Save the Sub Graph, and go back to our unlit graph. We’re going to add two new multiply nodes to our existing logic. First, multiply the two attenuation outputs together. Then, multiply that output by the light color. We can multiply this by NdotL from earlier to properly calculate attenuation in our basic shading.

Creating a Direct Specular Shader

The shader we’ve made is great for matte objects, but what if we want some shine? We can add our own specular calculations to our shader! For this step, we’ll use another Custom Function node wrapped in a Sub Graph, called Direct Specular. Take a look at the `CustomLighting` include file again, and see that we’re now referencing another function from the same file: 

void DirectSpecular_half(half3 Specular, half Smoothness, half3 Direction, half3 Color, half3 WorldNormal, half3 WorldView, out half3 Out)
{
#if SHADERGRAPH_PREVIEW
   Out = 0;
#else
   Smoothness = exp2(10 * Smoothness + 1);
   WorldNormal = normalize(WorldNormal);
   WorldView = SafeNormalize(WorldView);
   Out = LightingSpecular(Color, Direction, WorldNormal, WorldView, half4(Specular, 0), Smoothness);
#endif
}

This function performs some simple specular calculations, and if you’re curious, you can read more about them here. The Sub Graph for this function also includes some inputs on the Blackboard:

Make sure that your new node has all the appropriate input and output ports to match the function. Adding properties to the Blackboard is simple; just click the Add (+) icon on the top right, and select the data type. Double-click the pill to rename the input, and drag and drop the pill to add it to the graph. Lastly, update the output port for your Sub Graph, and save it. 

Now that specular calculation is set up, we can go back to the unlit graph, and add it through the Create Node menu. Connect the Attenuation output to the Color input of the Direct Specular Sub Graph. Next, connect the Direction output from the Get Main Light function to the Direction input of the specular Sub Graph. Add the result of NdotL*Attenuation to the output of the Direct Specular Sub Graph, and plug this in the Color output.

Now we’ve got a bit of shine!

Working with Multiple Lights

The LWRP's main light refers to the brightest directional light relative to the object, which is usually the sun. To improve performance on lower end hardware, the LWRP calculates the main light and any additional lights separately. To make sure our shader calculates correctly for all lights in the Scene, and not just the brightest directional light, you need to create a loop in your function. 

To get the additional light data, we used a new Sub Graph to wrap a new Custom Function node. Take a look at the `AdditionalLight_float` function in the `CustomLighting` include file:

void AdditionalLights_half(half3 SpecColor, half Smoothness, half3 WorldPosition, half3 WorldNormal, half3 WorldView, out half3 Diffuse, out half3 Specular)
{
   half3 diffuseColor = 0;
   half3 specularColor = 0;

#ifndef SHADERGRAPH_PREVIEW
   Smoothness = exp2(10 * Smoothness + 1);
   WorldNormal = normalize(WorldNormal);
   WorldView = SafeNormalize(WorldView);
   int pixelLightCount = GetAdditionalLightsCount();
   for (int i = 0; i < pixelLightCount; ++i)
   {
       Light light = GetAdditionalLight(i, WorldPosition);
       half3 attenuatedLightColor = light.color * (light.distanceAttenuation * light.shadowAttenuation);
       diffuseColor += LightingLambert(attenuatedLightColor, light.direction, WorldNormal);
       specularColor += LightingSpecular(attenuatedLightColor, light.direction, WorldNormal, WorldView, half4(SpecColor, 0), Smoothness);
   }
#endif

   Diffuse = diffuseColor;
   Specular = specularColor;
}

Like before, use the `AdditionalLights` function in the file reference of the Custom Function node, and ensure that you've created all the proper inputs and outputs. Make sure to expose Specular Color and Specular Smoothness on the Blackboard of the Sub Graph in which the node is wrapped. Use the Position, Normal Vector, and View Direction nodes to plug in the World Position, World Normal, and World Space View Direction in the Sub Graph.

After you've set up the function, use it! First, take your main Unlit graph from the previous step, and collapse it to a Sub Graph. Select the nodes, and right-click Convert to Sub-graph. Remove the last Add node, and plug the outputs into the output ports of the Sub Graph. We recommend that you also create input properties for Specular and Smoothness.

July 31, 2019 in Technology | 13 min. read