Creating Custom Lighting in Unity’s Shader Graph with Universal Render Pipeline
Hi, I’m Ned and I make games! In this tutorial, I show how to implement custom lighting in Unity’s shader graph for the Universal Render Pipeline. As of now, there are no nodes which expose light data or modify the result of the default PBR calculation. Thankfully, you can get around this and handle materials that don’t look quite right on a lit graph, like foliage or skin, or need stylized lighting, like a toon shader.
Tested Unity versions: Unity 2020.3 and Unity 2021.1.
This custom lighting implementation includes a simple lighting algorithm featuring diffuse and specular lighting. It supports lighting and shadows from the sun light and additional point and spot lights, baked lighting, light probes, and fog. Note: point light shadows are only available in Unity 2021.1 and later.
I’ll assume you know the basics of Unity, URP, and the shader graph. You should also understand simple HLSL (the shader programming language) and what control keywords are. Finally, although I’ll give a general overview of various lighting terms and techniques, I won’t explain them in detail.
Project set up
First, create a new project using the URP template or set your project up to use URP by creating a settings asset and assigning it in graphics settings. Create the following simple test scene:
Create a plane. Create a sphere floating above it. Create an unlit URP shader graph called “TestLighting.” Create a material. Assign it the TestLighting shader. Set it on both test objects. To enable easy reuse, create a shader subgraph called “CustomLighting.”
Let’s initialize the graphs to sample a texture and display it with no lighting.
Open CustomLighting. Create a float3 “Albedo” property. Change the output parameter to a float3 named “Out.” Route Albedo into Out.
Open TestLighting. Add a Texture2D “Albedo” property. Sample it. Add a CustomLighting node. Route the nodes together.
The bulk of the custom lighting algorithm must happen in a custom function node, which works best with a shader code HLSL file. You cannot create a .hlsl file directly through Unity, so open your asset folder in your operating system. Create a text file called CustomLighting and change the file extension to “.hlsl.” Open it in your script editor.
First, set up a guard keyword to prevent this file from being compiled twice. This file has three main parts: the first, CustomLightingData
, is a data structure containing all data needed for the lighting calculation. The second, CalculateCustomLighting
, contains and executes the lighting algorithm (right now, just returning albedo). The third, CalculateCustomLighting_float
is the custom function wrapper which the shader graph can call. Note the number precision suffix. This wrapper constructs the data struct and calls the main function. Separating the wrapper from the algorithm gives you better control over data flow and also enables easy reuse from non-graph shaders, if you ever have the need.
Return to the CustomLighting subgraph. Create a CustomFunction node. Set up the input and output to match the wrapper function in CustomLighting.hlsl. Set the name to “CalculateCustomLighting” (the number precision suffix is not required) and the source to your HLSL file. Reroute the graph through your custom function.
You may see some errors while setting the function up; however, if the preview box is black, you’re good to go. If it’s magenta, an error still exists. Double check your spelling — everything is also case-sensitive. When everything is working, your test scene will have flat shading.
Diffuse Lighting
Diffuse lighting refers to the soft light that illuminates the side of an object facing a light source. It’s calculated by taking the dot product of the surface’s normal vector and the light direction. Let’s add shape to our scene by calculating diffuse lighting for the main light.
Open CustomLighting.hlsl. Add a normal vector field to CustomLightingData
. Rewrite CalculateCustomLighting
to call GetMainLight
(from URP’s shader library), obtaining a struct containing the main light’s direction and color. Call a new CustomLightHandling
function.
This function computes the color resulting from the passed light. The radiance variable contains the light’s strength — right now purely its color. Calculate the diffuse dot product, then multiply the result with the surface albedo and radiance to compute the final color.
Finally, in the custom function wrapper, add a normal argument and set it in the struct.
Back in the CustomLighting graph, you’ll get an error about no function existing. Just add a float3 “normal” input to the custom function and move it above “albedo,” so the inputs match the wrapper function once again. Now, you’ll get an error about “GetMainLight” or the “Light” structure not existing. This occurs because the shader graph doesn’t include the URP lighting library when rendering the node preview windows.
To fix this, we need to exclude sections of code from the shadergraph previews. Add an if-not-defined block around the CustomLightHandling
function. Then in CalculateCustomLighting
, write this estimation of diffuse lighting for the preview windows, placing the old logic in the else block.
In your subgraph, the errors should disappear. Add a vector3 “normal” attribute and route it into the custom function.
In the TestLighting graph, you can route a normal vector node into the subgraph, or, if you want to use normal maps, a normal map sample transformed into world space. Either way, check out your shaded scene!
Specular Lighting
Specular lighting models the small highlight visible on a shiny surface. It’s calculated by taking the average of the view direction and light direction, and then, taking the dot product of this “half” vector and the surface’s normal vector. Using this specular value directly sometimes leads to highlights in dark areas. Multiply with the diffuse lighting value to avoid this artifact.
Open CustomLighting.hlsl. Add a view direction field to CustomLightingData
and the wrapper function. In CustomLightHandling
, calculate the specular lighting dot product. Then, multiply it with “diffuse” to calculate the final specular value. Finally, to add the specular component of lighting, add “diffuse” and “specular” together in the final color calculation.
In the CustomLighting subgraph, create a view direction node, set to world space. In certain Unity versions, view direction is not normalized; route it through a normalize node. Then, update and route into the custom function.
Looking at your scene now, it will be difficult to see any highlights. We’ve neglected smoothness, which controls how focused the specular highlight is.
Open CustomLighting.hlsl. Add a float field for smoothness in CustomLightingData
and update the custom function wrapper. In CustomLightHandling
, tighten the specular dot by taking it to a power related to smoothness, defined by GetSmoothnessPower
function. It transforms a value ranging from 0 to 1 on an exponential curve, but the math is ultimately arbitrary. You can edit it as needed. This results in a higher smoothness value shrinking the specular highlight.
Before moving on, add a specular approximation for the node preview windows to CalculateCustomLighting
.
Back in the CustomLighting subgraph, add a float smoothness property to the custom function and a corresponding graph property.
Then, in the TestLighting graph, add another smoothness property. For ease of editing, set it to slider mode, ranging from zero to one. Check out your handiwork!
Shadows
Shadows are an important part of lighting, and take a bit to set up. If you move an object using custom lighting over something with a default lit material, you’ll see it does cast a shadow already! However, our custom lit shader does not receive shadows. There are a few things we need to do to fix that.
First, the programming. Open CustomLighting.hlsl. Shadows, at least for the main light, are held in textures called shadow maps. The system needs a value called a shadow coordinate to accurately read them; as well as this fragment’s world position. Add both in CustomLightingData
— notice that the shadow coord is a float4.
In the custom function wrapper, only add a position argument and set the position in the structure. We can calculate the shadow coord from this position. Set up another preview keyword block. In the preview side, set the coord to zero. In the else side, calculate this fragment’s clip space position (which is related to the pixel it displays in on screen). Depending on your shadow settings, Unity might store shadow maps in different layouts, needing different coordinates. This keyword allows you to calculate the correct value.
In CalculateCustomLighting
, when calling GetMainLight
, pass the shadow coord and position. The third argument is something called a shadow mask, which we’ll come back to later. This let’s Unity set a field in the Light
structure called shadowAttenuation
, which is a multiplier to this light’s radiance due to shadows. In CustomLightHandling
, multiply it with the light color when calculating radiance.
In the CustomLighting subgraph, add a position argument to the custom function and route a position node in world space into it. In the scene, you’ll notice your materials still receive no shadows. This occurs because, to save resources, Unity does not sample shadow maps unless you explicitly tell it to by using various shader keywords.
Return to the CustomLighting subgraph. Add a boolean keyword (the option is under the new property menu). The name isn’t important, but the reference must be _MAIN_LIGHT_SHADOWS
exactly — underscores and capitals matter! Set the definition to “multi compile,” the scope to “global,” and the default to on.
Now in the scene, you’ll get an error about shadowcoords not existing. To fix this, open CustomLighting.hlsl and add this little snippet. Discovered by Cyanilux, it removes some unnecessary code from shaders generated by the shader graph, avoiding the error.
At this point, if you don’t see any shadows, make sure they’re enabled in your URP pipeline settings and on your camera (and if not using a fresh project, that cascade count is one).
To make them look better, add two more boolean keywords to CustomLighting, enabling shadow cascades and soft shadows. Keep the settings the same, but set the references to _MAIN_LIGHT_SHADOWS_CASCADE
and _SHADOWS_SOFT
. Make sure cascades and soft shadows are also enabled in your settings asset.
More Lights
There’s only so much you can do with a single light, so let’s add support for point and spot lights. We’ve got the framework ready, so it won’t be too difficult.
Open CustomLighting.hlsl. Unity stores additional light data separately from the main light in a buffer, which we need to loop through. This is the primary reason why I prefer implementing custom lighting all in one custom function, since loops are impossible in the shader graph. Anyway, Unity defines a keyword if this object is affected my additional lights, so create an if-def block for it.
Inside, call GetAdditionalLightsCount
to get the number of additional lights, then loop through each. GetAdditionalLight
returns a Light data structure for the specified light. It works just like GetMainLight
, except it doesn’t require a shadow coord — that’s only for main light shadows. Send the light data to CustomLightHandling
and add the returned color to the final color.
Unlike the main light, point and spot lights’ strength varies based on position. This is encapsulated by the distanceAttenuation
field in the Light data structure. In CustomLightHandling
, multiply it into the radiance calculation. distanceAttenuation
is always 1 for the main light.
Back in the CustomLighting subgraph, add two more boolean keywords with the references _ADDITIONAL_LIGHTS
and _ADDITIONAL_LIGHT_SHADOWS
. Again, set the definition to “multi compile,” the scope to “global,” and the default to on. Then, select your URP settings asset and set additional lights to per pixel and enable cast shadows.
Create some lights to test things out! Make sure all lights are in realtime mode and that the shadow type is not “no shadows.” Turn up the intensity so things are really plain to see. Note that point light shadows are only available in Unity 2021.1 and later.
Global Illumination
Global illumination is a broad concept including baked lightmaps, light probes, global reflections, shadow masks, and spherical harmonics. We can implement them all in one fell swoop! I won’t spend much time explaining how these various techniques work or how to use them — each deserves its own tutorial. Just know they try to add a lot of inexpensive lighting to a scene.
In CustomLighting.hlsl, add two new fields into CustomLightingData
: ambientOcclusion
, which controls how much global lighting this fragment should receive, and bakedGI
, which is the baked lighting color for this fragment.
Moving down to the wrapper function, add an argument for ambient occlusion and set it on the struct. As for baked GI, we’ll calculate it using a few special functions and macros. They require the lightmap UV, which we’ll receive as an additional argument.
In the not-preview block below, call OUTPUT_LIGHTMAP_UV
to get the final lightmap UV using the lightmap scale Unity provides. OUTPUT_SH
calculates the spherical harmonics for this fragment, which encode the color of the nearest light probes. Finally, resolve everything into a single baked global illumination value with SAMPLE_GI
. It samples the lightmap texture, if available.
To add global light sources into our lighting algorithm, write a CustomGlobalIllumination
function inside the preview-not-defined block. URP did the hard work in the custom function wrapper, so just multiply the albedo, bakedGI, and occlusion values together to calculate indirect diffuse. Return it.
In CalculateCustomLighting
, initialize the color variable with CustomGlobalIllumination
. But before that, we need to correct the global GI value. Under some circumstances, the main light’s shading contribution is also baked into global GI. We don’t want to include it twice, so call this URP function, MixRealtimeAndBakedGI
, to remove the main light from the globalGI value, if needed.
Open your subgraph and create arguments for ambient occlusion and lightmap UV. Add a new float property for occlusion and hook it into the custom function. The lightmap UV is usually automatically stored by Unity in the second set of UVs, which is UV1. Route a UV node, in UV1 mode, into the lightmap UV field. Finally, add one more boolean keyword with LIGHTMAP_SHADOW_MIXING
as the reference, which enables mixed lighting.
In the TestLighting graph, create an ambient occlusion property. It should be a slider ranging from zero to one. You can, of course, use an occlusion map if you’d prefer!
Now test things out in your scene. Create a few new lights and set them to baked mode. Set your ground plane GameObject
to static so it will receive lightmaps. Create a light probe group around your floating sphere. To make things easier to see, have differently colored baked lights appear on opposite sides of the probes. Once ready, save your project and navigate to the lighting panel (Window -> Rendering -> Lighting). Select generate lighting and wait for the lightmapper to finish its work.
First, check that baked lighting appears on your plane. If you disable realtime lights, you should still see some lighting where the baked lights were. Then, test light probes by moving your sphere around. Again, it might be difficult to notice at first, so turn off realtime lights and look for slightly different coloring on your sphere.
Moving on to baked reflections. Unity creates a reflection cubemap based on the skybox, and it’s very easy to get a hold of.
In CustomLighting.hlsl, add these lines to CustomGlobalIllumination
. We first need to sample the cubemap by calculating the sample normal. Visualize yourself standing at its center and looking at the pixel to sample — that’s the sample normal. In this situation, modeling a mirror, we calculate it by reflecting the view direction around the normal vector.
I found that reflections look nice around the edge of an object, an effect known as a rim or Fresnel light. It’s calculated by finding the dot product of the normal and view direction, subtracting it from one, and tightening it with an exponent (4 in this case).
Call GlossyEnvironmentReflections
to sample the cubemap, passing the sample normal, a roughness value, and the occlusion. Roughness is one minus smoothness, and affects how blurry the reflections are. I’m repurposing this function from URP’s PBR rendering code, which expects something called “perceptual roughness.” Luckily, we can convert to that using RoughnessToPerceptualRoughness
. Finally, multiply by fresnel
and add the indirect specular result to the final calculation.
That’s it! If the effect is too strong, you can reduce reflection strength in the lighting window.
Finally, let’s add baked shadows. These are lightweight, precalculated shadows for the main light baked into a texture, like lightmaps. They’re always used for far away objects, but you can turn them on for all objects in the quality settings.
Open CustomLighting.hlsl. It’s time to tackle that shadowmask value I spoke about earlier. Add a float4
field in CustomLightingData
. In the wrapper function, set the shadow mask to zero in the preview block, and call SAMPLE_SHADOWMASK
at the end of the not-preview block. Then, in CalculateCustomLighting
, pass the new shadowmask value as the third argument to GetMainLight
and GetAdditionalLight
.
In your CustomLighting subgraph, add one more boolean keyword with SHADOWS_SHADOWMASK
as the reference and the same settings as always.
To test shadowmasks, add a large, static object above your plane, set your main light to mixed mode, and bake lighting again. Go to your quality settings and change the Shadow Mask mode to “Shadowmask.” You should see shadows on all static objects, like your ground plane. Note that light probes received baked shadows, so that will affect the lighting of dynamic objects.
Before moving on, change the shadowmask mode back to distance shadowmask. Now, baked shadows are only used for distant objects.
Fog
Fog is the final feature I will add in this tutorial, and it’s pretty simple. It’s a method to fade distant objects into a flat color, to help avoid pop-in.
In your hlsl file, add a fogFactor
field to the data struct, which URP will use to calculate the fog strength. We’ll set it in the function wrapper. Set fogFactor
to zero in the preview block. Otherwise, call ComputeFogFactor
, which uses this fragment’s the clip space z-position, related to it’s depth from the camera.
In CalculateCustomLighting
, call MixFog
. This URP function applies fog to the final color, taking care of all fog modes for us.
Return to the scene view and test things out by turning on fog in the lighting window. Try each mode and adjust the settings. MixFog
can handle them all!
Upcoming Changes
In version 2021.2, Unity is poised to release many new features to URP and the Shader Graph which will affect this project. The biggest change is the addition of custom vertex interpolators, which will allow us to support vertex lights and more efficient global illumination. They’re also adding light cookies, reflection probe blending, light layers, screen space shadows and ambient occlusion (for unlit graphs), and deferred rendering. Needless to say, expect a follow up tutorial after 2021.2 releases.
Wrap Up
Thank you so much for reading! Now you have the convenience of the Shader Graph and complete control over how your game looks. Use these techniques well and your project will really stand out! I’m currently working on a tutorial building on this framework to create a grass, leaf, and general foliage shader. Please keep an eye out for it!
Here is the final version of the script, for cross referencing.
If you have any questions, feel free to contact me at any of the links at the bottom of this article.
If you want to see this tutorial from another angle, I created a video version you can watch here: https://www.youtube.com/watch?v=GQyCPaThQnA
Try out my Interactive Lighting Math Explorer to visualize how diffuse, specular, and other lighting calculations work: https://nedmakesgames.itch.io/lighting-explorer
I want to take a moment to thank David Cru for his support, and all my patrons for making this tutorial possible: Adam R. Vierra, Aleksandr Molchanov, Alina Matson, Alvaro LOGOTOMIA, Antonio Ripa, anzhony manrique, Ben Luker, Ben Wander, BM, Bohemian Grape, Cameron Horst, Candemir, ChainsawFilms, Chris, Christopher Ellis, Connor Wendt, Danny Hayes, darkkittenfire, David Cru, Evan Malmud, Eren Aydin, FABG Team, Georg Schmidbauer, hyunsookim, Isobel Shasha, Jerzy Gab, JP Lee, jpzz kim, Karthick Gunasekaran, Kyle Harrison, Leafenzo, Lhong Lhi, Luke Hopkins, Mad Science, Mark Davies, masahito nagasaka, Matt Anderson, maxo, Mike Young, NG, Oskar Kogut, Pat, Patrik Bergsten, phanurak rubpol, Qi Zhang, Quentin Arragon, rafael ludescher, Sam Slater, Sebastian Cai, SlapChop, starbi, Steph, Stephen Sandlin, Tvoyager, Voids Adrift, Will Tallent, Winberry, 성진 김
If you would like to download a completed version of this tutorial and all the files featured here, consider joining my Patreon page. You will also get early access to tutorials, voting power in topic polls, and more. Thank you!
Thanks so much for reading, and make games!
🔗 Tutorial list website ▶️ YouTube 🔴 Twitch 🐦 Twitter 🎮 Discord 📸 Instagram 👽 Reddit 🎶 TikTok 👑 Patreon 📧 E-mail: nedmakesgames gmail
Credits, references, and special thanks:
- Cyanilux: URP_ShaderGraphCustomLighting
- Ida Faber: Lowpoly Shiba Inu Dog
- XfrogPlants: Curry Leaf Tree Free 3D model
- Lennart Demes: Grass 001
- Baptiste Manteau: Bark old ginko
- Render Knight: Fantasy Skybox FREE
All code appearing in GitHub Gist embeds is Copyright 2021 NedMakesGames, licensed under the MIT License.