Unity Rendering Principle (15) Unity's Light Attenuation and Opaque Object Shadows

Let’s move on to the last part of Unity lighting, the decay

Light attenuation

In the previous blog, we mentioned that Unity uses a texture as a Look up table to calculate pixel-by-pixel light attenuation in the chip shader. The advantage of this is that calculating attenuation does not depend on the complexity of mathematical formulas, we just need to use a parameter value to sample the texture. However, using texture lookup to calculate attenuation also has some drawbacks:

  • Requires preprocessing to get the sampled texture, and the size of the texture also affects the accuracy of the attenuation
  • Inintuitive and inconvenient, so once the data is stored in the Look up table, we cannot use other mathematical formulas to calculate the attenuation

However, since this method can improve performance to a certain extent, and the results obtained are good in most cases, Unity uses this texture lookup method by default to calculate pixel-by-pixel point light and spotlight attenuation.

Textures for light attenuation

Unity internally uses a texture called _LightTexture0 to calculate the light source attenuation. It should be noted that if we use cookies for this light source, then the attenuation lookup texture is _LigthTextureB0, but this situation is not discussed here. We usually only care about the texture color values on the diagonal of the _LightTexture0, which indicate the attenuation value of points at different positions in the light source space, such as (0,0) point indicates the attenuation value of the point that coincides with the light source position, and (1,1) point indicates the attenuation of the farthest point of interest in the light source space.

In order to sample the _LightTexture0 texture to get the attenuation value of a given point to the light source, we first need to get the position of the point in the light source space, which is obtained by _LightMatrix0 transformation matrix. We only need to multiply the vertex coordinates in the _LightMatrix0 and world space. To get the corresponding position in the light source space:

1
flaot3 lightCoord = mul(_LightMatrix0, float4(i.worldPostion, 1)).xyz

Then we can use the square of the modulus of this coordinate to sample the attenuation texture and get the attenuation value

1
fixed atten = tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr).UNITY_ATTEN_CHANNEL

In the above code, we use the square of the vertex distance in the light source space to sample the texture. The reason why we do not use the distance value is because this method can avoid the square operation. Then we use the macro UNITY_ATTEN_CHANNEL to obtain the attenuation value in the attenuation texture. Component, get the final attenuation value.

Calculate attenuation using mathematical formulas

Although the texture sampling method can reduce the complexity of calculating the attenuation, sometimes we want to use the formula in the code to calculate the attenuation of the light source. For example, we can use the code to calculate the linear attenuation of the light source

1
2
float distance = length(_WorldSpaceLightPos0.xyz - i.worldPosition.xyz);
atten = 1.0 / distance;

However, Unity does not give relevant instructions for the built-in attenuation calculation. Although we can still use some mathematical formulas in the chip shader to calculate the attenuation, since we cannot get the range of the light source, the orientation of the spotlight, the opening angle and other information through the built-in variables in the Shader, the effect obtained is often somewhat Unsatisfactory, especially when the object leaves the lighting range of the light source, it will mutate (because the object is not in the range of the light source, Unity will not perform an Additional Pass for the object). Of course, we can use the script to pass the relevant information of the light source to the Shader, but the flexibility is relatively low.

Unity

In order to make the scene look more realistic and have depth information, we usually hope that the light source can cast shadows of some objects on other objects.

Let’s take a look at how to make an object cast shadows on other objects, and how to make an object receive shadows from other objects.

How is the shadow achieved?

We can first consider how shadows are generated in real life. When a ray of light emitted by a light source encounters an opaque object, the light cannot continue to illuminate other objects (regardless of reflection). So the object casts shadows on the objects next to it, and those shadow areas are generated because the light cannot reach those areas.

In real-time rendering, we most often use a technology called Shadow Map. This technology is relatively simple to understand. First, the position of the camera will be placed in a position coinciding with the light source, so the shaded area of the light source in the scene is the place that the camera cannot see. And Unity uses this technology.

In the forward rendering path, if the most important parallel light in the scene has shadows turned on, Unity will calculate its shadow map texture (shadowmap) for that light source. In this way, the shadow map texture is essentially a depth map, which records the closest surface position (depth information) in the scene that can be seen from the position of the light source.

So when calculating the shadow map texture, how do we determine the closest surface position to it? One method is to first place the camera at the light source position, and then follow the normal rendering process, that is, call Base Pass and Additional Pass to update the depth information and obtain the shadow map texture. But this method will cause a certain waste of performance, because we actually only need depth information, and Base Pass and Additional Pass often involve a lot of complex lighting model calculations.

Therefore, ** Unity chooses to use an additional Pass specifically to update the light source, this Pass is the Pass whose LightMode tag is set to ShadowCaster. The rendering target of this Pass is not for frame cache, but shadow map texture (depth texture) **. Unity first places the camera at the light source position, and then calls the Pass to obtain the position under the light source space by transforming the vertices, and accordingly outputs the depth information to the shadow map texture.

Therefore, ** when the shadow effect of the light source is turned on, the underlying rendering engine will first find the Pass with LightMode as ShadowCaster in the Unity Shader of the currently rendered object. If not, it will continue to search in the Unity Shader specified by Fallback. If still not found, the object cannot cast shadows on other objects ** (but it can still receive shadows from other objects). When a Pass with LightMode as ShdowCaster is found, Unity will use that Pass to update the shadow mapping texture of the light source.

In traditional shadow map texture line of sight, we transform the vertex position under the light source space in the normally rendered Pass to obtain its 3D position in the light source space. Then, we use the xy component to sample the shadow map texture to obtain depth information at that position in the shadow map texture. If the depth value is less than the depth value of the vertex (usually obtained by the z component), then the point is in shadow.

In Unity 5, Unity uses a different shadow sampling technique from this traditional one, Screenspace Shadow Map. This technique was originally a method for generating shadows in deferred rendering. It should be noted that not all platforms Unity will use this technique. This is because the technology requires the graphics card to support MRT, which some mobile platforms do not support.

When using screen space shadow mapping technology, Unity first obtains the shadow mapping texture of the light source that can cast shadows and the depth texture of the camera by calling LightMode to ShadowCaster’s Pass. Then the screen space shadow map is obtained based on the shadow mapping texture of the light source and the camera depth texture. If the surface depth recorded in the camera’s depth map is greater than the depth value in the converted shadow mapping texture, it means that the surface is visible, but in the shadow of the light source. In this way, the shadow map contains all hard and hard areas in screen space. If we want an object to accept shadows from other objects, we only need to sample the shadow map in Shader. Since the shadow map is in screen space, we first need to transform the surface coordinates from model space to screen space, and then use this coordinate to sample the shadow map

To sum up, an object receiving shadows from other objects and it casting shadows on other objects are two processes.

  • If we want an object to accept shadows from other objects, we must sample the shadow map texture (including the shadow map of screen space) in Shader, multiply the sampling result and the final lighting result to produce the shadow effect.
  • If we want an object to cast a shadow on other objects, we must add the object to the calculation of the shadow map texture of the light source, so that other objects can get the relevant information of the object when sampling the shadow map texture. In Unity, this process is achieved by executing a Pass with LightMode to ShadowCaster for the object. If screen-space projection mapping technology is used, Unity will also use this Pass to generate a camera depth texture

Shadows of opaque objects

We first create two planes, a cube, and then create a new material, the material Shader is ForwardRendering from our last blog, and assign the new material to the cube.

Make objects cast shadows

In Unity, we can choose whether to make an object cast or accept shadows. This is achieved by setting the Cast Shadows and Receive Shadows properties in the Mesh Render component.

Cast Shadows can be turned on or off. If the Cast Shadows property is enabled, Unity will add the object to the calculation of the shadow map texture of the light source, so that other objects can get information about the object when sampling the shadow map texture. As mentioned earlier, this process is achieved by executing a pass with LightMode to ShadowCaster for the object. Receive Shadows can choose whether to let the object accept shadows from other objects. If Receive Shadows is not enabled, then when we call Unity’s built-in macros and variables to calculate shadows, these macros will not calculate shadows for us internally by judging that the object does not have the function of accepting shadows enabled.

When we enable Cast Shadows on the cube and Receive Shadows on two planes, we can find that the cube produces shadows on the plane. This is because although our ForwardRendering does not have a Pass with LightMode as ShaderCaster, its Fallback is Specular, and the Fallback of Specular is VertexLit, which contains a Pass with LightMode as ShaderCaster in VertexLit.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Pass {
Name "ShadowCaster"
Tags { "LightMode" = "ShadowCaster" }

CFPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile_shadowcaster
#include "UnityCG.cginc"

struct v2f {
V2F_SHADOW_CASTER;
}

v2f vert (appdata _ base v) {
v2f o;
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o);
return o;
}

float4 frag(v2f i): SV_Target {
SHADOW_CASTER_FRAGMENT(i);
}
ENDCG
}

There are many macro definitions in this code, but the actual use is to write depth information into the rendering target.

One more thing to note here is that by default, we will remove the back of the object when calculating the shadow map texture of the light source. But for the built-in plane, it has only one face, and if the object does not have any frontface in the light source space when calculating the shadow map texture, it will not be added to the shadow map texture. We can set Cast

Make objects receive shadows

The reason why our plane can produce shadows is because of the use of built-in Standard Shader, and the built-in Shader of both performs operations related to receiving shadows.

In order for the cube to accept shadows, we need to modify our Shader code. We create a new Shader, name it Shadow, and then copy the ForwardRendering code to it and modify it

First you need to include a new built-in file in Base Pass

1
#include "AutoLight.cgnic"

This is because the macro definitions used when calculating shadows are all in this file

Then we need to add a built-in macro SHADOW_COORDS to our output architecture v2f.

1
2
3
4
5
6
struct v2f {
float4 pos:SV_POSITION;
float3 worldNormal: TEXCOORD0;
float3 worldPos: TEXCOORD1;
SHADOW_COORDS(2)
}

The purpose of this macro is very simple, just declare a coordinate for sampling the shadow texture. It should be noted that the parameter of this macro needs to be the index value of the next available interpolation register.

Then we add a built-in macro TRANSFER_SHADOW before the vertex shader returns.

1
2
3
4
5
6
7
v2f vert(a2v v) {
v2f o;
...
TRANSFER_SHADOW_CASTER_NORMALOFFSET(o);
...
return o;
}

This macro is used to calculate the shadow texture coordinates declared in the previous step in the vertex shader

Next, we calculate the shadow value in the chip shader, which also applies a macro definition SHADOW_ATTENUATION

1
fixed shadow = SHADOW_ATTENUATION(i);

SHADOW_COORDS, TRANSFER_SHADOW and SHADOW_ATTENUATION are the three Musketeers when calculating shadows. These built-in macros help us calculate shadows of light sources when necessary. We can find their declarations in AutoLight.cgnic

SHADOW_COORDS actually declares a shadow texture coordinate variable called _ShadowCoord. The implementation of TRANSFER_SHADOW will vary depending on the platform. If the current platform can use screen space shadow mapping technology (by determining whether UNITY_NO_SCREENSPACE_SHADOWS are defined), TRANSFER_SHADOW will call the built-in ComputeScreenPos function to calculate the _ShadowCoord. If the platform does not support screen space shadow mapping technology, traditional shadow mapping technology will be used. TRANSFER_SHADOW will transform the vertex coordinates from model space to light space and store them in the _ShadowCoord. SHADOW_ATTENUATION then responsible for sampling the relevant texture using _ShadowCoord to obtain shadow information.

It should be noted that these macros will use context variables for related calculations, such as TRANSFER_SHADOW will use v.vertex and a.pos to calculate coordinates, so in order to make these macros work correctly, we need to ensure that our custom variable names match the variable names used by these macros

Unified management of attenuation and shadows

We have already talked about how to calculate the light attenuation in the forward rendering path of Unity Shader before - in Base Pass, the attenuation factor of parallel light is always equal to 1, while in Additional Pass, we need to determine the type of light source processed by the Pass, and then use the built-in variables and macros to calculate the attenuation factor. In fact, the effect of light attenuation and shadow on the final rendering result of the object is essentially the same - we multiply the light attenuation factor and shadow value with the lighting result to get the final rendering result. So, is there a way to calculate both information at the same time?

Unity does provide such functionality in Shader, mainly through built-in UNITY_LIGHT_ATTENUATION macros

Let’s go back to the previous step of Shadow Shader copy and rename it to AttenuationAndShadowUseBuildInFunctions

Although the code in Shdow Shader allows us to get the correct shadow, in practice we usually use Unity’s built-in macros and functions to calculate decay and shadow, thus hiding some details.

First, it needs to be included in the required header file.

1
2
#include "Lighting.cgnic"
#include "AutoLight.cgnic"

Use macros to define SHADOW_COORD declare shadow coordinates in the v2f structure

1
2
3
4
5
6
struct v2f {
float4 pos : SV_Target;
float3 worldNormal : TEXCOORD0;
float3 worldPos : TEXCOORD1;
SHADOW_COORDS(2)
}

(3) Use built-in macro TRANSFER_SHADOW in vertex shader to calculate and pass shadow coordinates to chip shader

1
2
3
4
5
6
v2f vert(a2v v) {
v2f o;
...
TRANSDER_SHADOW(o);
return o;
}

(4) In the chip element shader, we use the built-in macro UNITY_LIGHT_ATTENUATION to calculate the light attenuation and shadow

1
2
3
4
5
fixed4 frag(v2f i) : SV_Target {
...
UNITY_LIGHT_ATTENUATION(attne, i, i.worldPos);
return fixed4(ambient + (diffuse + specular) * atten, 1.0);
}

UNITY_LIGHT_ATTENUATION is a built-in macro for calculating light attenuation and shadows. We can find its declaration in the built-in AutoLight.cgnic. It accepts three parameters and stores the result of multiplying the light attenuation and shadow values into the first parameter. Notice that we didn’t declare the first parameter, atten, because UNITY_LIGHT_ATTENUATION will declare this variable for me. The second parameter is the struct v2f, which will be passed to the SHADOW_ATTENUATION to calculate the shadow value. The third parameter is the coordinates of world space. This parameter will be used to calculate the coordinates in light space, and then sample the light attenuation texture to obtain the light attenuation.

Due to the use of UNITY_LIGHT_ATTENUATION, we have unified the code of Base Pass and Additional Pass. We do not need to handle shadows separately in Base Pass, nor do we need to determine the type of light source in Additional Pass to handle light attenuation. If we want to add shadow effects in Additional Pass, we need to use ‘#pragma multi_compile_fwdadd_fullshadows’ compile directive instead of ‘#pragma multi_compile_fwadd’ in Additional Pass