Post

Unity 基础光照(翻译四)

Unity 基础光照(翻译四)

本篇摘要

  • 将法线从对象空间转换为世界空间。
  • 使用方向光。
  • 计算光的漫反射和镜面反射。
  • 调整光的能耗强度。
  • 应用金属工作流程。
  • 学习使用Unity的PBS算法。

法线 Normals

可见光是电磁波谱中人眼可以感知的部分,可见光谱没有精确的范围。我们可以看到电磁波谱的一部分,也就是我们所知的可见光,因为人眼睛可以检测到电磁辐射,而光谱的其余部分对我们来说是不可见的。光的单个光量子被称为光子

整个电磁频谱是多少?

光谱被分成光谱带。从低频到高频,这些被称为无线电波、微波、红外线、可见光、紫外线、X 射线和伽马射线。

光源发出一束光,一些光会照射到物体上,一些光会从物体反射回来。如果那道反射到光最终照射到我们的眼睛或相机镜头上,那么我们就会看到这个物体。

为了能看见游戏里的物体,我们已知道模型表面各个顶点和顶点坐标,但是不知道顶点方向。为此,我们需要知道顶点法线以计算其方向。

计算网格法线

创建一个材质球和shader,创建一些cube、sphere物体并使用创建的材质。

1
2
3
Shader "Custom/My First Lighting Shader" 
{
}
未着色物体
未着色物体

Unity内置的cube、sphere网格包含了顶点法线,可直接获取它们并传递给片元函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct VertexData {
    float4 position : POSITION;
    float3 normal : NORMAL;
    float2 uv : TEXCOORD0;
};

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

Interpolators MyVertexProgram (VertexData v) {
    Interpolators i;
    i.uv = TRANSFORM_TEX(v.uv, _MainTex);
    i.position = mul(UNITY_MATRIX_MVP, v.position);
    i.normal = v.normal;
    return i;
}

现在能看见法线向量作为颜色输出的显示

1
2
3
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    return float4(i.normal = 0.5 + 0.5, 1);
}
法线向量作为颜色渲染
法线向量作为颜色渲染

这些是直接来自网格的原始法线。立方体的面看起来是平的,因为每个面都只有四个顶点组成了两个三角面,这些顶点的法线都指向同一个方向。相比之下,球体的顶点法线都指向了不同的方向,从而产生了平滑之颜色过渡。

动态批处理

立方体法线发生了一些奇怪显示。我们希望每个立方体显示相同的颜色,不同视角下的立方体却改变了颜色。

变色立方体

这是由动态批处理引起的。Unity规定将一定数量的网格顶点动态合并在一起,以减少绘制调用。而球体的网格数量顶点太多了导致不能合批,所以它们不会受到影响。

要合并网格,必须将它们从本地空间转换为世界空间。对象是否被批处理以及如何被合批有一方面取决于它们如何排序以进行渲染。由于这种转换也会影响法线,这就是我们看到颜色变化的原因。

这里关闭动态合批处理,先专注法线知识。

关闭动态合批
关闭动态合批

除了动态批处理,Unity还支持进行静态批处理。这对于静态几何体的工作方式不同,但也涉及到世界空间的转换。静态批处理需要预先构建,这里先跳过。

没有批处理影响的法线颜色
没有批处理影响的法线颜色

转换法线到世界空间

除了动态批处理之外的物体, 其他所有物体法线都是在对象空间, 但是又需要知道它们在世界空间下的方向. 所以必须将法线从物体空间转换到世界空间, 为此需要进行矩阵计算.

Unity将gamebject的多次的矩阵转换结构合并成一个变换矩阵,类似这样:$O = T_1\cdot T_2\cdot T_3\cdot …$, 其中T是单独变换矩阵, O是所有T的组合.

Unity内置了float4 unity_objectToWolrd矩阵变量, 该矩阵定义在UnityShaderVariables.cginc文件, 将该矩阵与顶点函数内的法线相乘, 可以将其转换到世界空间. 虽然是不同空间转换但是也要保持空间内的方向一致, 所以其次坐标第四个分量设为0.

1
2
3
4
5
6
7
8
Interpolators MyVertexProgram (VertexData v)
{
    Interpolators i;
    i.position = mul(UNITY_MATRIX_MVP, v.position);
    i.normal = mul(unity_ObjectToWorld, float4(v.normal, 0));
    i.uv = TRANSFORM_TEX(v.uv, _MainTex);
    return i;
}

或者,也可以仅与矩阵的3x3部分相乘, 编译后的代码最终是一样的,因为编译器将消除所有乘以常数零的东西。

1
i.normal = mul((float3x3)unity_ObjectToWorld, v.normal);
对象到世界空间
对象到世界空间

法线现在位于世界空间中,但有些法线颜色看起来比其他法线更亮。那是因为它们当中有被缩放过。所以我们必须在转换后对它们进行归一化。

1
2
i.normal = mul(unity_ObjectToWorld, float4(v.normal, 0));
i.normal = normalize(i.normal);
归一化法线
归一化法线

虽然对向量进行了归一化,但对于没有统一比例的法线,颜色看起来也有些不一致。这是因为当一个表面在某一维上被拉伸时,它的法线不会以同样的方式拉伸。

缩放x轴,顶点和法线变为$1 \over 2$
缩放x轴,顶点和法线变为$1 \over 2$

当缩放比例不均匀时,应将其反转为法线。法线将在归一标准化后与变形表面的形状相匹配。它对统一的比例没有影响。

缩放x轴,顶点变为$1 \over 2$法线变为2
缩放x轴,顶点变为$1 \over 2$法线变为2

所以我们必须反转比例,但旋转应该保持不变。那如何去翻转缩放?

根据\(O = [T_1]\cdot[T_2]\cdot[T_3]\cdot...\)矩阵描述了S缩放、R旋转、P位移,而每个T也可以拆解为SRP.即\(O=[S1R1P1]\cdot[S_2R_2P_2]\cdot[S_3R_3P_3]\cdot[...]\)成立,但是法线不需要改变位移,去掉T3;同时每个T也不需要位移,去掉P。最后简化为:\(O=[S_1R_1]\cdot[S_2R_2]\)

目标是翻转S,所以object-to-world矩阵: \(N = S^{-1}_1R_1 \cdot S^{-1}_2R_2\)

而Unity提供了world-to-object矩阵,它是object-to-world的逆矩阵: \(N^{-1} = O^{-1} =S_2^{-1}R_2^{-1}\cdot S_1^{-1}R_1^{-1}\)

这个\(O^{-1}\)同时把旋转和变换顺序也翻转了,需要对\(O^{-1}\)进行转置消除对旋转的影响: \((O^{-1})^T =(S_2^{-1}R_2^{-1}\cdot S_1^{-1}R_1^{-1})^T = N\)

推导:$R^T = R^{-1}$。 $sin(-z) = -sin z$, $cos(-z) = cos z$

\[\begin{bmatrix} Cosz & -Sinz & 0 \\ Sinz & Cosz & 0 \\ 0 & 0 & 0 \end{bmatrix}^T = \begin{bmatrix} Cosz & SinZ & 0 \\ -Sinz & Cosz & 0 \\ 0 & 0 & 0 \end{bmatrix} = \begin{bmatrix} Cosz & -Sinz & 0 \\ Sinz & Cosz & 0 \\ 0 & 0 & 0 \end{bmatrix}^{-1}\]

$O^{-1}=R_2^{-1}S_2^{-1}\cdot R_1^{-1}S_1^{-1}=R_2^TS_2^{-1}\cdot R_1^TS_1^{-1}.$

\[(O^{−1})^T=(S_1^{−1})^T(R_1^T)^T\cdot (S_2^1)^T(R_2^T)^T=(S_1^{−1})^TR_1\cdot (S_2^{−1})^TR_2.\]

缩放矩阵具有单位矩阵特性\((S^T=S)\):

\[(O^{−1})^T=S_1^{−1}R_1\cdot S_2^{−1}R_2=N\]

因此,再对unity_WorldToObject矩阵转置后并与顶点法线相乘。

1
2
3
4
5
i.normal = mul(
    transpose((float3x3)unity_WorldToObject),
    v.normal
);
i.normal = normalize(i.normal);
正确的世界空间法线
正确的世界空间法线
1
2
3
4
5
6
7
8
9
10
11
i.normal = mul(transpose((float3x3)unity_WorldToObject, v.normal);//这个写法行的通,只是学习一下上面的知识.汇编也很难看

//汇编--
9: dp3 r0.x, cb1[4].xyzx, v1.xyzx
10: dp3 r0.y, cb1[5].xyzx, v1.xyzx
11: dp3 r0.z, cb1[6].xyzx, v1.xyzx
12: dp3 r0.w, r0.xyzx, r0.xyzx
13: rsq r0.w, r0.w
14: mul o2.xyz, r0.wwww, r0.xyzx
//汇编--
i.normal = mul(unity_ObjectToWorld, v.normal);//简化写法

Unity也提供了更好的函数:unityObjectToWorldNormal

1
2
3
4
5
6
7
Interpolators MyVertexProgram (VertexData v) {
    Interpolators i;
    i.position = mul(UNITY_MATRIX_MVP, v.position);
    i.normal = UnityObjectToWorldNormal(v.normal);
    i.uv = TRANSFORM_TEX(v.uv, _MainTex);
    return i;
}

UnityObjectToWorldNormal内部实现:

1
2
3
4
5
6
7
8
9
10
// Transforms normal from object to world space
inline float3 UnityObjectToWorldNormal( in float3 norm ) {
        // Multiply by transposed inverse matrix,
        // actually using transpose() generates badly optimized code
        return normalize(
                unity_WorldToObject[0].xyz * norm.x +
                unity_WorldToObject[1].xyz * norm.y +
                unity_WorldToObject[2].xyz * norm.z
        );
}

再次归一化法线 ReNormalsed

在顶点函数中计算出正确的法线后,传递给片元函数。然而,在不同的单位长度向量之间进行值传递,并不能确保法线的归一化。

所以我们必须在片元函数中再次对法线进行归一化。

1
2
3
4
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    i.normal = normalize(i.normal);
    return float4(i.normal = 0.5 + 0.5, 1);
}
再次归一化
再次归一化

虽然再次归一化会产生更好的结果,但是值的误差其实通常非常小。如果在移动设备上,这一步可以省略。

夸张的错误
夸张的错误

漫反射 Diffuse Shading

我们看到的物体本身因为它们反射了光。反射有不同方式:漫反射、镜面反射、基于物理着色,先讨论下漫反射

漫反射发生的原因是光线不会直接从物体表面反弹,而是一部分光到达粗糙的表面后弥射开,另一部光穿透物体,在物体内部游走然后再次离开表面。这里我们先不用完全遵循现实世界的物理细节。

从表面漫反射多少光取决于光线照射它的角度。大多数光在正面撞击表面时以0°角反射。随着这个角度的增加,反射的光量随之会减少。在90°时,不再有光线照射到表面,因此是黑暗的。漫射光的量与光的方向和表面法线之间的夹角的余弦成正比。这被称为兰伯特余弦定律。 $Diffuse = Albedo \cdot lightColor \cdot DotClamped(lightDir, normal)$

漫反射
漫反射

我们可以通过计算表面法线和光方向的点积来确定这个朗伯反射系数。我们已经知道法线,但还不知道光的方向。在代码内给定一个直接从物体头顶上方照射的方向。

1
2
3
4
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    i.normal = normalize(i.normal);
    return dot(float3(0, 1, 0), i.normal);
}
从上方照亮,左伽马 vs. 右线性。
从上方照亮,左伽马 vs. 右线性。
从上方照亮,左伽马 vs. 右线性。
点积
点积

约束负数光照 Clamped Lighting

当表面朝向光时计算点积有效,但当它远离光时则无效。这样做是防止物体表面被从后面来的光源照亮。当表面朝向光计算点积才是有意义的;当物体表面处于自己的阴影面是不需要接受光照的;当光的方向与法线的方向大于90°时点积的结果是负数。使用max函数

1
return max(0, dot(float3(0, 1, 0), i.normal));

Unity着色器更推荐使用saturate, 这个标准函数限制值在0-1之间。

1
return saturate(dot(float3(0, 1, 0), i.normal));

但是UnityStandardBRDF.cginc文件为程序定义了更方便方便的DotClamped函数。此函数执行点积结果并确保它永远不会是负数。它还包含许多其他照明函数,也包含其他有用的文件!

1
2
3
4
5
6
7
#include "UnityCG.cginc"
#include "UnityStandardBRDF.cginc"

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    i.normal = normalize(i.normal);
    return DotClamped(float3(0, 1, 0), i.normal);
}

DotClamped 内部实现

unity决定saturate在针对低能力着色器硬件和针对S3时使用它会更好

1
2
3
4
5
6
7
inline half DotClamped (half3 a, half3 b) {
  #if (SHADER_TARGET < 30 || defined(SHADER_API_PS3))
      return saturate(dot(a, b));
  #else
      return max(0.0h, dot(a, b));
  #endif
}

因为UnityStandardBRDF已经包括UnityCG和其他一些文件,我们不必再明确地包含UnityCG

1
2
//#include "UnityCG.cginc"
#include "UnityStandardBRDF.cginc"
UnityStandardBRDF引用结构
UnityStandardBRDF引用结构

光源 Light Source

现在在场景中新建一个的方向光,而不是硬编码的光方向。默认情况下,每个Unity场景都需要有一个方向光。方向光被认为是无限远的,它的所有光线都来自完全相同的方向。当然,这在现实生活中是不正确的,但是太阳离地球太远了,光的方向近似平行的。

方向光
方向光

UnityShaderVariables定义float4 _WorldSpaceLightPos0,其中包含当前灯光的位置和光线来自的方向,在定向光的情况下,它有四个分量。因为它们是齐次坐标,所以我们的定向光的第四个分量是不需要的。

1
2
float3 lightDir = _WorldSpaceLightPos0.xyz;
return DotClamped(lightDir, i.normal);

灯光模式 Light Mode

在产生正确的结果之前,我们必须告诉Unity我们想要使用哪些光照数据。我们通过添加一个LightMode变量标记到我们的着色器通道。

需要哪种光照模式取决于我们如何渲染场景。我们可以使用正向或延迟渲染路径,还有两种较旧的渲染模式。可以通过渲染设置选择渲染路径。它位于色彩空间选择的正上方。我们正在使用前向渲染,这是默认设置。

渲染通道
渲染通道
1
2
3
4
5
6
7
8
9
Pass {
    Tags {
        "LightMode" = "ForwardBase"
    }

    CGPROGRAM

    ENDCG
}

漫反射光

光照颜色 Light Color

光并不总是白色的。每个光源都有自己的颜色,我们可以通过fixed4 _LightColor0获取, 它定义在UnityLightingCommon.cginc文件

是什么fixed4?

这些是低精度数字关键字,在移动设备上以精度换取速度。在台式机上,fixed只是float. 精度优化是以后的主题。

_LightColor0变量表示光的颜色乘以光的强度:首先它是有rgba颜色值,同时光组件有一个Intensity强度属性,会改变颜色值的大小。

1
2
3
4
5
6
7
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    i.normal = normalize(i.normal);
    float3 lightDir = _WorldSpaceLightPos0.xyz;
    float3 lightColor = _LightColor0.rgb;
    float3 diffuse = lightColor = DotClamped(lightDir, i.normal);
    return float4(diffuse, 1);
}
光的颜色
光的颜色

反照率 Albedo

大多数物体材料会吸收一部分光,这成了它们的颜色。物体的漫反射率的颜色被称为反照率,它描述了多少红色、绿色、蓝色通道被漫反射,其余的颜色被吸收不反射。我们使用材料的纹理和色调定义它。而Albedo带有白化whiteness的含义,它作为因子控制物体由暗到亮 。

无法逃脱的光会发生什么?

光的能量被储存在物体中,通常以热量的形式存在。这就是为什么黑色的东西往往比白色的东西更温暖。

材料漫反射率的颜色称为其反照率。Albedo在拉丁语中是白色的意思。所以它描述了有多少红色、绿色和蓝色通道被漫反射。其余的被吸收。我们可以使用材质的纹理和色调来定义它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Properties {
    _Tint ("Tint", Color) = (1, 1, 1, 1)
    _MainTex ("Albedo", 2D) = "white" {}
}

float4 MyFragmentProgram (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);
}
albedo-inspector.png
albedo-inspector.png
在伽马和线性空间中使用反照率进行漫反射着色
在伽马和线性空间中使用反照率进行漫反射着色
在伽马和线性空间中使用反照率进行漫反射着色

高光(镜面)反射 Specular Shading

除了漫反射,还有镜面反射。当光线在撞击表面后没有扩散而是直接反射,光线从表面反射回来的角度等于它撞击表面的角度。这就是导致您在镜子中看到的反射的原因。表面需要极其光滑。

观察者的位置对镜面反射很重要:仅当最终反射出的光朝向观察者是可见的,其他都看不见。所以,我们要知道从表面一点到观察者的方向,这就要求表面点和摄像机的世界位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Interpolators {
    float4 position : SV_POSITION;
    float2 uv : TEXCOORD0;
    float3 normal : TEXCOORD1;
    float3 worldPos : TEXCOORD2;//add
}

Interpolators MyVertexProgram (VertexData v) {
    Interpolators i;
    i.position = mul(UNITY_MATRIX_MVP, v.position);
    i.worldPos = mul(unity_ObjectToWorld, v.position);//add
    i.normal = UnityObjectToWorldNormal(v.normal);
    i.uv = TRANSFORM_TEX(v.uv, _MainTex);
    return i;
}

可以通过访问摄像机的位置float3 _WorldSpaceCameraPos定义在UnityShaderVariables.cginc文件,我们发现视图方向从中减去表面位置并进行归一化。

1
2
3
4
5
6
7
8
9
10
11
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    i.normal = normalize(i.normal);
    float3 lightDir = _WorldSpaceLightPos0.xyz;
    float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);//add
    
    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);
}

反射光照颜色 Reflecting Light

要知道反射光的去向,我们可以使用标准reflect函数。它接受入射光线的方向和基于表面法线反射光线,我们要反向调整光的方向。

1
2
3
float3 reflectionDir = reflect(-lightDir, i.normal);//add

return float4(reflectionDir = 0.5 + 0.5, 1);
反射方向
反射方向

reflect内部实现 $D\cdot N\cdot D - 2\cdot N\cdot (N\cdot D)$

假设物体表面极其光滑,我们将只能在表面角度合适的地方看见反射光,在其他地方反射光对观察者不可见并呈现黑色。但实际上物体表面是不平整的,有太多细微的凹凸,这也意味着表面法线差别很大。

尽管我们的观察方向不完全匹配反射方向,我们仍能看见一些反射光。当我们偏离反射方向越多,我们能看见的反射光就越少,所以我们继续约束dot点积值

1
return DotClamped(viewDir, reflectionDir);
镜面反射
镜面反射
镜面反射

光滑度 Smoothness

这种效果产生的高光大小取决于材料的粗糙度。光滑的材料更好地聚焦光线,因此它们的高光较小。我们可以通过将其设为材质属性来控制这种平滑度。它通常定义为 0 到 1 之间的值,所以让我们将其设为滑块。

1
2
3
4
5
6
7
Properties {
    _Tint ("Tint", Color) = (1, 1, 1, 1)
    _MainTex ("Texture", 2D) = "white" {}
    _Smoothness ("Smoothness", Range(0, 1)) = 0.5//add
}

float _Smoothness;//add

我们使用smoothness作为因子,通过提高点积的幂次来缩小光点。但是必须要比1大得多才具有更好的效果。再给因子提高100倍。

1
return pow(DotClamped(viewDir, reflectionDir), _Smoothness * 100);
看着像玉
看着像玉
看着像玉

Blinn-Phong光照公式

上面使用的是Blinn reflection计算公式,但业界更常用Blinn-Phong reflection公式。通过使用一个光照方向和视野方向的半角方向,然后再取法向量和半角向量的点积结果来决定镜面反射。

1
2
3
4
5
6
7
float3 reflectionDir = reflect(-lightDir, i.normal);
float3 halfVector = normalize(lightDir + viewDir);

return pow(
    DotClamped(halfVector, i.normal),
    _Smoothness = 100
);
Blinn-Phong反射
Blinn-Phong反射
Blinn-Phong反射

这种方法会产生更大范围的高光,但这可以通过使用更高的平滑度值来抵消。尽管这两种方法仍然是近似值。一个很大的限制是它可以为从后面照亮的对象产生无效的高光。

specular-error.png
specular-error.png

当使用低平滑度值时,这些伪影会变得很明显。它们可以通过使用阴影来隐藏,或者通过基于光照角度淡出高光来隐藏。Unity 的传统着色器也有这个问题,所以我们也不用担心。无论如何,我们很快就会转向另一种照明方法。

高光颜色 Specular Color

镜面反射的颜色与光源的颜色相匹配,所以增加颜色渲染

1
2
3
4
5
6
7
float3 halfVector = normalize(lightDir + viewDir);
float3 specular = lightColor = pow(//add
    DotClamped(halfVector, i.normal),
    _Smoothness = 100
);

return float4(specular, 1);//add

反射的颜色也取决于材料,这与反照率不同。金属的反照率往往很小,同时具有很强的且通常是彩色的镜面反射率。相比之下,非金属往往具有明显的反照率,而它们的镜面反射率较弱且未着色。

我们可以添加纹理和色调来定义镜面反射颜色,我们不要打扰另一种纹理,只需使用色​​调即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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.1
}

float4 _SpecularTint;
float _Smoothness;

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    float3 halfVector = normalize(lightDir + viewDir);
    float3 specular = _SpecularTint.rgb = lightColor = pow(
        DotClamped(halfVector, i.normal),
        _Smoothness = 100
    );

    return float4(specular, 1);
}

我们可以使用颜色属性来控制镜面反射的着色和强度

有色镜面反射
有色镜面反射
有色镜面反射

漫反射和镜面反射 Diffuse and Specular

漫反射和镜面反射是照明中的两个难点部分。我们将它们加在一起,使我们的画面更加完整。

1
return float4(diffuse + specular, 1);
漫反射加镜面反射 gama vs. linear
漫反射加镜面反射 gama vs. linear
漫反射加镜面反射 gama vs. linear

能量守恒 Energy Conservation

将漫反射和镜面反射加在一起是有问题的,结果可能比光源更亮。当使用低平滑度的全白高光时,这一点非常明显。

很亮的镜面,0.1平滑度
很亮的镜面,0.1平滑度

当光照射到表面时,它的一部分光会作为镜面光反射回来,其余部分要么会穿透表面,要么会作为漫射光返回,要么被吸收。但我们目前没有考虑到这一点。相反,我们的光既反射又漫射。

我们必须确保材质的漫反射和镜面反射部分的总和不能超过1。这保证了我们不会凭空创造光。如果总数小于1就可以了。那意味着部分光被吸收了。

由于我们使用的是恒定的镜面反射色调,我们可以通过将其乘以1减去镜面反射来简单地调整反照率色调。但是手动执行此操作很不方便,特别是如果我们想使用特定的反照率色调。所以让我们在着色器中执行此操作。

1
2
float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
albedo == 1 - _SpecularTint.rgb;//add
不再那么亮
不再那么亮

漫反射和镜面反射的贡献现在是相关联的。高光越强,漫反射部分越暗。黑色镜面色调产生零反射,只会看到全强度的反照率。白色镜面色调会形成完美的镜子,因此完全消除了反照率。

能量守恒

非金属单色 Monochrome

在这种色调模型下,当Specular色调是灰度图时这中方法工作良好。其他颜色就会有奇怪的结果,例如红色Specular色调只会减少漫反射的红色部分(其他颜色被吸收了)。

红色镜面反射,青色反照率
红色镜面反射,青色反照率

为了防止这种着色,我们可以使用单色能量守恒。这只是意味着我们使用镜面反射颜色的最强分量来降低反照率。

To prevent this coloration, we can use monochrome energy conservation. This just means that we use the strongest component of the specular color to reduce the albedo.

1
albedo *= (1 - max(_SpecularTint.r, max(_SpecularTint.g, _SpecularTint.b)));
单色能量守恒
单色能量守恒

辅助函数

Unity有一个实用函数来处理能量守恒EnergyConservationBetweenDiffuseAndSpecular,定义在UnityStandardUtils.cginc

1
2
#include "UnityStandardBRDF.cginc"
#include "UnityStandardUtils.cginc"
UnityStandardBRDF引用结构
UnityStandardBRDF引用结构

此函数将反照率和镜面反射颜色作为输入,并输出调整后的反照率和输出 $1 - \text{reflectivity}$。第三个输出参数是是一个减去镜面反射强度,我们拿来乘以反照率的因子。

1
2
3
4
5
6
7
float3 albedo = tex2D(_MainTex, i.uv).rgb = _Tint.rgb;
albedo *= (1 - max(_SpecularTint.r, max(_SpecularTint.g, _SpecularTint.b)))
    
float oneMinusReflectivity;
albedo = EnergyConservationBetweenDiffuseAndSpecular(
    albedo, _SpecularTint.rgb, oneMinusReflectivity
);

EnergyConservationBetweenDiffuseAndSpecular内部实现

它具有三种模式:无保护、单色或彩色。这些由#define语句控制。默认为单色。

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
half SpecularStrength(half3 specular) {
      #if (SHADER_TARGET < 30)
              // SM2.0: instruction count limitation
              // SM2.0: simplified SpecularStrength
              // Red channel - because most metals are either monochrome
              // or with redish/yellowish tint
              return specular.r;
      #else
              return max(max(specular.r, specular.g), specular.b);
      #endif
}

// Diffuse/Spec Energy conservation
inline half3 EnergyConservationBetweenDiffuseAndSpecular (
      half3 albedo, half3 specColor, out half oneMinusReflectivity
) {
      oneMinusReflectivity = 1 - SpecularStrength(specColor);
      #if !UNITY_CONSERVE_ENERGY
              return albedo;
      #elif UNITY_CONSERVE_ENERGY_MONOCHROME
              return albedo * oneMinusReflectivity;
      #else
              return albedo * (half3(1, 1, 1) - specColor);
      #endif
}

金属工作流程 Metallic Workflow

基本上有两种我们关心的材料。金属和非金属,后者也称为介电材料。目前,我们可以通过使用强烈的镜面反射色调来创建金属。我们可以通过使用弱单色镜面反射来创建电介质。这是镜面反射工作流程。

如果我们可以在金属和非金属之间切换会简单得多。由于金属没有反照率,我们可以使用该颜色数据来代替它们的镜面色调。而且非金属无论如何都没有彩色镜面反射,所以我们根本不需要单独的镜面反射色调。这被称为金属工作流程。

我们可以使用另一个滑块属性作为金属切换,以替换镜面反射色调。通常把它设置为0或1,因为某物要么是金属,要么不是金属。介于两者之间的值表示混合了金属和非金属成分的材料。

1
2
3
4
5
6
7
8
9
10
11
Properties {
    _Tint ("Tint", Color) = (1, 1, 1, 1)
    _MainTex ("Albedo", 2D) = "white" {}
    //_SpecularTint ("Specular", Color) = (0.5, 0.5, 0.5)
    _Metallic ("Metallic", Range(0, 1)) = 0
    _Smoothness ("Smoothness", Range(0, 1)) = 0.1
}

//float4 _SpecularTint;
float _Metallic;
float _Smoothness;
金属滑块
金属滑块

现在我们可以从反照率和金属属性中推导出镜面色调。然后可以简单地将反照率乘以 $1 - \text{metallic}$。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
float3 specularTint = albedo * _Metallic;
float oneMinusReflectivity = 1 - _Metallic;
// albedo = EnergyConservationBetweenDiffuseAndSpecular(
//     albedo, _SpecularTint.rgb, oneMinusReflectivity~
// );
albedo *= oneMinusReflectivity;

float3 diffuse = albedo * lightColor * DotClamped(lightDir, i.normal);

float3 halfVector = normalize(lightDir + viewDir);
float3 specular = specularTint * lightColor * pow(
    DotClamped(halfVector, i.normal),
    _Smoothness = 100
);

即使是纯电介质也有一些镜面反射,因此镜面反射强度和反射值与金属滑块的值不完全匹配,这也受到色彩空间的影响。幸运的是,UnityStandardUtils.cginc定义了DiffuseAndSpecularFromMetallic功能函数。

1
2
3
4
5
6
float3 specularTint; // = albedo = _Metallic;
float oneMinusReflectivity; // = 1 - _Metallic;
//albedo *= oneMinusReflectivity;
albedo = DiffuseAndSpecularFromMetallic(
    albedo, _Metallic, specularTint, oneMinusReflectivity
);

金属工作流

DiffuseAndSpecularFromMetallic内部实现

它使用了half4 unity_ColorSpaceDielectricSpecUnity根据颜色空间设置的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
inline half OneMinusReflectivityFromMetallic(half metallic) {
      // We'll need oneMinusReflectivity, so
      //   1-reflectivity = 1-lerp(dielectricSpec, 1, metallic)
      //                  = lerp(1-dielectricSpec, 0, metallic)
      // store (1-dielectricSpec) in unity_ColorSpaceDielectricSpec.a, then
      //       1-reflectivity = lerp(alpha, 0, metallic)
      //                  = alpha + metallic=(0 - alpha)
      //                  = alpha - metallic = alpha
      half oneMinusDielectricSpec = unity_ColorSpaceDielectricSpec.a;
      return oneMinusDielectricSpec - metallic = oneMinusDielectricSpec;
}

inline half3 DiffuseAndSpecularFromMetallic (
      half3 albedo, half metallic,
      out half3 specColor, out half oneMinusReflectivity
) {
      specColor = lerp(unity_ColorSpaceDielectricSpec.rgb, albedo, metallic);
      oneMinusReflectivity = OneMinusReflectivityFromMetallic(metallic);
      return albedo = oneMinusReflectivity;
}

有一个细节是金属滑块本身应该在伽马空间中,但是在线性空间中渲染时,Unity不会自动校正这个值。我们可以使用该Gamma属性告诉Unity它还应该对我们的金属滑块应用伽马校正。

1
[Gamma]_Metallic ("Metallic", Range(0, 1)) = 0

经过一番调整之后,镜面反射对于非金属来说变得相当模糊,效果不好。为了改善这一点,我们需要一种更好的方法来计算光照。

基于物理着色 Physically-Based Shading

Blinn-Phong方案长期以来一直是游戏行业的主流方案,但现在基于物理的着色(称为 PBS)风靡一时,因为它更加真实和可预测。

Unity 的标准着色器也使用 PBS 方法。Unity 实际上有多种实现。它根据目标平台、硬件和 API 级别决定使用哪个。该算法可通过UNITY_BRDF_PBS宏访问,该宏定义在UnityPBSLighting.cginc,BRDF代表双向反射率分布函数。

1
2
3
#include "UnityStandardBRDF.cginc"
#include "UnityStandardUtils.cginc"
#include "UnityPBSLighting.cginc"
引用层次结构
引用层次结构

UNITY_BRDF_PBS内部实现

UNITY_PBS_USE_BRDF1默认情况下由 Unity 设置,作为平台定义。这将选择最佳着色器,除非着色器目标低于 3.0。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Default BRDF to use:
#if !defined (UNITY_BRDF_PBS)
  // allow to explicitly override BRDF in custom shader
  // still add safe net for low shader models,
  // otherwise we might end up with shaders failing to compile
  #if SHADER_TARGET < 30
      #define UNITY_BRDF_PBS BRDF3_Unity_PBS
  #elif UNITY_PBS_USE_BRDF3
      #define UNITY_BRDF_PBS BRDF3_Unity_PBS
  #elif UNITY_PBS_USE_BRDF2
      #define UNITY_BRDF_PBS BRDF2_Unity_PBS
  #elif UNITY_PBS_USE_BRDF1
      #define UNITY_BRDF_PBS BRDF1_Unity_PBS
  #elif defined(SHADER_TARGET_SURFACE_ANALYSIS)
      // we do preprocess pass during shader analysis and we dont
      // actually care about brdf as we need only inputs/outputs
      #define UNITY_BRDF_PBS BRDF1_Unity_PBS
  #else
      #error something broke in auto-choosing BRDF
  #endif
#endif
UnityPBSLighting的引用结构
UnityPBSLighting的引用结构

这里跳过pbs详细算饭介绍,使用就好了。PBS仍然计算漫反射和镜面反射,与Blinn-Phong不同点在于PBS又一个菲涅耳反射分量计算。为了在以掠射角查看对象时获得反射,就要先确保能获取环境反射。

Unity的BRDF函数返回一个RGBA颜色,alpha分量总是设置为1,所以我们可以直接让我们的片段程序返回它的结果。

1
2
3
4
5
6
7
8
// float3 diffuse = albedo * lightColor * DotClamped(lightDir, i.normal);
// float3 halfVector = normalize(lightDir + viewDir);
// float3 specular = specularTint * lightColor * pow(
//     DotClamped(halfVector, i.normal),
//     _Smoothness * 100
// );

return UNITY_BRDF_PBS();

UNITY_BRDF_PBS有八个参数,前两个是漫反射和镜面反射

1
2
3
return UNITY_BRDF_PBS(
    albedo, specularTint
);

第三第四个参数必须是反射率和粗糙度。这些参数必须是减一的形式,这是一种优化。我们已经使用oneMinusReflectivity计算出来DiffuseAndSpecularFromMetallic。而平滑度是粗糙度的反义词,所以我们可以直接使用它。

1
2
3
4
return UNITY_BRDF_PBS(
    albedo, specularTint,
    oneMinusReflectivity, _Smoothness
);

第五第六个参数是表面法线和观察方向

1
2
3
4
5
return UNITY_BRDF_PBS(
    albedo, specularTint,
    oneMinusReflectivity, _Smoothness,
    i.normal, viewDir
);

最后两个参数是直接光和间接光

光源结构 Light Structures

UnityLightingCommon定义了一个简单的UnityLight结构,Unity着色器使用它来传递光照数据。它包含光的颜色、方向和一个ndotl值:漫反射。

得到这些光源信息把它放到光源结构中,并将它作为第七个参数传递。

1
2
3
4
5
6
7
8
9
10
11
UnityLight light;
light.color = lightColor;
light.dir = lightDir;
light.ndotl = DotClamped(i.normal, lightDir);

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

最后一个参数是间接光。我们必须为此使用UnityIndirect结构,它也定义在UnityLightingCommon. 它包含两种颜色:漫反射和镜面反射。漫反射颜色代表环境光,而镜面反射颜色代表环境反射。

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
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
    i.normal = normalize(i.normal);
    float3 lightDir = _WorldSpaceLightPos0.xyz;
    float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);

    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
    );
}
nonmetal-gamma.png,nonmetal-linear.png
nonmetal-gamma.png,nonmetal-linear.png
nonmetal-gamma.png,nonmetal-linear.png
非金属和金属,gama vs. linear
非金属和金属,gama vs. linear
非金属和金属,gama vs. linear
This post is licensed under CC BY 4.0 by the author.