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

更新时间:2023-04-24 17:33:01 阅读量: 实用文档 文档下载

说明:文章内容仅供预览,部分内容可能不全。下载后的文档,内容与下面显示的完全一致。下载之前请确认下面内容是否您想要的,是否完整无缺。

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

译者:张乾光(星际迷航)审校:崔国军(飞扬971)

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

?使用方向光。

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

?实现能量守恒。

?使用金属的工作流程。

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

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

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

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

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

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

法线

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

什么是整个电磁频谱?

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

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

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

使用网格的法线

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

1 2 3 4 5

Shader "Custom/My First Lighting Shader" {

}

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

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

1 s truct VertexData {

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

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 4 5 float4 MyFragmentProgram (Interpolators i) : SV_TARGET {

return float4(i.normal * 0.5 + 0.5, 1);

}

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

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

动态批次合并

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

有颜色变化的立方体。

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

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

批次合并的设置。

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

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

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

世界坐标空间中的法线

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

Unity 将一个物体的整个变换层次结构折叠成一个单一的变换矩阵,就像我们在第一部分中所做的那样。我们可以将它写为O = T1T2T3 ...其中T 是单独的变换矩阵,而O 是组合变换矩阵。这个矩阵被称为物体空间到世界空间的变换矩阵。 Unity 通过类型为float4x4的unity_ObjectToWorld 变量使这个矩阵在着色器中可用,该变量在UnityShaderVariables 中进行定义。将这个矩阵乘以顶点着色器中的法线数据,以便将数据转换到世界坐标空间。因为它是一个方向,重新定位应该被忽略。所以齐次坐标的第四个分量必须为零。 1 2 3 4 5 6 7 8 9 10 11 12 13 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;

}

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

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

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

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

1 2 3 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

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

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

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

所以,让我们转置世界空间到物体空间的矩阵,并乘以顶点的法线数据。 1 2 3 4 5 6 7 8 9 i.normal = mul(

transpose((float3x3)unity_WorldToObject),

v.normal

);

i.normal = normalize(i.normal);

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

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

1 2 3 4 5 6 7 8 9 10 11 12 13 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 看起来是什么样子?

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

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

2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 inline float3 UnityObjectToWorldNormal( in float3 norm ) {

\\\乘以转置逆矩阵,

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

return normalize(

unity_WorldToObject[0].xyz * norm.x +

unity_WorldToObject[1].xyz * norm.y +

unity_WorldToObject[2].xyz * norm.z

);

}

重新归一化

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

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

1 2 3 4 5 6 7 float4 MyFragmentProgram (Interpolators i) : SV_TARGET {

i.normal = normalize(i.normal);

return float4(i.normal * 0.5 + 0.5, 1);

}

对法线重新进行归一化。

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

这是比较夸张的错误。

漫反射的渲染

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

发生漫反射是因为光线不仅仅从物体表面发生反射。相反,光会穿透表面,反弹一会儿,然后分裂几次,直到它再次离开物体的表面。在现实中,光子和原子之间的相互作用比这更复杂,但我们不需要知道真实世界的物理那么多的细节。 多少光会在物体表面上进行漫反射取决于光线射到物体表面的角度。当光线射到物体表面的角度是0°角,也就是正面碰撞的时候,大多数的光会被反射。 随着光线射到物体表面的角度的增加,

光的漫反射将减小。光线射到物体表面的角度到达90°的时候,就没有光照射到物体的表面,所以物体的表面会保持黑暗。漫反射光的量与光的入射方向和表面法线之间的角度的余弦成正比。这被称为兰伯特余弦定律。

漫反射。

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

1 2 3 float4 MyFragmentProgram (Interpolators i) : SV_TARGET {

i.normal = normalize(i.normal);

4

5 6 7

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

}

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

什么是点积?

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

代数上,它被定义为

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

float dotProduct = v1.x * v2.x + v1.y * v2.y + v1.z * v2.z;

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

点积。

受钳制的光照

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

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

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

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

UnityStandardBRDF 导入文件定义了方便的DotClaped 函数。这个函数会执行一个点积,并确保点积的结果永远不为负。这正是我们需要的。它还包含许多其他光照功能,并会导入其他有用的文件,我们以后会需要这些文件。所以,让我们使用这个导入文件! 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #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);

}

DotClaped 看起来是什么样子?

下面就是它的具体代码。显然,他们决定在面向低性能着色器硬件的时候以及在面向PS3的时候使用saturate 函数。 1 2 3 4 5 6 7 8 9 10 11 12 13 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 和一些其他文件,我们不必显式导入它。这样做是没有错的,但我们也可以保持简短。

// #include "UnityCG.cginc"

#include "UnityStandardBRDF.cginc"

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

光源

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

本文来源:https://www.bwwdw.com/article/zsoq.html

Top