This is the eighth installment of a tutorial series covering Unity's scriptable render pipeline. It's about supporting both static and dynamic global illumination.

This tutorial is made with Unity 2018.3.0f2.

There were two bugs in previous tutorials that you'll have to fix if you did them a while ago. First, when rendering cascading shadows only, the squares shadow distance must also be set.

Light Maps

Realtime lighting only deals with direct light. Only surfaces that are directly exposed to a light are brightened by it. What's missing is the indirect light, caused by light traveling from surface to surface, and finally to the camera. This is also known as global illumination. We can add this light in Unity by baking it into light maps. The Rendering 16, Static Lighting tutorial covers the basics of baking light in Unity, but for the legacy pipeline with the Enlighten lightmapper only.

Setting the Scene It's easiest to see that there is no indirect light by having only a single directional light in the scene. All shadowed areas will be nearby black. Scene with realtime lighting only. Some very big shadows make this more obvious. Large shadows. We can still see the objects inside the shadows because specular environment reflections are added to the direct lighting. If there are no reflection probes in use then we'll see the skybox reflected, which is bright. Eliminate the contribution of the skybox by lowering its Intensity Multiplier to zero. That will make all shadowed areas completely black. Black environment.

Baking Light Baking indirect light is done by enabling Baked Global Illumination under Mixed Lighting in the scene lighting settings and selecting Baked Indirect for its Lighting Mode. That will make Unity bake lighting, although we won't see it yet. Baking indirect lighting. I'll use the default Lightmapping Settings, with a few changes. The default is to use the progressive lightmapper, which I'll keep. Because I have a small scene I increased the Lightmap Resolution from 10 to 20. I also disabled Compress Lightmaps to get the best quality, skipping the map compression step. Also, change the Directional Mode to Non-Directional, because that only makes sense when using normal maps, which we don't. Lightmapping settings. Baked lighting is static, so cannot change while in play mode. Only game objects that are marked as lightmap-static will have their indirect light contribution baked. It's quickest to just mark all geometry as completely static. Static game object. While baking, Unity might complain about overlapping UVs. That can happen when an object's UV unwrap ends up too small in the light map, which causes the light information to overlap. You can tweak and object's scale in the lightmap, by adjusting its Scale in Lightmap factor. Also, for objects like the default sphere enabling Stitch Seams will improve the baked light. Scale in lightmap and seam stitching. Finally, to bake the contribution of the main light, set its Mode to Mixed. That means it will be used for realtime lighting, while its indirect light will also be baked. Mixed light mode. After baking is complete, you can inspect the maps via the Baked Lightmaps tab of the Lighting window. You can end up with multiple maps, depending on the map size and how much space is required to bake all static geometry. Two light maps.

Sampling the Light Map To sample the light map we need to instruct Unity to make the maps available to our shader and include the lightmap UV coordinates in the vertex data. That's done by enabling the RendererConfiguration.PerObjectLightmaps flag in MyPipeline.Render , just like we enabled the reflection probes. drawSettings.rendererConfiguration |= RendererConfiguration.PerObjectReflectionProbes | RendererConfiguration.PerObjectLightmaps ; When an object with a light map gets rendered, Unity will now provide the required data and will also pick a shader variant for the LIGHTMAP_ON keyword. So we have to add a multi-compile directive for it to our shader. #pragma multi_compile _ _SHADOWS_SOFT #pragma multi_compile _ LIGHTMAP_ON The light map is made available via unity_Lightmap and its accompanying sampler state, so add those to Lit.hlsl. TEXTURE2D(unity_Lightmap); SAMPLER(samplerunity_Lightmap); The lightmap coordinates are provided via the second UV channel, so add then to VertexInput . struct VertexInput { float4 pos : POSITION; float3 normal : NORMAL; float2 uv : TEXCOORD0; float2 lightmapUV : TEXCOORD1; UNITY_VERTEX_INPUT_INSTANCE_ID }; We have to add them to VertexOutput as well, but that's only needed when a light map is used. struct VertexOutput { float4 clipPos : SV_POSITION; float3 normal : TEXCOORD0; float3 worldPos : TEXCOORD1; float3 vertexLighting : TEXCOORD2; float2 uv : TEXCOORD3; #if defined(LIGHTMAP_ON) float2 lightmapUV : TEXCOORD4; #endif UNITY_VERTEX_INPUT_INSTANCE_ID }; Light maps also have a scale and offset, but they don't apply to the map in its entirety. Instead, they're used to tell where in the light map an object's UV unwrap is located. It's defined as unity_LightmapST as part of the UnityPerDraw buffer. Because it doesn't match the naming convention expected by TRANSFORM_TEX , we have to transform the coordinates ourselves in LitPassVertex , if needed. CBUFFER_START(UnityPerDraw) … float4 unity_LightmapST; CBUFFER_END … VertexOutput LitPassVertex (VertexInput input) { … output.uv = TRANSFORM_TEX(input.uv, _MainTex); #if defined(LIGHTMAP_ON) output.lightmapUV = input.lightmapUV * unity_LightmapST.xy + unity_LightmapST.zw; #endif return output; } Can objects that use light maps be instanced? unity_LightmapST is set per draw, however it gets overruled by a macro definition when we include UnityInstancing, if instancing is enabled. So GPU instancing works with light mapping, but only for objects that end up sampling from the same light map. Be aware that static batching will override instancing, but only in play mode. This happens when objects are marked as batching-static and static batching is enabled in the player settings. Let's create a separate SampleLightmap function that samples the light map, given some UV coordinates. In it, we'll forward the invocation to the SampleSingleLightmap function defined in the Core EntityLighting file. We have to provide it the map, sampler state, and coordinates. The first two have to be passed via the TEXTURE2D_PARAM macro. float3 SampleLightmap (float2 uv) { return SampleSingleLightmap( TEXTURE2D_PARAM(unity_Lightmap, samplerunity_Lightmap), uv ); } Shouldn't that be TEXTURE2D_ARGS ? That would make more sense, but the macros are defined the other way around, at least in the experimental version that we're using in Unity 2018.3. They've been swapped in future versions. SampleSingleLightmap needs a few more arguments. The next is a scale-offset transformation for the UV coordinates. But we already did that in the vertex program, so here we'll supply an identity transformation. return SampleSingleLightmap( TEXTURE2D_PARAM(unity_Lightmap, samplerunity_Lightmap), uv , float4(1, 1, 0, 0) ); After that comes a boolean to indicate whether the data in the light map needs to be decoded. This depends on the target platform. If Unity uses full HDR light maps then this isn't necessary, which is the case when UNITY_LIGHTMAP_FULL_HDR is defined. return SampleSingleLightmap( TEXTURE2D_PARAM(unity_Lightmap, samplerunity_Lightmap), uv, float4(1, 1, 0, 0), #if defined(UNITY_LIGHTMAP_FULL_HDR) false #else true #endif ); Finally, we need to provide decoding instructions to bring the lighting in the correct range. We need to use float4(LIGHTMAP_HDR_MULTIPLIER, LIGHTMAP_HDR_EXPONENT, 0.0, 0.0) for that. return SampleSingleLightmap( TEXTURE2D_PARAM(unity_Lightmap, samplerunity_Lightmap), uv, float4(1, 1, 0, 0), #if defined(UNITY_LIGHTMAP_FULL_HDR) false , #else true , #endif float4(LIGHTMAP_HDR_MULTIPLIER, LIGHTMAP_HDR_EXPONENT, 0.0, 0.0) ); We sample the light map because we want to add global illumination. So let's create a GlobalIllumination function for that, which takes care of the details. Give it a VertexOutput parameter, which means that it needs to be defined after that struct. If there is a light map, sample it, otherwise return zero. struct VertexOutput { … }; float3 GlobalIllumination (VertexOutput input) { #if defined(LIGHTMAP_ON) return SampleLightmap(input.lightmapUV); #endif return 0; } Invoke this function at the end of LitPassFragment , initially replacing all other lighting so we can see it in isolation. float4 LitPassFragment ( VertexOutput input, FRONT_FACE_TYPE isFrontFace : FRONT_FACE_SEMANTIC ) : SV_TARGET { … color += ReflectEnvironment(surface, SampleEnvironment(surface)); color = GlobalIllumination(input); return float4(color, albedoAlpha.a); } Only global illumination.

Transparent Surfaces The results should look mostly soft, but discontinuity artifacts can appear near transparent surfaces, especially for fade materials. The progressive lightmapper uses the material's render queue to detect transparency, and relies on the _Cutoff shader property for clipped materials. So that works, but it has trouble with exposed back faces. Double-sided geometry can also cause trouble when the front and back faces overlap, which is the case for the double-sided geometry that we generated ourselves. Transparency artifacts. The problem is that lightmapping only applies to front faces. Back faces cannot contain data. Rendering back faces works, but they end up using the light data from the front face. The artifacts appear because the lightmapper hits back faces when sampling, which produce no valid light information. You can mitigate this problem by assigning custom Lightmap Parameters to objects that end up with artifacts, and lowering the Backface Tolerance threshold so the lightmapper accepts more missing data and smoothes it out.

Tolerant lightmapping.

Combining Direct and Indirect Light Now that we know that global illumination works, add it to the direct light. As the indirect light is diffuse only, multiply it with the surface's diffuse property. color += GlobalIllumination(input) * surface.diffuse ; Direct and global illumination. The result is brighter than without global illumination, which is expected. However, the scene is now quite a lot brighter than before. That's because the skybox is factored into global illumination. Let's only add the indirect light of the single directional light so we can better examine it, by reducing the intensity of the environment lighting to zero. Black environment.

Only Baked Lighting It is also possible to set the Mode of our light to Baked. That means it no longer is a realtime light. Instead, both its direct and indirect light is baked into the light map. In our case, we end up with a scene without any realtime lighting. It also eliminates all specular lighting and softens shadows.

Fully baked light.