PA3 The First Light

Shader 与渲染算法 - 表面与光照 - 光照模型与 PBR

1 Normals:法线可视化输出

great normals

1.1 缜密的想法 Meticulous Idea

  1. structure 中添加法线相关内容;

  2. vertex shader 中处理顶点法线,需要重新考虑法线坐标(原本在物体局部坐标系)和方向(跟着物体被缩放后需要重新计算),并且进行归一化处理;

  3. fragment shader 将法线映射为颜色输出,法线插值后再次重新归一化处理。

1.2 CODING

Normal.shader
CGPROGRAM
#pragma vertex Vert
#pragma fragment Frag
#include "UnityCG.cginc"

struct VertexData {
    float4 position: POSITION;
    float3 normal: NORMAL;
};

struct Interpolators {
    float4 position: SV_POSITION;
    float3 normal: TEXCOORD0;
};

Interpolators Vert(VertexData v) {
    Interpolators i;
    i.position = mul(unity_MatrixMVP, v.position);
    i.normal = UnityObjectToWorldNormal(v.normal);
    return i;
}

float4 Frag(Interpolators i): SV_Target {
    i.normal = normalize(i.normal);
    return float4(i.normal * 0.5 + 0.5, 1);
}
ENDCG
如何理解 vertex shader 中的 UnityObjectToWorldNormal 函数?

UnityObjectToWorldNormal 函数一键式解决法线处理中的三个主要问题:

  1. 局部性问题

  2. 非均匀缩放问题

  3. 高亮问题

如何理解 normal 的 “局部性问题”?

除了 Dynamic Batching 作用下的物体以外,所有的法线(Normals)都处于物体的局部坐标系中。因为物体表面的方向实际定义在世界坐标系,所以需要得到世界坐标系中的法线方向。

对于局部性问题的处理,Unity 将一系列变换压缩为了一个 object - world 矩阵,进行从局部坐标系向世界坐标系的转换,是一个名为 unity_ObjectToWorld 的 float4×4 类型 matrix。

将法线在顶点着色器中与该 matrix 相乘,且因为对象是向量,无关位置,因此 repositioning 位,置 0:

i.normal = mul(unity_ObjectToWorld, float4(v.normal, 0));
如何理解 normal 的 “非均匀缩放问题”?

法线的变化总是机械跟随。尤其在非均匀缩放情况下,因为其不会自动随着物体表面形变而变化,由此导致物体法线方向错误。

Scaling X, both vertices and normals by ½.

其解决方法为逆变换(invert):

Scaling X, vertices by ½ and normals by 2.

处理逆变换时,希望改变缩放而保持旋转不变。

我们描述物体的变换矩阵形式为 O=T1T2T3O = T_1T_2T_3,其中 TT 为一个独立的变换。

分解 TT 为该独立变换中的各个步骤 SRPSRP,有 O=S1R1P1S2R2P2S3R3P3...O = S_1R_1P_1S_2R_2P_2S_3R_3P_3..., 因为向量不考虑位置,所以可以缩写为 O=S1R1S2R2O=S_1R_1S_2R_2

我们希望仅对其中的缩放变换取逆,因此有期待矩阵:N=S11R1S21R2N = {S_1}^{-1}R_1{S_2}^{-1}R_2

因为 Unity 提供了从世界坐标到局部坐标的逆矩阵( O1=R21S21R11S11O^{-1}={R_2}^{-1}{S_2}^{-1}{R_1}^{-1}{S_1}^{-1} ),我们可以借用它完成取逆操作。

美中不足是该矩阵还包含了逆旋转和变换顺序的反转,这可能导致错误。通过对该矩阵可以消除不需要的旋转影响,同时保留正确的旋转结果,得到正确的法线: (O1)T=N{(O^{-1})}^T = N

// 对 WorldToObject 矩阵转置得到 Object - World 正确法线结果
i.normal = mul(transpose((float3x3)unity_WorldToObject), v.normal);
如何理解 normal 的 “高亮问题”?

能观察到有一些物体好像变得更亮了。这是因为在执行物体放缩操作时,法线也跟着进行了放缩,而法线在片元着色器中被可视化为颜色。此时需要对法线进行归一化(normalize)操作 —— 使其长度为 1。

i.normal = normalize(i.normal);
如何理解 fragment shader 中对 normal 的 normalize 操作?

在顶点着色器中取得正确的顶点法线(长度为单位向量)后,法线通过插值器传递给片元着色器。插值器根据顶点法线的计算结果对该表面中每个片元的法线进行插值计算,而此时插值出的新法线结果通常小于 1。

因此,为了确保插值后的法线依然是单位向量,需要在片元着色器中对其再次进行归一化操作(vertex shader 中也有归一化操作,只是包含在 UnityObjectToWorldNormal 中了)。

如何理解 fragment shader 的输出公式?
  1. i.normal 是一个三维向量,其分量在 [-1, 1] 之间,这代表了从物体表面出发的向量方向;

  2. i.normal * 0.5 + 0.5 的作用是首先把向量分量空间映射为大小为 1 (-0.5 ~ 0.5),再加 0.5 的偏移量;

  3. 因为函数需要返回一个 float4 类型的数据,所以第二个参数 1 是手动补充的最后一个数值。

  4. 最终结果,比如法线向量 (1, 0, 0) 会被映射为红色 (1, 0.5, 0.5),只需要将三个坐标分量 x、y、z 分别带入上述计算公式(i.normal × 0.5 + 0.5)即可得出。

1.3 番外:Dynamic Batching

Dynamic Batching 是一种合并网格的批处理技术,可以降低 GPU 渲染压力。副作用是,会导致重新计算、不受控制的坐标结果。

该副作用发生在对 normal 不做任何细致考虑之时

按理来说,法线颜色由片元着色器制定,不会随着观察角度的变化而变化。

然而如果 Dynamic Batching 开启,法线会从原本物体的局部坐标变成网格合成(Dynamic Merge)后的世界坐标,从而法线颜色着色颜色也会随着观察角度不同而改变。

如果没有观察到这样的情况,在 Edit - Project Settings - Player - Dynamic Batching 路径下开启

Dynamic Batching

2 Diffuse:漫反射部分

diffuse shading

2.1 前置知识

Ld=kd(I/r2)max(0,nl)L_d = kd(I/r^2)\max(0, n \cdot l)
如何理解漫反射公式所基于的 “兰伯特余弦定律”?

Lambert's cosine law 兰伯特余弦定律:漫反射中,物体表面 shading point 接收到的能量与 光线方向和法线方向的夹角的余弦 成正比。

Lambert's cosine law

2.2 缜密的想法 Meticulous Idea

  1. 首先计算物体与光照交互模型(根据兰伯特余弦定律),即公式中 max(0,nl)\max(0, n\cdot l) 部分;

  2. 然后计算光线信息。原公式做为数学计算,主要考虑了光的强度即光线衰减 (I/r2)(I/r^2)。这里对光的问题整体考虑,即光的强度与光源颜色;

  3. 最后计算物体表面信息,即 KdK_d 系数。

2.3 CODING

Diffuse.shader
Shader "Code/PA3_FirstLight/Diffuse"
{
    // 为了展示 Diffuse 的效果,增加了材质 _MainTex 和光源颜色 _Tint 及相关代码
    Properties {
        _Tint("Tint", Color) = (1, 1, 1, 1)
        _MainTex("Albedo", 2D) = "white" {}
    }
    SubShader {
        Pass {
            Tags {
                "LightMode" = "ForwardBase"
            }
            CGPROGRAM
            #pragma vertex Vert
            #pragma fragment Frag
            #include "UnityStandardBRDF.cginc"
            
            sampler2D _MainTex;
            float4 _Tint, _MainTex_ST;

            struct VertexData {
                float4 position: POSITION;
                float2 uv: TEXCOORD0;
                float3 normal: NORMAL;
            };
            
            struct Interpolators {
                float4 position: SV_POSITION;
                float2 uv: TEXCOORD0;
                float3 normal: TEXCOORD1;
            };
            
            Interpolators Vert(VertexData v) {
                Interpolators i;
                i.position = mul(unity_MatrixMVP, v.position);
                i.normal = UnityObjectToWorldNormal(v.normal);
                i.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return i;
            }

            float4 Frag(Interpolators i): SV_Target {
                i.normal = normalize(i.normal);
                float3 lightDir = _WorldSpaceLightPos0.xyz;
                float3 lightColor = _LightColor0.rgb;
                float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
                // 返回光照模型各项乘积
                float3 diffuse = albedo * lightColor * DotClamped(lightDir, i.normal);
                return float4(diffuse, 1);
            }
            ENDCG
        }
    }
}
如何理解 DotClamped 函数执行 max(0,nl)\max(0, n \cdot l) 部分的计算?

该计算对应了上述 DotClamped 函数。DotClamped 函数执行了上述公式两个步骤,即 dot 运算与 clamp 操作。

DotClamped 函数定义在 UnityStandardBRDF 中。因为 UnityStandardBRDF 包含 UnityCG 和其他文件,所以可以把 UnityCG 删除。

Include file hierarchy

// DotClamped 函数定义在 UnityStandardBRDF 中
// #include "UnityCG.cginc"
#include "UnityStandardBRDF.cginc"

...
// dot 运算 + 与 “0” 取 max 操作
// return max(0, dot(float3(0, 1 ,0), i.normal));

// 第一层升级:对 max 运算,实际更常用的是 saturate 函数。它会将函数 clamp 到 0~1。
// return saturate(dot(float3(0, 1 ,0), i.normal));

// 第二层升级:更便携的 DotClamped 函数,可以做一个 dot 运算并同时保证结果非负
return DotClamped(float3(0, 1, 0), i.normal);
如何理解 lightDir (light direction,公式中的 ll) 通过 _WorldSpaceLightPos0 获得?

_WorldSpaceLightPos0 是 Untiy 内置的一个全局变量,专门用来储存场景中第一个光源的信息,这个光源通常是场景中的主光源。

Unity 的每个新场景中,默认情况下都会创建一个代表太阳的方向光(Directional Light)。因此当没有改变默认设置时,_WorldSpaceLightPos0 中包含的就是该方向光。

  1. 对于方向光:_WorldSpaceLightPos0 存储的是光源的方向而不是位置。方向光被认为是来自无限远处,因此它的光线都是平行的。对于这种光源,_WorldSpaceLightPos0 的前三个分量存储光源方向的反方向(即光线从哪里来的方向),第四个分量为 0。这是因为在齐次坐标系中,方向用一个四元组表示,第四个分量为 0 表示这是一个方向而非位置。

  2. 对于点光源或聚光灯:如果场景中的第一个光源是点光源或聚光灯,_WorldSpaceLightPos0 的前三个分量会存储光源的位置,第四个分量为 1,表示这是一个位置向量。

如何理解 LightMode = "ForwardBase"

对于光的渲染有两种方式(在 Rendering Path 中指定),一种是 Forward 一种是 Deferred。Forward 是我们所需要的,即通过多通道的办法,一层一层的叠加光的计算,叫做前向渲染。

选择前向渲染就需要在 shader 中显式指定 “处理主光源的通道”,即上述 LightMode 语句。

如何理解光线信息 lightColor 的计算?

因为这里使用的是默认的方向光,在全局几乎是均匀的,不考虑衰减。因此只需要获取光源颜色。

Unity 内置 _LightColor0 变量,fixed4 类型,用来获取光源颜色,与 _WorldSpaceLightPos0 同理。光源颜色可以在旁边面板设置。

如何理解物体表面信息 albedo 的计算?

很多材料会吸收一定波段的光谱来呈现它们的颜色。比如如果所有可见的红色段光谱被吸收,剩下的颜色会被反射出去,这个材料就会被呈现为青色。材料被漫反射出去的颜色被称为 Albedo,反射率,拉丁语中的 “whiteness”(超有道理,越白的材料反射率越高)。

对应到 Blinn-Phong 模型,Albedo 就是 KdKd 系数(材质的漫反射系数),它决定了表面如何反射来自光源的漫反射光。

albedo 是指物体表面的本色(即未经光照影响的颜色),通常由材质的颜色纹理(_MainTex)和颜色调整参数(如 _Tint)决定。

关于 Light Mode & Rendering Path 部分更详细的解释

  1. Rendering Path:渲染路径,它是一种如何渲染场景中光照的策略,回答的问题是 “如何表达光”。 选项(Camera 中设置):Forward,Deferred

  2. Forward & Deferred:前向渲染 & 延迟渲染。Rendering Path 的两种处理方法,前向渲染比较类似光源的逐一叠加,用多通道的方式,就像 RGB 通道处理的过程和结果。而延迟渲染则比较类似于批处理的方式,先统一计算光源信息,再一次性运用。

  3. ForwardBase Pass:如果选择了前向渲染作为渲染路径,那么就选择了这种多通道、逐一处理光的方法。此时需要在 shader 中显式地指定一个处理主光源的通道,即 ForwardBase Pass。

  4. Light Mode:写在 shader 中,定义应该使用哪种光照模式或数据,例如指定处理主方向光或其他光源。它回答的问题是 “光是什么”。

在 Camera 中设置 Rendering Path

光源的 Light Mode 和 shader 中的 LightMode 标签有什么区别与联系?
  • 光源的 Light Mode:整体交互。描述光源与整个场景(包括所有模型)之间的交互形态。这决定了光源如何影响场景中的物体——是实时计算的(Realtime),还是预先烘焙的(Baked),亦或是两者结合的(Mixed)。它涉及到光源如何在整个场景中发挥作用,影响全局的光照效果。

  • 着色器中的 LightMode 标签:具体处理方式。描述了在上述光源与场景交互的背景下,当光照影响到某个特定物体时,着色器如何处理这一交互。也就是说,它决定了在渲染路径中,当光源与场景中的物体 “相交” 时(比如光照照射到物体表面),具体的光照计算如何进行。着色器中的 LightMode 标签告诉渲染管线在这个特定光源模式下,应该如何计算光影、颜色、阴影等效果。

  • 这两个层面共同作用,确保光照效果既符合场景的整体光照模式,也能在渲染过程中得到精确、合理的处理。

3 Specular:高光部分

3.1 前置知识

Ls=ks(I/r2)max(0,cosα)p=ks(I/r2)max(0,nh)p\begin{equation*}\begin{split} L_s &=k_s(I/r^2)\max(0, \cos \alpha)^p\\&=k_s(I/r^2)\max(0, n\cdot h)^p\end{split}\end{equation*}
其中:h(半程向量)=bisector(v,l)=v+lv+l其中:h_{(半程向量)}= bisector(v, l) = \frac{v+l}{\lVert v+l\rVert}
Phong 模型和 Blinn-Phong 模型有什么区别?

Phong 模型是根据高光原理直接产生的,而 Blinn-Phong 模型是基于 Phong 模型和一个有趣的观察。

首先回忆高光产生的原理:对于一个绝对光滑的物体,其入射光 l 与法线 n 形成的夹角 = 出射光 R 与法线形成的夹角。而当观察角度 v 的方向与出射光方向 R 接近时,就会观察到高光。

上述对高光的描述可以直接表达为 Phong 模型。但因为出射光线角度比较难算,而继续观察光照模型发现:假设将入射光 l 和观察方向 v 之间夹角的一半称作半程向量 h,那么观察方向 v 与出射光 R 足够接近时,半程向量 h 和法线 n 非常接近。由此推导出 Blinn-Phong 模型。

specular models

在上述 Blinn-Phong 模型中,pp 是光泽度指数,控制高光的锐度:

visualization of Blinn-Phong

3.2 粗糙的想法 Rough Idea

  1. 首先解决高光与物体表面交互信息,即 max(0,nh)\max(0, n \cdot h) 部分。这里主要需要进行半程向量的计算;

  2. 定义光泽度 _Smoothness,即公式中指数 pp 部分,控制高光锐度;

  3. 然后解决光线信息,即 (I/r2)(I/r^2) 部分;

  4. 最后定义物体表面信息,即公式中 KsK_s 系数;

  5. 解决能量守恒问题

3.3 CODING

Specular.shader
Shader "Code/PA3_FirstLight/Specular"
{
    // Properties 新增 _SpecularTint 和 _Smoothness
    Properties {
        _Tint("Tint", Color) = (1, 1, 1, 1)
        _MainTex("Albedo", 2D) = "white" {}
        _SpecularTint("Specular", Color) = (0.5, 0.5, 0.5)
        _Smoothness("Smoothness", Range(0, 1)) = 0.5
    }
    SubShader {
        Pass {
            Tags {
                "LightMode" = "ForwardBase"
            }
            CGPROGRAM
            #pragma vertex Vert
            #pragma fragment Frag
            #include "UnityStandardBRDF.cginc"
            #include "UnityStandardUtils.cginc"
            
            sampler2D _MainTex;
            float4 _Tint, _MainTex_ST, _SpecularTint;
            float _Smoothness;

            struct VertexData {
                float4 position: POSITION;
                float2 uv: TEXCOORD0;
                float3 normal: NORMAL;
            };
            
            // Interpolators 中定义 worldPos,将 mesh 顶点转换到世界坐标系
            struct Interpolators {
                float4 position: SV_POSITION;
                float2 uv: TEXCOORD0;
                float3 normal: TEXCOORD1;
                float3 worldPos: TEXCOORD2;
            };
            
            Interpolators Vert(VertexData v) {
                Interpolators i;
                i.position = mul(unity_MatrixMVP, v.position);
                i.worldPos = mul(unity_ObjectToWorld, v.position); // 物体顶点转换到世界坐标系
                i.normal = UnityObjectToWorldNormal(v.normal);
                i.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return i;
            }

            float4 Frag(Interpolators i): SV_Target {
                i.normal = normalize(i.normal);
                float3 lightDir = _WorldSpaceLightPos0.xyz;
                
                float3 lightColor = _LightColor0.rgb;
                float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
                float oneMinusReflectivity;
                albedo = EnergyConservationBetweenDiffuseAndSpecular(albedo, _SpecularTint.rgb, oneMinusReflectivity);

                // 半程向量公式:normalize(light direction + view direction)
                // viewDir 是观察方向,来自观察点 - 物体顶点(两点相减得向量)
                float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos); // view director, 观察方向
                float3 halfVector = normalize(lightDir + viewDir); // half vector, 半程向量

                float3 diffuse = albedo * lightColor * DotClamped(lightDir, i.normal);
                float3 specular = _SpecularTint.rgb * lightColor * pow(DotClamped(halfVector, i.normal), _Smoothness * 100);
                return float4(diffuse + specular, 1);
            }
            ENDCG
        }
    }
}
如何理解 EnergyConservationBetweenDiffuseAndSpecular 函数的使用?

正常的光线中能量是守恒的,如果漫反射(diffuse)镜面反射(specular)的光线直接相加,会导致总光照强度甚至高于原始光源的强度。

EnergyConservationBetweenDiffuseAndSpecular 就是Unity 在 UnityStandardUtils 里提供解决上述问题的一个工具函数。

在这个工具函数的使用中,前两个参数比较好理解。第三个参数,实际上是一个输出参数。这是 CG/HLSL 语法的神奇(离谱)之处。某些函数可以通过函数引用来返回多个值,这种情况下,这些参数即可以作为输入参数,也可以作为输出参数。

此时,第三个参数是新定义的 oneMinusReflectivity,它的作用是返回系数(相当于 1 - specular)。

对于能量守恒问题,为什么不可以简单让 albedo *= (1 - specular)?

以高光 _SpecularTint.rgb 为基准,将 albedo 乘以一个系数,使得 albedo_SpecularTint.rgb 之和为 1。

albedo *= 1 - _SpecularTint.rgb; 

上述处理会出现一个很 weird 的情况:高光是单色调还好,如果高光是其他颜色,那么漫反射部分就会成为它的补色:

Yellow specular, blue albedo

这个问题的出现在于,如果高光是灰度色调,那么对漫反射的影响就是均衡的。否则它的 RGB 三个通道,多的,漫反射部分就会更少;少的,漫反射部分就会更多。于是产生了不正确的色彩偏移。

单色能量守恒就是这个补丁:为了修正色彩偏移,使用高光 RGB 中最大的的通道值,统一减少漫反射的色彩强度。

// double max(max 函数只能有两个参数)
albedo *= 1 - max(_SpecularTint.r, max(_SpecularTint.g, _SpecularTint.b));

right albedo

EnergyConservationBetweenDiffuseAndSpecular 就是为了解决这个问题诞生的,所以这个丑陋且常用的写法直接可以使用上述内置工具函数。

4 Metallic Workflow

根据两类材质(金属 & 非金属)的光照特点,可以有两种工作流(高光工作流 & 金属工作流)来进行处理。其中每种工作流都能满足两种材质的需求,只是各有侧重。

4.1 前置知识

如何根据光照特点区分材质?

非金属(Dielectrics) 也称为介电材料(如塑料、木材、石材等)。高光反射较弱且没有颜色(通常为白色),其漫反射决定了它们的主要颜色表现。

金属(Metals) 表面具有强烈的镜面反射。高光部分通常具有金属本身的颜色,而没有漫反射(albedo)。其在光照下主要表现为高光颜色

如何理解高光工作流 & 金属工作流?

高光工作流 Specular Workflow

在高光工作流中,通常设置一个较弱的无色高光创建非金属材质,也可以通过设置一个强烈的高光颜色(specular tint)来创建金属材质。

这种方法需要对高光颜色进行详细的控制,因此创建材质时需要同时管理高光颜色和漫反射颜色。

金属工作流 Metallic Workflow

金属材质没有漫反射,因此可以用漫反射颜色数据来表示高光颜色。而对于非金属材质,因为其高光通常是无色的(白色),不需要单独指定高光颜色。

金属工作流通过一个开关或滑块来切换材质是金属还是非金属。只需一个颜色源(用于漫反射或高光)和一个滑块(表示金属性)就能创建出逼真的材质。这种方式避免了手动设置高光颜色,使材质创建过程更直观。

两种工作流横评

所谓金属工作流,实际上是单颜色源工作流。通过滑块这个 bool 开关,对于金属材质该颜色源表示高光,而对于非金属材质,该颜色源表示漫反射(高光部分直接白色);

高光工作流,则是双颜色源工作流。其对于两种颜色,根据材质区别进行单独控制。

因此,金属工作流更简单,适合大多数实际应用,但缺少细节控制,无法轻易创造出非现实的材质效果。而高光工作流提供更多控制,可以精确调整高光颜色和强度,可以更加独特或非现实的材质效果。但由于有更多的参数需要设置,材质创建的复杂度较高。

4.2 缜密的想法 Meticulous Idea

Drawing
specular workflow
Drawing
metallic workflow

所以工作流的切换关键是是 albedo 和 SpecularTint 的逻辑处理区别。

高光工作流的 _SpecularTint 会在 Properties 编辑器里就开始定义,也就是说,高光工作流的高光是单独设置的。而金属工作流的 SpecularTint 是在 fragment shader 里临时定义的,并且马上就 “剥夺” 了 albedo 的成果 —— 赋予其 albedo 与金属度 _Metallic 的乘积结果。

这就实现了金属材质的 idea:其材质主要表现为高光,而非金属材质则主要表现为漫反射。

  1. Properties:移除高光(Specular Tint)并删除与高光相关的代码;添加金属度滑块(Metallic):新增一个 _Metallic 滑块,这是金属工作流的核心,用于在金属和非金属之间切换;

  2. 重新进行 specular 与 albedo 的交互计算:

    1. SpecularTint = albedo * _Metallic

    2. albedo *= oneMinusReflectivity (即 1 - _Metallic)

MetallicWorkflow.shader
// 关键逻辑(不使用工具函数 DiffuseAndSpecularFromMetallic 的情况)
float3 SpecularTint = albedo * _Metallic;
float oneMinusReflectivity = 1 - _Metallic;
// albedo = EnergyConservationBetweenDiffuseAndSpecular(albedo, _SpecularTint.rgb, oneMinusReflectivity);
albedo *= oneMinusReflectivity;

4.3 CODING

MetallicWorkflow.shader
Shader"Code/PA3_FirstLight/MetallicWorkflow"
{
    Properties {
        _Tint("Tint", Color) = (1, 1, 1, 1)
        _MainTex("Albedo", 2D) = "white" {}
        [Gamma] _Metallic("Metallic", Range(0, 1)) = 0 // 结构改动 (_SpecularTint -> _Metallic)
        _Smoothness("Smoothness", Range(0, 1)) = 0.5
    }
    SubShader {
        Pass {
            Tags {
                "LightMode" = "ForwardBase"
            }
            CGPROGRAM
            #pragma vertex Vert
            #pragma fragment Frag
            #include "UnityStandardBRDF.cginc"
            #include "UnityStandardUtils.cginc"
            
            sampler2D _MainTex;
            float4 _Tint, _MainTex_ST;
            
            float _Smoothness, _Metallic;

            struct VertexData {
                float4 position: POSITION;
                float2 uv: TEXCOORD0;
                float3 normal: NORMAL;
            };
            
            struct Interpolators {
                float4 position: SV_POSITION;
                float2 uv: TEXCOORD0;
                float3 normal: TEXCOORD1;
                float3 worldPos: TEXCOORD2;
            };
            
            Interpolators Vert(VertexData v) {
                Interpolators i;
                i.position = mul(unity_MatrixMVP, v.position);
                i.worldPos = mul(unity_ObjectToWorld, v.position);
                i.normal = UnityObjectToWorldNormal(v.normal);
                i.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return i;
            }

            float4 Frag(Interpolators i): SV_Target {
                i.normal = normalize(i.normal);
                float3 lightDir = _WorldSpaceLightPos0.xyz;
                float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
                float3 halfVector = normalize(lightDir + viewDir);
                float3 lightColor = _LightColor0.rgb;
                float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
                
                float3 SpecularTint;
                float oneMinusReflectivity;
                albedo = DiffuseAndSpecularFromMetallic(albedo, _Metallic, SpecularTint, oneMinusReflectivity);

                float3 diffuse = albedo * lightColor * DotClamped(lightDir, i.normal);
                float3 specular =  * lightColor * pow(DotClamped(halfVector, i.normal), _Smoothness * 100);
                
                return float4(diffuse + specular, 1);
            }
            ENDCG
        }
    }
}
如何理解 DiffuseAndSpecularFromMetallic 函数的使用?

简单地将金属度(_Metallic)直接用于控制高光和漫反射的调整是一种过于简化的处理方式。

原先简单的线性调整的方式假设完全非金属(_Metallic = 0)时镜面反射完全消失,完全金属(_Metallic = 1)时只有有色高光,而没有考虑到非金属仍然存在一些基本的无色高光反射。

为了解决这个问题,Unity 提供了一个更准确的工具函数 DiffuseAndSpecularFromMetallic,用来正确地处理金属和非金属材质之间的差异。使得材质表现更加贴近真实的物理效果,特别是在处理光滑表面、混合材质或色彩空间转换时,能够更好地保持材质的真实感和一致性。

MyFirstLightingShader.shader - MyFragmentProgram
// 使用工具函数解决属性之间的关系问题
// float3 SpecularTint = albedo * _Metallic;
// float oneMinusReflectivity = 1 - _Metallic;
// albedo *= oneMinusReflectivity;

float3 SpecularTint;
float oneMinusReflectivity;
albedo = DiffuseAndSpecularFromMetallic(albedo, _Metallic, SpecularTint, oneMinusReflectivity);
如何理解 Properties 里 _Metallic 滑块前的 [Gamma] 标签?

在线性空间渲染中,Unity 会自动对纹理和颜色进行 Gamma 校正,但是单一数值(如 _Metallic 滑块的值)并不会自动进行 Gamma 校正。

所以在 _Metallic 滑块前加上 [Gamma] 标签,进行手动矫正。

5 PBS:基于物理的光照模型

好像是有点不一样了但是说不上哪不一样了

Blinn-Phong 模型长期以来一直是游戏工业中常用的光照模型。而现在则更多使用一种更为真实、可预测的模型 —— PBS(Physically-Based Shading,基于物理的光照模型。Unity 的标准 shader 就使用了 PBS 方法。

PBS (光照模型)通过 BRDF 函数(数学模型)来实现。

Unity 中如何实现 PBS 模型?

Unity 实际上有多种不同的 PBS 实现方案,并会根据目标平台来选择最合适的一个。PBS 的算法通过一个叫做 UNITY_BRDF_PBS 的宏来实现,该宏定义在 UnityPBSLighting 中。

BRDF 数学模型精确描述表面光照的反射和散射行为,生成 PBS 模型下的光线。

BRDF 方程的实现非常重数学,在这里不做展示(请看闫老师的详细展示)。它使用了和 Blinn-Phong 模型不同的方法计算漫反射和高光反射外,还引入了 Fresnel 反射组件。

如何理解 Fresnel 反射组件?

Fresnel 反射描述了物体表面反射光线强度如何随着观察角度变化。当观察角度接近平行于表面时(即 grazing angles,掠射角),反射光会变得更强。这种现象在镜面反射或水面反射等场景中尤为明显。

5.1 粗糙的想法 Rough Idea

  1. PBS 算法通过一个 BRDF 函数宏(叫做 UNITY_BRDF_PBS )实现,其定义在 UnityPBSLighting 中;

  2. 基于 Metallic Workflow 的代码,为该 BRDF 宏传入所需的八个参数:

    1. 前两个是 diffuse(漫反射)和 specular(高光)。前述代码已经实现了;

    2. 紧接着是 reflectivity(反射率)和 roughness(粗糙度)。为优化性能,这些系数需要以(1 减去它们)的形式给出。我们已经通过 DiffuseAndSpecularFromMetallic 得到了 oneMinusReflectivity,而 smoothness(光滑度)正好与 roughness 相反,因此可以直接用它们;

    3. 第五和第六个参数是法线向量和观察方向向量 of course;

    4. 最后两个参数是 direct & indirect light(直射光和间接光)。这两个参数将传入一个 UnityLight / UnityIndirect 结构体;

  3. 因为 BRDF 函数返回一个 RGBA 颜色,其中 alpha 通道往往设置为 1。所以可以直接返回其结果。

5.2 CODING

PBS.shader
// 以下 PBS 实现是基于 Metallic Workflow 的代码
Shader"Code/PA3_FirstLight/PBS"
{
    Properties {
        _Tint("Tint", Color) = (1, 1, 1, 1)
        _MainTex("Albedo", 2D) = "white" {}
        [Gamma] _Metallic("Metallic", Range(0, 1)) = 0
        _Smoothness("Smoothness", Range(0, 1)) = 0.5
    }
    SubShader {
        Pass {
            Tags {
                "LightMode" = "ForwardBase"
            }
            CGPROGRAM
            #pragma target 3.0
            #pragma vertex Vert
            #pragma fragment Frag
            // #include "UnityStandardBRDF.cginc"
            // #include "UnityStandardUtils.cginc"
            #define UNITY_PBS_USE_BRDF3
            #include "UnityPBSLighting.cginc"
            
            sampler2D _MainTex;
            float4 _Tint, _MainTex_ST;
            
            float _Smoothness, _Metallic;

            struct VertexData {
                float4 position: POSITION;
                float2 uv: TEXCOORD0;
                float3 normal: NORMAL;
            };
            
            struct Interpolators {
                float4 position: SV_POSITION;
                float2 uv: TEXCOORD0;
                float3 normal: TEXCOORD1;
                float3 worldPos: TEXCOORD2;
            };
            
            Interpolators Vert(VertexData v) {
                Interpolators i;
                i.position = mul(unity_MatrixMVP, v.position);
                i.worldPos = mul(unity_ObjectToWorld, v.position);
                i.normal = UnityObjectToWorldNormal(v.normal);
                i.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return i;
            }

            float4 Frag(Interpolators i): SV_Target {
                i.normal = normalize(i.normal);
                float3 lightDir = _WorldSpaceLightPos0.xyz;
                float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
                // float3 halfVector = normalize(lightDir + viewDir); 不需要自行计算半程向量
                float3 lightColor = _LightColor0.rgb;
                float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
                
                float3 SpecularTint;
                float oneMinusReflectivity;
                albedo = DiffuseAndSpecularFromMetallic(albedo, _Metallic, SpecularTint, oneMinusReflectivity);

                UnityLight light;
                light.color = lightColor;
                light.dir = lightDir;
                light.ndotl = DotClamped(i.normal, lightDir);

                UnityIndirect indirectLight;
                indirectLight.diffuse = 0;
                indirectLight.specular = 0;

                return UNITY_BRDF_PBS(
                    albedo, SpecularTint,
                    oneMinusReflectivity, _Smoothness,
                    i.normal, viewDir,
                    light, indirectLight
                    );

                // float3 diffuse = albedo * lightColor * DotClamped(lightDir, i.normal);
                // float3 specular = SpecularTint * lightColor * pow(DotClamped(halfVector, i.normal), _Smoothness * 100);
                // return float4(diffuse + specular, 1);
            }
            ENDCG
        }
    }
}
如何理解 #pragma target 3.0 语句

为了确保 Unity 能够选择最佳的 BRDF 算法,将 shader 的目标版本(target)至少设置为 3.0 级别。这一设置启用了更高级的着色器功能,确保 PBS 能够在更复杂的硬件平台上运行,并展现出更真实的光照效果。

UNITY_BRDF_PBS 未定义宏错误处理

需要在 #include "UnityPBSLighting.cginc" 语句前添加: #define UNITY_PBS_USE_BRDF3

ChatGPT 教的:UNITY_PBS_USE_BRDF3 宏确保了 UnityPBSLighting.cginc 文件中的 UNITY_BRDF_PBS 被定义为 BRDF3_Unity_PBS,从而避免未定义宏而引发的错误。

如何理解 UnityLight structure 及其相关语句?

UnityLightingCommon 定义了一个简单的 UnityLight 结构体,Unity 的 shaders 用它来传递光照数据。它包含了光源的颜色、方向,以及一个 ndotl 值(用于表示漫反射的参数)。这些结构体只是为了方便我们的使用,它不会影响最终编译出来的代码。

这些数据我们都有了,只需要传递给这个结构体,然后再把它传递为第七个参数 (direct light)。

如何理解 UnityIndirect structure 及其相关语句?

最后一个参数是用于 indirect light 的,我们需要使用 UnityIndirect 结构体来处理,它同样定义在 UnityLightingCommon 中。该结构体包含两个颜色值,一个是漫反射颜色,另一个是高光颜色。

Indirect light 稍后讨论,现在可以简单地把它的参数都设置为黑色( = 0,即不发光)。


APPENDIX

Anki

Reference

  • GAMES101

    Blinn-Phong 模型部分 Lecture 7 - Shading1 (Illumination, Shading and 
Graphics Pipeline) Lecture 8 - Shading2 (Shading, Pipeline and Texture Mapping)

    PBS 部分 Lecture 13 - Ray Tracing 1
 (Whitted-Style Ray Tracing) Lecture 14 - Ray Tracing 2 (Acceleration & Radiometry) Lecture 15 - Ray Tracing 3 (Light Transport & Global Illumination)

Last updated