Unity 渲染教程(四):第一个光源

将法线从物体空间转换到世界空间。

使用方向光。

计算漫反射和镜面高光反射。

实现能量守恒。

使用金属的工作流程。

利用Unity的基于物理规则渲染的算法。

这是关于渲染基础的系列教程的第四部分。前面的教程介绍了混合使用多张纹理。这一次,我们将看看如何计算光照。

这个系列教程是使用Unity 5.4.0开发的,这个版本目前还是开放测试版本。我使用的是5.4.0b17版本。

这个教程使用的着色器与前面的教程不匹配?

为了提高兼容性,我已经改变了以前的教程中的着色器。我还在这个系列的第二部分中介绍了着色器的结构,而不是推迟到这个教程才介绍。

现在是时候让光源照射到事物上了。

法线

我们可以看到东西,这是因为我们的眼睛可以检测到电磁辐射。传递电磁相互作用的基本粒子称为光子我们只可以看到电磁光谱的一部分,这是我们所知的可见光。电磁光谱的其余部分我们是看不见的。

什么是整个电磁频谱?

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

光源能够发射光。一些光会击中物体。击中物体的光中的一部分会被物体反射。如果会被物体反射的光最终进入到我们的眼睛-或是相机镜头-然后我们就看到这个物体了。

要做到这一点,我们必须知道我们物体的表面信息。我们已经知道物体的表面的位置,但不知道物体的表面的方向。为了知道我们物体的表面信息,我们需要物体的表面法线向量。

使用网格的法线

复制我们的第一个着色器,并使用复制的第一个着色器的代码来作为我们的第一个光照着色器。使用光照着色器来创建材质,并将其分配给场景中的某些立方体和球体。给对象不同的旋转和尺度,其中一些并不均匀,这样就得到了一个变化的场景。

Shader"Custom/My First Lighting Shader"{

}

场景中的一些立方体和球体。

Unity的立方体和球面网格包含了顶点法线。我们可以得到这些法线信息并将它们直接传递给片段着色器。

structVertexData {

float4 position : POSITION;

float3 normal : NORMAL;

float2 uv : TEXCOORD0;

};

structInterpolators {

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;

returni;

}

现在我们可以在我们的着色器中对法线进行可视化。

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {

returnfloat4(i.normal * 0.5 + 0.5, 1);

}

把法线向量作为颜色表示出来。

这些数据是原始法线数据,直接从网格中得到的。立方体的表面看起来很平,这个因为立方体的每个面是具有四个顶点的独立四边形。这些顶点的法线都指向相同的方向。 相反,球体的顶点的法线都指向不同的方向,这导致了平滑的插值。

动态批次合并

立方体法线发生了一些奇怪的事情。我们期望每个立方体会显示相同的颜色,但事实却不是这样。立方体的法线其实是可以改变颜色的,这取决于我们如何看这些立方体。

有颜色变化的立方体。

这个问题是由动态批次合并引起的。Unity动态地将小的网格合并在一起,以减少绘制调用。球体的网格对动态批次合并而言太大了,因此球体的网格不受影响。 但是动态批次合并会对立方体有影响。

要合并网格的话,这些网格必须从它们的本地空间转换到世界空间。是否以及如何对对象进行批次合并取决于其他因素,比如说如何对这些对象进行排序以便进行渲染。因为这种转换也会影响法线,所以这就是为什么我们会看到颜色的变化。

如果需要的话,你可以通过播放器的设置来关闭动态批次合并。

批次合并的设置。

除了动态批次合并以外,Unity还可以进行静态批次合并。这涉及对静态几何体的处理,所以静态批次合并和动态批次合并的工作原理不同,但也涉及到世界空间的转换。行静态批次合并发生在构建的时候。

在没有动态批次合并时候的法线数据。

虽然你需要对动态批次合并有了解,但是这其实没有什么可担心的。事实上,我们必须对我们的法线做同样的事情。所以你可以启用动态批次合并。

世界坐标空间中的法线

除了被动态批次合并的对象以外,我们所有的法线都在物体空间之中。但是我们必须知道世界坐标空间中的表面的方向。因此,我们必须将法线从物体空间转换到世界坐标空间。为了做到这一点,我们需要物体的转换矩阵信息。

Unity将一个物体的整个变换层次结构折叠成一个单一的变换矩阵,就像我们在第一部分中所做的那样。我们可以将它写为O = T1T2T3 ...其中T是单独的变换矩阵,而O是组合变换矩阵。这个矩阵被称为物体空间到世界空间的变换矩阵。

Unity通过类型为float4x4的unity_ObjectToWorld变量使这个矩阵在着色器中可用,该变量在UnityShaderVariables中进行定义。将这个矩阵乘以顶点着色器中的法线数据,以便将数据转换到世界坐标空间。因为它是一个方向,重新定位应该被忽略。所以齐次坐标的第四个分量必须为零。

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);

returni;

}

或者,我们可以只对矩阵的3×3的部分做乘法运算。编译出来的代码最终是一样的,因为编译器会去掉所有与常数零相乘的东西。

1

i.normal = mul((float3x3)unity_ObjectToWorld, v.normal);

从物体空间变换到世界坐标空间。

法线现在处于世界坐标空间,但有些发现看起来比别的发现更亮。这是因为他们也进行了缩放。因此,我们必须在转换后对法线进行归一化。

i.normal = mul(unity_ObjectToWorld, float4(v.normal, 0));

i.normal = normalize(i.normal);

归一化后的法线。

虽然我们再次对向量进行了归一化,但对于没有均匀大小的对象来说,它们看起来很奇怪。这是因为当表面在一个维度上进行拉伸的时候,这个表面的法线不会以相同的方式进行拉伸。

在X轴进行缩放,顶点和法线都变为½。

当大小不均匀的时候时,应该对法线进行取逆操作。这样,当它们被再次被归一化后,法线将匹配变形的曲面的形状。而这对于均匀尺度来说没有影响。

在X轴进行缩放,顶点变为½,而法线加倍。

所以我们必须对大小进行取逆操作,但旋转应该保持不变。那么我们应该怎么做?

我们将对象的变换矩阵描述为O = T1T2T3 ...但我们可以更加具体一些。我们知道层次结构中的每个步骤都结合了缩放、旋转和位移。因此每个T可以分解为SRP。

这意味着O=S1R1P1S2R2P2S3R3P3…,但是为了方便起见,让我们假设说O=S1R1P1S2R2P2。

因为法线是方向向量,所以我们不关心重新定位的问题。所以我们可以进一步简化到O=S1R1S2R2,而且我们只需要考虑3×3的矩阵。

我们想要对缩放取逆,但同时保持旋转不变。所以我们想要一个新的矩阵N = S-11R1S-12R2。

如何对矩阵取逆?

矩阵M的逆写作。 它也是一个矩阵,当它们相乘的时候,将抵消另外一个矩阵带来的操作。互相是对方矩阵的逆。所以。

要抵消一系列步骤带来的影响,必须以相反的顺序执行相反的步骤。这方面的助记符涉及一些规则。这意味着。

对于单个数x的情况,它的逆更加简单的,这是因为。这也表明零没有逆元。也不是每个矩阵都具有相应的逆矩阵。

我们正在使用缩放、旋转和重新定位矩阵。只要我们不把矩阵缩放为零,所有这些矩阵可以取逆。

位移矩阵的逆矩阵是通过简单地对其第四列中的XYZ分量取负来得到的。

缩放矩阵的逆矩阵是通过对它的对角线上的分量取倒数得到的,我们只需要考虑3×3的矩阵。

旋转矩阵可以每次针对一个轴进行考虑,例如考虑围绕Z轴的情况。 旋转z弧度的操作可以通过简单旋转-z弧度的操作来抵消。当你研究正弦和余弦波的时候,你会注意到sin(-z)= - sinz和cos(-z)= cosz。 这使得旋转矩阵的逆矩阵非常简单。

需要注意的是,旋转矩阵的的逆矩阵在其主对角线上的分量与原始矩阵相同。只有正弦分量的正负发生了变化。

除了物体空间到世界空间的变换矩阵意外,Unity还提供了一个世界空间到物体空间的变换矩阵。这些矩阵实际上是彼此的逆矩阵。所以我们得到这么一个公式。

这给出了我们需要的缩放矩阵的逆矩阵,但也给了我们旋转矩阵和位移矩阵的逆矩阵。幸运的是,我们可以通过转置矩阵来移除那些我们不需要的效果。 然后我们得到。

什么是矩阵的转置?

矩阵M的转置被写为。通过翻转矩阵的主对角线上的变量来对矩阵进行转置。因此它的行会成为转置矩阵的列,它的列会成为转置矩阵的行。需要注意的是,这意味着对角线上的变量本身保持不变。

像逆矩阵一样,对矩阵乘法进行转置会反转其顺序。。当对不是方阵的矩阵使用的时候,这是有意义的,否则可能会导致无效的乘法。 但是一般来说,这个等式是成立的,你可以查找下它的证明。

当然转置两次会让你得到最初的结果。所以。

所以,让我们转置世界空间到物体空间的矩阵,并乘以顶点的法线数据。

i.normal = mul(

transpose((float3x3)unity_WorldToObject),

v.normal

);

i.normal = normalize(i.normal);

正确的世界坐标空间的法线。

实际上,UnityCG包含一个方便的UnityObjectToWorldNormal函数,正是做这个工作。所以我们可以使用那个函数。它也使用显式的矩阵乘法,而不是使用矩阵转置。这应该会生成更好的编译代码。

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);

returni;

}

UnityObjectToWorldNormal看起来是什么样子?

这里就是UnityObjectToWorldNormal的代码了。inline关键字不起任何作用。

\\\ 将法线从物体空间变换到世界坐标空间。

inline float3 UnityObjectToWorldNormal(infloat3 norm ) {

\\\乘以转置逆矩阵,

\\\ 实际上使用transpose()来生成高度优化的代码。

returnnormalize(

unity_WorldToObject[0].xyz * norm.x +

unity_WorldToObject[1].xyz * norm.y +

unity_WorldToObject[2].xyz * norm.z

);

}

重新归一化

在顶点程序中产生正确的法线之后,正确的法线值会通过内插值器。不幸的是,在不同单位长度的向量之间进行线性内插不会生成另外一个单位长度的向量。它会比单位长度的向量要小一些。

所以我们必须在片段着色器中再次对法线进行归一化。

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {

i.normal = normalize(i.normal);

returnfloat4(i.normal * 0.5 + 0.5, 1);

}

对法线重新进行归一化。

虽然对法线重新进行归一化可以产生更好的结果,但这两者之间的误差通常非常小。如果你更重视性能的话,你可以决定不在片段着色器里面再次进行归一化。这是移动设备中常见的优化。

这是比较夸张的错误。

漫反射的渲染

我们如果看到的物体它本身不是光源的话,那么就是因为它们反射光我们才能看到这个物体。可能有不同的方式来发生反射。让我们先考虑漫反射

发生漫反射是因为光线不仅仅从物体表面发生反射。相反,光会穿透表面,反弹一会儿,然后分裂几次,直到它再次离开物体的表面。在现实中,光子和原子之间的相互作用比这更复杂,但我们不需要知道真实世界的物理那么多的细节。

多少光会在物体表面上进行漫反射取决于光线射到物体表面的角度。当光线射到物体表面的角度是0°角,也就是正面碰撞的时候,大多数的光会被反射。 随着光线射到物体表面的角度的增加,光的漫反射将减小。光线射到物体表面的角度到达90°的时候,就没有光照射到物体的表面,所以物体的表面会保持黑暗。漫反射光的量与光的入射方向和表面法线之间的角度的余弦成正比。这被称为兰伯特余弦定律。

漫反射。

我们可以通过计算表面法线向量和光的入射方向的点积来确定这个兰伯特反射系数。我们已经知道了表面法线向量,但还不知道是光的方向。让我们从一个固定的光线方向开始,从垂直上方入射开始。

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {

i.normal = normalize(i.normal);

returndot(float3(0, 1, 0), i.normal);

}

从上面照亮的效果,在伽马空间和线性空间的对比结果。

什么是点积?

两个向量之间的点积在几何上定义为A·B = || A || || B || cosθ。 这意味着两个向量之间的点积是矢量之间的角度的余弦乘以它们的长度。因此,在两个单位向量的情况下,A·B =cosθ。

代数上,它被定义为

这意味着你可以通过乘以所有分量对并对它们进行求和来计算它。

floatdotProduct = v1.x* v2.x+ v1.y* v2.y+ v1.z* v2.z;

在视觉上,这个操作将一个向量直接向下投射到另一个向量之上。好像在上面做了一个投影。通过这样做,你会得到一个直角三角形,其底边的长度是点积的结果。如果两个向量都是单位长度向量的话,那么结果就是它们的角度的余弦。

点积。

受钳制的光照

计算点积的工作原理是当表面的法线向量指向光的入射方向的时候,而不是表面的法线向量指向远离光的入射方向的时候。在这种情况下,表面将在逻辑上处于其自身的阴影中,并且它应该根本不接收光。由于光的入射方向和表面法线之间的角度在这一点上必须大于90°,所以其余弦和点积变为负。由于我们不想要负光,我们必须钳制结果。我们可以使用标准的最大函数来做这个事情。

1

returnmax(0, dot(float3(0, 1, 0), i.normal));

除了max函数以外,你会经常看到着色器使用saturate函数进行代替。这个标准函数在把结果限制在0和1之间。

1

returnsaturate(dot(float3(0, 1, 0), i.normal));

这似乎是不必要的,因为我们知道我们的点积将永远不会产生大于1的结果。但是,在某些情况下,它实际上可以更高效,这取决于硬件的实现。但是我们不应该担心这种比较小的优化。事实上,我们可以将这个事情委托给Unity的开发人员。

UnityStandardBRDF导入文件定义了方便的DotClaped函数。这个函数会执行一个点积,并确保点积的结果永远不为负。这正是我们需要的。它还包含许多其他光照功能,并会导入其他有用的文件,我们以后会需要这些文件。所以,让我们使用这个导入文件!

#include "UnityCG.cginc"

#include "UnityStandardBRDF.cginc"

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {

i.normal = normalize(i.normal);

returnDotClamped(float3(0, 1, 0), i.normal);

}

DotClaped看起来是什么样子?

下面就是它的具体代码。显然,他们决定在面向低性能着色器硬件的时候以及在面向PS3的时候使用saturate函数。

inline half DotClamped (half3 a, half3 b) {

#if (SHADER_TARGET < 30 || defined(SHADER_API_PS3))

returnsaturate(dot(a, b));

#else

returnmax(0.0h, dot(a, b));

#endif

}

这个着色器使用半精度的数字,但是你不需要担心数字的精度。它只对移动设备有所帮助。

因为UnityStandardBRDF已经导入了UnityCG和一些其他文件,我们不必显式导入它。这样做是没有错的,但我们也可以保持简短。

//           #include "UnityCG.cginc"

#include "UnityStandardBRDF.cginc"

导入文件的层次结构,从UnityStandardBRDF文件开始。

光源

为了不对光的入射方向进行硬编码,我们应该使用在我们的场景中的光的入射方向。在默认情况下,每个Unity场景都有一个表示太阳的光源。它是一个方向光,这意味着它被认为是无限远的。结果就是,场景中所有的光线来自完全相同的方向。当然,在现实生活中这不是真的,但太阳是如此的远,以至于这是一个很棒的近似。

默认场景中的光源。

UnityShaderVariables里面定义了float4_WorldSpaceLightPos0,其中包含了当前光源的位置。或者在定向光的情况下光线来自的方向。光源的位置有四个分量,因为这些是齐次坐标。因此,我们的方向光的第四个分量是0。

1

2

3float3 lightDir = _WorldSpaceLightPos0.xyz;

returnDotClamped(lightDir, i.normal);

光的模式

在这产生正确的结果之前,我们必须告诉Unity我们要使用哪个光源的数据。 我们可以通过添加一个LightMode标签到我们的着色器进行这个传递。

我们需要哪种光模式取决于我们如何渲染场景。我们可以使用前向渲染路径或是延迟渲染路径。还有两种比较老的渲染模式,但我们不会去讲述。你可以通过播放器渲染设置来选择渲染路径。它位于颜色空间选择的正上方。我们使用的是前向渲染,这是默认值。

渲染路径的选择。

我们必须使用ForwardBase通道。这是当使用前向渲染路径渲染某个物体的时候使用的第一遍渲染。它让我们可以访问场景的主要方向光。它还设置了一些其他的东西,但我们会在后来的内容讲述这些。

Pass {

Tags {

"LightMode"="ForwardBase"

}

CGPROGRAM

ENDCG

}

光的颜色

当然光不总是白色的。每个光源都有自己的颜色,我们可以通过fixedL _LightColor0变量获得光源的颜色,光源的颜色是在UnityLightingCommon之中进行定义的。

什么是fixed4?

这些是低精度数字,其在移动设备上用降低精度来提高速度。在桌面电脑上,fixed是float的别名。精度优化会是以后文章的主题。

这个变量包含光的颜色,乘以其强度。虽然它提供了所有四个通道,但是我们只需要RGB分量。

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);

returnfloat4(diffuse, 1);

}

有了颜色以后的光。

反射率

大多数材质会吸收部分电磁谱。这给了这些材质颜色。举个简单的例子来说,如果所有可见的红色频率被吸收的话,则逸出光将表现为青色。

没有逸出的光会发生什么?

光的能量存储在物体之中,通常会转化为热量。这就是为什么黑色的物体往往比白色的物体更暖和。

材质的漫反射率的颜色被称为材质的反射率。反射率是一个来自拉丁语的单词。因此,它描述了有多少红色、绿色和蓝色被漫反射。其余的部分被吸收。 我们可以使用材质的纹理和色调来定义材质的反射率。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);

returnfloat4(diffuse, 1);

}

让我们在检查器中将主纹理的标签更改为Albedo。

Properties {

_Tint ("Tint", Color) = (1, 1, 1, 1)

_MainTex ("Albedo", 2D) ="white"{}

}

具有反射率的漫反射渲染效果,在伽马空间和线性空间之中的对比。

镜面高光着色

除了漫反射之外,还有镜面高光反射。当光在击中表面后不发生漫反射的时候,就会发生镜面高光反射的情况。光线以等于其撞击表面的角度反射离开表面。这就是导致你在镜子中看到反射的原因。

与漫反射不同,观察者的位置对于镜面高光反射是非常重要的。只有最终直接反射到你眼睛的光才是可见的。其余的都在别的地方,所以你不会看到它。

所以我们需要知道从表面到观察者的方向。这需要表面和相机的世界空间位置。

我们可以通过物体空间-世界空间的矩阵来确定顶点程序中曲面的世界位置,然后将其传递给片段着色器程序。

structInterpolators {

float4 position : SV_POSITION;

float2 uv : TEXCOORD0;

float3 normal : TEXCOORD1;

float3 worldPos : TEXCOORD2;

};

Interpolators MyVertexProgram (VertexData v) {

Interpolators i;

i.position = mul(UNITY_MATRIX_MVP, v.position);

i.worldPos = mul(unity_ObjectToWorld, v.position);

i.normal = UnityObjectToWorldNormal(v.normal);

i.uv = TRANSFORM_TEX(v.uv, _MainTex);

returni;

}

可以通过float3 _WorldSpaceCameraPos来访问摄像机的位置,这在UnityShaderVariables中进行定义。我们找到视线的方向减去表面位置并进行归一化。

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 diffuse =

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

returnfloat4(diffuse, 1);

}

不要在Unity的着色器内部对视线方向进行插值?

是的。Unity的着色器计算顶点程序中的视线方向并对其进行插值。在片段程序中或在针对能力较弱的硬件的顶点程序中会进行归一化。这两种方法都很好。

反射光

要知道反射的光在哪里,我们可以使用标准反射函数。它要获取入射光线的方向并基于表面法线对它进行反射。所以我们必须对我们的光的入射方向取反。

float3 reflectionDir = reflect(-lightDir, i.normal);

returnfloat4(reflectionDir * 0.5 + 0.5, 1);

反射方向。

如果计算向量的反射向量?

通过计算D-2N(N·D)可以得出方向D被法线N反射以后的向量。

在一个完美光滑的镜子的情况下,我们只能在表面角度恰到好处的情况下看到反射光。在所有其他地方,反射光会错过我们,表面会显得黑色。但物体不可能是完美光滑的。它们会有很多微小的隆起,这意味着表面法线可能有很大的变化。

所以即使我们的视线方向不完全匹配反射方向的情况下,我们也可以看到一些反射,我们的视线方向越偏离反射方向,我们看到的光会越少。再次,我们可以使用受限制的点积来计算出有多少光线会到达我们的眼睛。

1

returnDotClamped(viewDir, reflectionDir);

镜面高光反射。

平滑度

由这个效果产生的高亮的大小尺寸取决于材质的粗糙度。光滑的材质会更好的聚焦光线,所以他们有较小的亮点。我们可以通过将材质的粗糙度成为材质的特性来控制这种平滑度。它通常被定义为0和1之间的值,所以让我们使它成为一个滑块。

Properties {

_Tint ("Tint", Color) = (1, 1, 1, 1)

_MainTex ("Texture", 2D) ="white"{}

_Smoothness ("Smoothness", Range(0, 1)) = 0.5

}

float_Smoothness;

我们通过将点积乘以一个更高的指数来缩小高光。我们使用的平滑度值来做这个事情,但它必须远远大于1,以获得所需的效果。所以,让我们把平滑度值乘以100。

returnpow(

DotClamped(viewDir, reflectionDir),

_Smoothness * 100

);

极度光滑的效果。

Blinn-Phong

我们目前正在根据Blinn反射模型来计算光的反射。但最常用的反射模型是Blinn-Phong。它使用的是光的入射方向和视线方向之间的半矢量。法线和半矢量之间的点积可以确定镜面高光的贡献。

//                float3 reflectionDir = reflect(-lightDir, i.normal);

float3 halfVector = normalize(lightDir + viewDir);

returnpow(

DotClamped(halfVector, i.normal),

_Smoothness * 100

);

Blinn-Phong模型计算出来的高光。

这种方法可以产生更大的高亮效果,但是可以通过使用更高的平滑度值来抵消。结果证明在视觉上比Phong模型能更好的接近现实,虽然这两种方法仍然是对现实的近似。一个很大的限制是,它可以为从后面照亮的物体产生无效的高光。

光滑度值为0.01的情况下,得到的不正确的高光结果。

当使用比较低的平滑度值的时候,这些瑕疵变得非常明显。它们可以通过使用阴影或是通过基于光的入射方向来淡出镜面高光来进行隐藏。Unity自带的着色器也有这个问题,所以我们也不用担心这个问题。我们很快就会转向另一种光照方法。

镜面高光颜色

当然,镜面高光反射的颜色要与光源的颜色相匹配。所以让我们考虑一下。

float3 halfVector = normalize(lightDir + viewDir);

float3 specular = lightColor * pow(

DotClamped(halfVector, i.normal),

_Smoothness * 100

);

returnfloat4(specular, 1);

但这不是全部内容。反射的颜色也取决于材质。这和反射率不一样。金属倾向于具有非常低的(如果有的话)反射率,同时具有比较强的和经常带有颜色的镜面高光反射率。相比之下,非金属倾向于具有不同的反照率,而它们的镜面高光反射率较弱且不带有颜色。

我们可以添加一个纹理和色调来定义镜面高光颜色,就像我们对反射率做的一样。但是让我们不使用另外一个纹理,而是只使用色调。

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

);

returnfloat4(specular, 1);

}

我们可以用颜色属性控制镜面高光反射的颜色和强度。

带有颜色的镜面高光反射。

我们不能使用色调的透明度作为平滑度吗?

这当然是可能的。你也可以以这种方式在单个纹理中存储镜面高光颜色和平滑度。

漫反射和镜面高光

漫反射和镜面高光反射是光照拼图的两个部分。我们可以将它们添加在一起,使我们的图片更加的完整。

1

returnfloat4(diffuse + specular, 1);

漫反射加镜面反射,分别在伽马空间和线性空间的效果。

能量守恒

但是这么做回有一个问题,只是添加漫反射和镜面高光反射的结果在一起的话,得到的反射结果可以比光源的入射光更亮。当使用完全白色的镜面高光结合低平滑度的时候,这种情况是非常明显的。

白色镜面高光外加0.1的光滑度。得到的反射结果太亮了。

当光撞击物体的表面的时候,它的一部分作为镜面高光被反射。其余的部分会穿透表面,并作为漫射光返回或被吸收。但我们目前不考虑这一点。相反,我们的入射光既会100%反射也会进行100%的漫反射。因此,我们最终将光的反射能量加倍了。

我们必须确保材质的漫反射和镜面高光反射部分的总和不超过1。这保证了我们不会无中生有的创造光。如果总和小于1是很好的。这意味着部分光被吸收了。

当我们使用一个恒定的镜面高光色调的时候,我们可以简单地通过乘以1减去镜面高光来调整反射率的色调。但是这是手动的方法,非常的不方便,特别是如果我们想使用一个特定的反照率色调。所以让我们在着色器中这样做。

float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;

albedo *= 1 - _SpecularTint.rgb;

得到的结果不再太亮。

漫反射和镜面高光的贡献现在进行了链接。镜面高光反射越强,漫反射的部分就会越弱。黑色镜面高光色调会产生零反射,在这种情况下,你会看到反射率到达全强度。白色镜面高光色调会导致完美的镜子,所以反照率被完全的消除。

单色

当镜面高光色调是灰度颜色的时候,这种方法可以正茶馆工作。但是当使用其他颜色的时候,它会产生奇怪的结果。举个简单的例子来说,红色镜面高光色调将仅减少漫反射部分的红色分量。结果就是,反射率将带有青色色调。

红色镜面高光,青色反照率的效果图。

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

albedo *= 1 -

max(_SpecularTint.r, max(_SpecularTint.g, _SpecularTint.b));

单色的能量守恒。

功能函数

正如你可能期望的那样,Unity有一个功能函数来负责能量守恒。它是EnergyConservationBetweenDiffuseAndSpecular并且在UnityStandardUtils之中进行定义。

#include "UnityStandardBRDF.cginc"

#include "UnityStandardUtils.cginc"

导入文件的层次结构,从UnityStandardUtils文件开始。

这个函数使用反射率和镜面高光颜色作为输入,并输出调整后的反射率。但它也有第三个输出参数,称为一个负的反射率。这是用1减去镜面高光强度,我们用来乘以反射率的一个因子。这是一个额外的输出,因为反射率也需要其他光照计算。

float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;

//                albedo *= 1 -

//                    max(_SpecularTint.r, max(_SpecularTint.g, _SpecularTint.b));

floatoneMinusReflectivity;

albedo = EnergyConservationBetweenDiffuseAndSpecular(

albedo, _SpecularTint.rgb, oneMinusReflectivity

);

EnergyConservationBetweenDiffuseAndSpecular函数是什么样子的?

这里就是它的代码了。它有三种模式,不进行能量守恒、单色能量守恒或是彩色能量守恒。这些模型是由#define语句控制的。默认为单色能量守恒。

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

returnspecular.r;

#else

returnmax(max(specular.r, specular.g), specular.b);

#endif

}

\\Diffuse/Spec Energy conservation

inline half3 EnergyConservationBetweenDiffuseAndSpecular (

half3 albedo, half3 specColor,outhalf oneMinusReflectivity

) {

oneMinusReflectivity = 1 - SpecularStrength(specColor);

#if !UNITY_CONSERVE_ENERGY

returnalbedo;

#elif UNITY_CONSERVE_ENERGY_MONOCHROME

returnalbedo * oneMinusReflectivity;

#else

returnalbedo * (half3(1, 1, 1) - specColor);

#endif

}

金属的工作流程

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

如果我们可以在金属和非金属之间切换,这将更简单。由于金属没有反射率,我们可以使用它的镜面高光色调的颜色数据。因为非金属没有一个彩色的镜面高光,所以我们不需要一个单独的镜面高光色调。这被称为金属的工作流程。

哪个工作流会更好一点?

这两种方法都很好。这就是为什么Unity为每个工作流都有一个标准的着色器。金属的工作流程更简单,因为你只要有一个颜色源外加一个滑块就可以了。这足以创建现实的材料。镜面高光的工作流可以产生相同的结果,但是因为你有更多的控制,不创造切实际的材质也是可能的。

我们可以使用另一个滑块属性来作为金属切换,以替换镜面高光色调。通常情况下,它应该设置为0或1,因为某个物体或者是金属或者不是金属。如果是0到1其中的一个值表示的是具有金属和非金属成分的混合物材质。

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减去金属属性值。

float3 specularTint = albedo * _Metallic;

floatoneMinusReflectivity = 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也有DiffuseAndSpecularFromMetallic函数,它为我们处理这个问题。

float3 specularTint;// = albedo * _Metallic;

floatoneMinusReflectivity;// = 1 - _Metallic;

//                albedo *= oneMinusReflectivity;

albedo = DiffuseAndSpecularFromMetallic(

albedo, _Metallic, specularTint, oneMinusReflectivity

);

DiffuseAndSpecularFromMetallic是什么样子?

下面就是这个函数的代码了。需要注意的是,它使用的是half4 unity_ColorSpaceDielectricSpec这个变量,这是由Unity基于颜色空间进行设置的。

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;

returnoneMinusDielectricSpec - metallic * oneMinusDielectricSpec;

}

inline half3 DiffuseAndSpecularFromMetallic (

half3 albedo, half metallic,

outhalf3 specColor,outhalf oneMinusReflectivity

) {

specColor = lerp(unity_ColorSpaceDielectricSpec.rgb, albedo, metallic);

oneMinusReflectivity = OneMinusReflectivityFromMetallic(metallic);

returnalbedo * oneMinusReflectivity;

}

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

1

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

不幸的是,现在对于非金属来说,镜面高光反射现在变得相当模糊。为了改善这一点,我们需要一个更好的方法来计算光照。

基于物理规则的渲染

Blinn-Phong模型长期以来一直广泛应用于游戏产业,但是现在基于物理规则的渲染-被称为PBS-是整个行业的热点。而且有很好的理由来支持这一点,因为基于物理规则的渲染更接近现实,也更容易预测。在理想情况下,游戏引擎和建模工具都使用相同的渲染算法。这使得内容创建更容易。游戏行业正在慢慢地向一个标准的基于物理规则的渲染实现靠近。

Unity的标准着色器也使用基于物理规则的渲染方法。Unity实际上有多个实现。它会根据目标平台、硬件和API级别来决定使用哪个。这个算法可以通过UNITY_BRDF_PBS宏进行访问,这个宏在UnityPBSLighting中进行定义。而BRDF代表的是双向反射分布函数。

//            #include "UnityStandardBRDF.cginc"

//            #include "UnityStandardUtils.cginc"

#include "UnityPBSLighting.cginc"

部分导入文件的层次结构,从UnityPBSLighting文件开始。

UNITY_BRDF_PBS是什么样子的?

它定义了Unity的双向反射分布函数的别名。UNITY_PBS_USE_BRDF1由Unity进行默认设置,会根据平台进行定义。这将选择最好的着色器,除非目标着色器低于3.0。

//  默认使用的双向反射分布函数。

#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

我没有包括实际的函数实现,因为它们很大。你可以通过下载Unity的导入文件,或是通过查找你的Unity安装中的文件来查看它们。它们的位置在UnityStandardBRDF。

这些函数主要是和数学相关,所以我不会深入它们的实现细节。它们仍然计算的是漫反射和镜面高光反射,只是使用的是一个稍微不同于Blinn-Phong模型的方式。除此之外,还有一个菲涅尔反射分量。这增加了在以掠射角观察物体时获得的反射。一旦我们的场景中包括了环境反射,这些将变得显而易见。

为了确保Unity选择最好的双向反射分布函数函数,我们必须至少定位着色器级别高于3.0。我们用pragma语句做到这一点。

CGPROGRAM

#pragma target 3.0

#pragma vertex MyVertexProgram

#pragma fragment MyFragmentProgram

Unity的双向反射分布函数函数返回一个RGBA颜色,透明度通道分量总是设置为1。所以我们可以直接让我们的片段程序返回其结果。

//                float3 diffuse =

//                    albedo * lightColor * DotClamped(lightDir, i.normal);

//                float3 halfVector = normalize(lightDir + viewDir);

//                float3 specular = specularTint * lightColor * pow(

//                    DotClamped(halfVector, i.normal),

//                    _Smoothness * 100

//                );

returnUNITY_BRDF_PBS();

当然,我们必须用参数调用它。每个函数有八个参数。前两个参数是材质的漫反射和镜面高光颜色。我们已经有了这两个参数。

returnUNITY_BRDF_PBS(

albedo, specularTint

);

接下来的两个参数必须是反射率和粗糙度。这些参数必须使用的是一减去某个值的形式,这是一个优化。我们已经从DiffuseAndSpecularFromMetallic中获得了一个MinusReflectivity。 平滑度与粗糙度相反,因此我们可以直接使用它。

returnUNITY_BRDF_PBS(

albedo, specularTint,

oneMinusReflectivity, _Smoothness

);

当然我们还需要表面法线方向和视线方向。这些会成为第五个和第六个参数。

returnUNITY_BRDF_PBS(

albedo, specularTint,

oneMinusReflectivity, _Smoothness,

i.normal, viewDir

);

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

光数据的结构

UnityLightingCommon定义了Unity着色器用来传递光数据的简单UnityLight结构。它包含了光的颜色、光的方向和ndotl值,这是漫反射术语。请记住,这些结构纯粹是为了我们的方便。它不会影响编译的代码。

我们有所有这些信息,所以我们要做的是把它放在一个光数据的结构里面,并把它作为第七个参数。

UnityLight light;

light.color = lightColor;

light.dir = lightDir;

light.ndotl = DotClamped(i.normal, lightDir);

returnUNITY_BRDF_PBS(

albedo, specularTint,

oneMinusReflectivity, _Smoothness,

i.normal, viewDir,

light

);

为什么光的数据要包括漫反射项?

由于双向反射分布函数具有他们自己需要参与计算的所有信息,为什么我们还必须提供这些信息?这是因为光数据的结构也用在其他上下文中。

实际上,GGX的双向反射分布函数版本甚至不使用ndotl。 它自己计算这个值,用来取代表面发现向量。和往常一样,着色器编译器会去掉所有未使用的代码。所以你不必担心。

最后要讨论的是间接光。我们必须使用UnityIndirect结构,这也是在UnityLightingCommon中定义的。它包含两种颜色,一种是漫反射,另一种是镜面高光反射。漫反射颜色表示的设计环境光,而镜面高光反射颜色表示的是环境反射。

稍后我们将介绍间接光,因此现在只需将这些颜色设置为黑色。

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;

floatoneMinusReflectivity;

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;

returnUNITY_BRDF_PBS(

albedo, specularTint,

oneMinusReflectivity, _Smoothness,

i.normal, viewDir,

light, indirectLight

);

}

在伽马空间和线性空间,金属和非金属的效果对比图。

这个系列的下一篇教程是关于使用多个光源进行光照的。

推荐阅读更多精彩内容