学实时渲染不了解全局光照,就像看四大名著不看红楼梦……

这次笔记没什么美术含量,算是一个代码解析,如果有错误的地方欢迎勘误。

UnityURPshader光照部分学习分析(中)-全局光照和阴影雾效

GI部分

在理解这些之前希望大伙先去看看CatlikeCoding的SRP部分的Baked Light这一章,非常有助于理解URP的全局光照。

EnvironmentSpecular

大部分的GI代码在Lighting.hlsl里,但是我们发现BRDF.hlsl里也有几个关于环境光的函数,

代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

// Computes the specular term for EnvironmentBRDF
half3 EnvironmentBRDFSpecular(BRDFData brdfData, half fresnelTerm)
{
float surfaceReduction = 1.0 / (brdfData.roughness2 + 1.0);
return half3(surfaceReduction * lerp(brdfData.specular, brdfData.grazingTerm, fresnelTerm));
}

half3 EnvironmentBRDF(BRDFData brdfData, half3 indirectDiffuse, half3 indirectSpecular, half fresnelTerm)
{
half3 c = indirectDiffuse * brdfData.diffuse;
c += indirectSpecular * EnvironmentBRDFSpecular(brdfData, fresnelTerm);
return c;
}

// Environment BRDF without diffuse for clear coat
half3 EnvironmentBRDFClearCoat(BRDFData brdfData, half clearCoatMask, half3 indirectSpecular, half fresnelTerm)
{
float surfaceReduction = 1.0 / (brdfData.roughness2 + 1.0);
return indirectSpecular * EnvironmentBRDFSpecular(brdfData, fresnelTerm) * clearCoatMask;
}

代码看着还是不如公式方便,我们把公式写下来。

首先,在BRDFData中specular是直接输入的数值,grazingTerm是saturate(smoothness+reflectivity),而reflectivity也是直接输入的物理量。

公式

大写字母都是brdfData中的参数或者函数参数,S是specular,G是grazingTerm,D是diffuse,而F是输入的菲涅尔项,之后的公式都会以这种方式表示。

而这个函数调用的位置是在GlobalIllumination.hlsl文件里,接下来开始看看这个文件。

GlobalIllumination.hlsl

先不看这个文件本身,而是去看它引用的三个文件:EntityLighting.hlsl、ImageBasedLighting.hlsl、ReatimeLights.hlsl,光看名字就知道是非常有用(难啃)的文件。

EntityLighting.hlsl

看过或者敲过CatlikeCoding教程的全局光照部分的朋友应该比较熟悉这个文件,里面放的是采样烘焙贴图Lightmap以及球谐函数采样光照探针的函数方法,有很多变体,包括HDR、DoubleLDR、环境吸收的采样,但是如果看过catlike的话这些都比较熟悉了,这里就不深入讨论下去。

ImageBasedLighting.hlsl

正如其名,这个文件是用来处理IBL的,urp有一套自己的IBL方法,文件里有一些转换粗糙度的函数,其他函数大部分可以找到引用的原文,我自己也没完全看完,就不记录了,怕误导到别人。

ReatimeLights.hlsl

这个文件里装的都是一些大家比较熟悉的函数,诸如GetMainLight()、GetAdditionalLight()、各种attenuation之类的,主要是提供一些访问实时光源的接口。

回到Globalillumination.hlsl

这个文件的大部分函数所使用的函数都在前面三个文件里了,没有的也都是一些有印象的词,比如unity_specCube0这样的全局变量。

代码
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

half3 SubtractDirectMainLightFromLightmap(Light mainLight, half3 normalWS, half3 bakedGI)
{
// Let's try to make realtime shadows work on a surface, which already contains
// baked lighting and shadowing from the main sun light.
// Summary:
// 1) Calculate possible value in the shadow by subtracting estimated light contribution from the places occluded by realtime shadow:
// a) preserves other baked lights and light bounces
// b) eliminates shadows on the geometry facing away from the light
// 2) Clamp against user defined ShadowColor.
// 3) Pick original lightmap value, if it is the darkest one.


// 1) Gives good estimate of illumination as if light would've been shadowed during the bake.
// We only subtract the main direction light. This is accounted in the contribution term below.
half shadowStrength = GetMainLightShadowStrength();
half contributionTerm = saturate(dot(mainLight.direction, normalWS));
half3 lambert = mainLight.color * contributionTerm;
half3 estimatedLightContributionMaskedByInverseOfShadow = lambert * (1.0 - mainLight.shadowAttenuation);
half3 subtractedLightmap = bakedGI - estimatedLightContributionMaskedByInverseOfShadow;

// 2) Allows user to define overall ambient of the scene and control situation when realtime shadow becomes too dark.
half3 realtimeShadow = max(subtractedLightmap, _SubtractiveShadowColor.xyz);
realtimeShadow = lerp(bakedGI, realtimeShadow, shadowStrength);

// 3) Pick darkest color
return min(bakedGI, realtimeShadow);
}
...
half3 GlobalIllumination(BRDFData brdfData, half3 bakedGI, half occlusion, half3 normalWS, half3 viewDirectionWS)
{
const BRDFData noClearCoat = (BRDFData)0;
return GlobalIllumination(brdfData, noClearCoat, 0.0, bakedGI, occlusion, normalWS, viewDirectionWS);
}

void MixRealtimeAndBakedGI(inout Light light, half3 normalWS, inout half3 bakedGI)
{
#if defined(LIGHTMAP_ON) && defined(_MIXED_LIGHTING_SUBTRACTIVE)
bakedGI = SubtractDirectMainLightFromLightmap(light, normalWS, bakedGI);
#endif
}

// Backwards compatibility
void MixRealtimeAndBakedGI(inout Light light, half3 normalWS, inout half3 bakedGI, half4 shadowMask)
{
MixRealtimeAndBakedGI(light, normalWS, bakedGI);
}

void MixRealtimeAndBakedGI(inout Light light, half3 normalWS, inout half3 bakedGI, AmbientOcclusionFactor aoFactor)
{
if (IsLightingFeatureEnabled(DEBUGLIGHTINGFEATUREFLAGS_AMBIENT_OCCLUSION))
{
bakedGI *= aoFactor.indirectAmbientOcclusion;
}

MixRealtimeAndBakedGI(light, normalWS, bakedGI);
}

MixRealtimeAndBakeGI()这段函数做了什么,在SubtractDirectMainLightFromLightmap()的注释里其实说清楚了,简单来讲它是用来处理全局光照下的实时阴影的,作用是从lightmap中减掉直接光的影响。

代码
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
26
27
28
29
30
31
32
33
34

half3 GlobalIllumination(BRDFData brdfData, BRDFData brdfDataClearCoat, float clearCoatMask,
half3 bakedGI, half occlusion, float3 positionWS,
half3 normalWS, half3 viewDirectionWS)
{
half3 reflectVector = reflect(-viewDirectionWS, normalWS);
half NoV = saturate(dot(normalWS, viewDirectionWS));
half fresnelTerm = Pow4(1.0 - NoV);

half3 indirectDiffuse = bakedGI;
half3 indirectSpecular = GlossyEnvironmentReflection(reflectVector, positionWS, brdfData.perceptualRoughness, 1.0h);

half3 color = EnvironmentBRDF(brdfData, indirectDiffuse, indirectSpecular, fresnelTerm);

if (IsOnlyAOLightingFeatureEnabled())
{
color = half3(1,1,1); // "Base white" for AO debug lighting mode
}

#if defined(_CLEARCOAT) || defined(_CLEARCOATMAP)
half3 coatIndirectSpecular = GlossyEnvironmentReflection(reflectVector, positionWS, brdfDataClearCoat.perceptualRoughness, 1.0h);
// TODO: "grazing term" causes problems on full roughness
half3 coatColor = EnvironmentBRDFClearCoat(brdfDataClearCoat, clearCoatMask, coatIndirectSpecular, fresnelTerm);

// Blend with base layer using khronos glTF recommended way using NoV
// Smooth surface & "ambiguous" lighting
// NOTE: fresnelTerm (above) is pow4 instead of pow5, but should be ok as blend weight.
half coatFresnel = kDielectricSpec.x + kDielectricSpec.a * fresnelTerm;
return (color * (1.0 - coatFresnel * clearCoatMask) + coatColor) * occlusion;
#else
return color * occlusion;
#endif
}

而GlobalIllumination()这个函数里做的才是我们一般情况下做的环境反射,看到这里我比较疑惑,因为这个函数里并不包含上面的SampleSH()系列的函数,而是只去采样了环境贴图和原来BRDF.hlsl里面的EnvironmentBRDF(),我的想法是或许在Unity的全局光照思路里,环境反射、球谐光照和lightmap都是分开的?回头去看一看litshader或许能够解答这个问题。

回到UniversalFragmentPBR

代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

// Clear-coat calculation...
BRDFData brdfDataClearCoat = CreateClearCoatBRDFData(surfaceData, brdfData);
half4 shadowMask = CalculateShadowMask(inputData);
AmbientOcclusionFactor aoFactor = CreateAmbientOcclusionFactor(inputData, surfaceData);
uint meshRenderingLayers = GetMeshRenderingLightLayer();
Light mainLight = GetMainLight(inputData, shadowMask, aoFactor);

// NOTE: We don't apply AO to the GI here because it's done in the lighting calculation below...
MixRealtimeAndBakedGI(mainLight, inputData.normalWS, inputData.bakedGI);

LightingData lightingData = CreateLightingData(inputData, surfaceData);

lightingData.giColor = GlobalIllumination(brdfData, brdfDataClearCoat, surfaceData.clearCoatMask,
inputData.bakedGI, aoFactor.indirectAmbientOcclusion, inputData.positionWS,
inputData.normalWS, inputData.viewDirectionWS);

直接看到这一段,它创建了LightingData,调用了我们上文提到的MixRealtimeAndBakedGI()和GlobalIllumination(),而球谐的调用在InitializeInputData()里。

代码
1
2
3
4
5
6
7

#if defined(DYNAMICLIGHTMAP_ON)
inputData.bakedGI = SAMPLE_GI(input.staticLightmapUV, input.dynamicLightmapUV, input.vertexSH, inputData.normalWS);
#else
inputData.bakedGI = SAMPLE_GI(input.staticLightmapUV, input.vertexSH, inputData.normalWS);
#endif

这里的SAMPLE_GI正是GlobalIllumination.hlsl中的一个宏,根据不同的宏设置来选择采样的全局光照,当DYNAMICLIGHTMAP_ON和LIGHTMAP_ON都被关闭的时候会去采样球谐光照。

代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14

// We either sample GI from baked lightmap or from probes.
// If lightmap: sampleData.xy = lightmapUV
// If probe: sampleData.xyz = L2 SH terms
#if defined(LIGHTMAP_ON) && defined(DYNAMICLIGHTMAP_ON)
#define SAMPLE_GI(staticLmName, dynamicLmName, shName, normalWSName) SampleLightmap(staticLmName, dynamicLmName, normalWSName)
#elif defined(DYNAMICLIGHTMAP_ON)
#define SAMPLE_GI(staticLmName, dynamicLmName, shName, normalWSName) SampleLightmap(0, dynamicLmName, normalWSName)
#elif defined(LIGHTMAP_ON)
#define SAMPLE_GI(staticLmName, shName, normalWSName) SampleLightmap(staticLmName, 0, normalWSName)
#else
#define SAMPLE_GI(staticLmName, shName, normalWSName) SampleSHPixel(shName, normalWSName)
#endif

阶段总结-GI

到这里总算是把全局光照的所有接口顺序捋清楚了。简单来讲,unity的光照分为三个部分:直接光照、间接光照、环境反射,直接光照在上回中捋过了,间接光照是通过GlobalIllumination.hlsl中的宏去敲定的,底层在EntityLighting.hlsl中,要么是采样LightMap要么是采样光照探针(不理解这里的可以去看CatlikeCoding全局光照那章),而环境反射是在同一个文件里面直接进行的。

在LitForwardPass中直接光照和间接光照的参数是在InitializeStandardLitSurfaceData()和InitializeInputData()的时候就已经初始化好的,后者直接把间接光照算到InputData的bakedGI里了(个人理解上间接光照的计算并不属于BRDF的范畴所以没有在之后放到BRDFData里一起去算),然后直接把所有数据放到UniversalFragmentPBR里得到颜色,这个过程在某个步骤里计算了shadow和fog。

阴影和雾

上回说到在某个步骤里URP计算了shadow和fog对颜色造成的影响,那么是在哪里呢?按照上面的思路顺藤摸瓜就能发现,很显然也是在InitializeInputData()这个函数里进行的,就在计算GI项的上面。

代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

#if defined(REQUIRES_VERTEX_SHADOW_COORD_INTERPOLATOR)
inputData.shadowCoord = input.shadowCoord;
#elif defined(MAIN_LIGHT_CALCULATE_SHADOWS)
inputData.shadowCoord = TransformWorldToShadowCoord(inputData.positionWS);
#else
inputData.shadowCoord = float4(0, 0, 0, 0);
#endif
#ifdef _ADDITIONAL_LIGHTS_VERTEX
inputData.fogCoord = InitializeInputDataFog(float4(input.positionWS, 1.0), input.fogFactorAndVertexLight.x);
inputData.vertexLighting = input.fogFactorAndVertexLight.yzw;
#else
inputData.fogCoord = InitializeInputDataFog(float4(input.positionWS, 1.0), input.fogFactor);
#endif

在这个函数里unity处理出了inputData中shadowCoord的值和fogCoord的值,那么这些值是在哪里进行运算的呢?

在RealtimeLights.hlsl里,实际上这个哈函数在我们平时自己写shader的时候也会用到:

代码
1
2
3
4
5
6
7
8

Light GetMainLight(float4 shadowCoord)
{
Light light = GetMainLight();
light.shadowAttenuation = MainLightRealtimeShadow(shadowCoord);
return light;
}

而其中的MainLightRealtimeShadow(shadowCoord)函数在Shadow.hlsl文件中,这个函数就是Sample了一下光源的Shadowmap,因为阴影的具体原理不是这次学习的重点,就略过了。

而雾效则是在LitForwardPass.hlsl里依靠一个叫MixFog()的函数进行实现的(实际上Lighting.hlsl里的CalculateFinalColor函数也调用了这个函数,但是在着色器里是分开在两块里的,估计是为了自由度吧)

而MixFog()这个函数在ShaderVariablesFunctions.hlsl里,InitializeInputDataFog()也在里面,用来计算fogcoord。

代码
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

float ComputeFogIntensity(float fogFactor)
{
float fogIntensity = 0.0;
#if defined(FOG_LINEAR) || defined(FOG_EXP) || defined(FOG_EXP2)
#if defined(FOG_EXP)
// factor = exp(-density*z)
// fogFactor = density*z compute at vertex
fogIntensity = saturate(exp2(-fogFactor));
#elif defined(FOG_EXP2)
// factor = exp(-(density*z)^2)
// fogFactor = density*z compute at vertex
fogIntensity = saturate(exp2(-fogFactor * fogFactor));
#elif defined(FOG_LINEAR)
fogIntensity = fogFactor;
#endif
#endif
return fogIntensity;
}

half3 MixFogColor(half3 fragColor, half3 fogColor, half fogFactor)
{
#if defined(FOG_LINEAR) || defined(FOG_EXP) || defined(FOG_EXP2)
half fogIntensity = ComputeFogIntensity(fogFactor);
fragColor = lerp(fogColor, fragColor, fogIntensity);
#endif
return fragColor;
}

float3 MixFogColor(float3 fragColor, float3 fogColor, float fogFactor)
{
#if defined(FOG_LINEAR) || defined(FOG_EXP) || defined(FOG_EXP2)
if (IsFogEnabled())
{
float fogIntensity = ComputeFogIntensity(fogFactor);
fragColor = lerp(fogColor, fragColor, fogIntensity);
}
#endif
return fragColor;
}

half3 MixFog(half3 fragColor, half fogFactor)
{
return MixFogColor(fragColor, unity_FogColor.rgb, fogFactor);
}

float3 MixFog(float3 fragColor, float fogFactor)
{
return MixFogColor(fragColor, unity_FogColor.rgb, fogFactor);
}

函数非常简单,就是根据输入的雾的density和color插值算一下,然后再和片元原本的颜色插值算一下,就得到了输出,那么雾的density又是在哪得到的?同样在ShaderVariablesFunctions.hlsl里。

代码
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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

real ComputeFogFactorZ0ToFar(float z)
{
#if defined(FOG_LINEAR)
// factor = (end-z)/(end-start) = z * (-1/(end-start)) + (end/(end-start))
float fogFactor = saturate(z * unity_FogParams.z + unity_FogParams.w);
return real(fogFactor);
#elif defined(FOG_EXP) || defined(FOG_EXP2)
// factor = exp(-(density*z)^2)
// -density * z computed at vertex
return real(unity_FogParams.x * z);
#else
return real(0.0);
#endif
}

real ComputeFogFactor(float zPositionCS)
{
float clipZ_0Far = UNITY_Z_0_FAR_FROM_CLIPSPACE(zPositionCS);
return ComputeFogFactorZ0ToFar(clipZ_0Far);
}
...

// Force enable fog fragment shader evaluation
#define _FOG_FRAGMENT 1
real InitializeInputDataFog(float4 positionWS, real vertFogFactor)
{
real fogFactor = 0.0;
#if defined(_FOG_FRAGMENT)
#if (defined(FOG_LINEAR) || defined(FOG_EXP) || defined(FOG_EXP2))
// Compiler eliminates unused math --> matrix.column_z * vec
float viewZ = -(mul(UNITY_MATRIX_V, positionWS).z);
// View Z is 0 at camera pos, remap 0 to near plane.
float nearToFarZ = max(viewZ - _ProjectionParams.y, 0);
fogFactor = ComputeFogFactorZ0ToFar(nearToFarZ);
#endif
#else
fogFactor = vertFogFactor;
#endif
return fogFactor;
}

而这个函数是在片元着色器里的InitializeInputData()函数中调用的,走到底层就可以发现fogFactor是通过一个全局变量unity_FogParams来算得的。

PS:这我真不知道怎么理解这种结构了,unity这么写自己不会晕吗……

小结

那么到现在为止,阴影和雾效的结构基本也清楚了,它们大多是在片元着色器中的InitializeInputData()函数中进行初始化到InputData中,然后在Lighiting.hlsl中的函数中进行计算,阴影的部分被并入了光源类型,而雾效则在片元着色器里进行处理,一切好像都连起来了。

看了unity写的这么一大块代码,自然而然地就浮现出一个疑问:我看这些干啥?我能如何去使用这些代码来提高自己的效率。

那么我们就需要知道unity到底把光照这件事情分为了多少个部分,让我们从头开始再理清一遍到目前为止的整个过程。

结构

整个过程还是很清晰的,每个步骤都单独属于一个模块,我们要写代码的话可以根据这个模块直接进行拆解,实际上在写风格化shader的时候,我们往往不需要改变阴影采样(最多可能会改变一下阴影的外观?)、GI、雾效这些值,大部分需要改的都在LightingPhysicallyBased里,所以我们只需要把UniversalFragmentPBR改成我们需要的函数,其他的地方完全可以直接照搬。

根据需求可以灵活地去调节这个配置,利用unity为我们提供的代码让自己的shader事半功倍。

中篇后话

那么接下来还有一个clearCoat,其实一路看下来clearCoat是个什么东西已经非常明显了,它是urp为我们提供的一个双层材质效果,在原有的材质基础上附加了一层光滑的清漆材质,靠mask来控制,其中的原理在源码里也能看到,由于我目前还没有做双层材质的需求,所以暂时跳过这个部分。

不过要写一个完整的shader,光有光照也还是不行的,从urp的Lit.shader中就可以看出,我还需要shadowcast、depthnormals等一系列的pass,这些也是写一个自己的高效率管线所绕不开的坎。

因此,在后篇中,我会去研究其它Pass所使用的代码结构和原理。