【跟着catlikecoding学渲染#3】光照模型与PBR
将法线从对象转换为世界空间。使用定向光源。计算漫反射和镜面反射。强制节能。使用金属工作流。利用 Unity 的 PBS 算法。
一,Normals
我们可以看到事物,因为我们的眼睛可以探测到电磁辐射。光的单个量子被称为光子。我们可以看到电磁波谱的一部分,我们知道它是可见光。光谱的其余部分对我们来说是看不见的。
光源发光。其中一些光线击中物体。其中一些光从物体上反弹。如果这些光最终照射到我们的眼睛或相机镜头上,那么我们就会看到这个物体。 为了解决这一切问题,我们必须知道我们物体的表面。我们已经知道它的位置,但不知道它的方向。为此,我们需要表面法向量。
1.1 Using Mesh Normals
复制我们的第一个着色器,并将其用作我们的第一个照明着色器。使用此着色器创建材质,并将其分配给场景中的某些立方体和球体。为对象提供不同的旋转和比例,有些是不均匀的,以获得不同的场景。
我们把顶点法线直接传递给片元着色器
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;
}
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
return float4(i.normal * 0.5 + 0.5, 1);
}
然后我们就可以在着色器中可视化法线
这些是原始法线,直接来自网格。立方体的面显示为平面,因为每个面都是具有四个顶点的单独四边形。这些顶点的法线都指向同一方向。相反,球体的顶点法线都指向不同的方向,从而产生平滑的插值。
1.2 Dynamic Batching
这些颜色由于这是由动态批处理引起的。Unity 将小网格动态合并在一起,以减少绘制调用。球体的网格太大,因此它们不受影响。
要合并网格,必须将它们从其局部空间转换为世界空间。是否以及如何对对象进行批处理取决于对象的排序方式等因素。由于这种转换也会影响法线,这就是为什么我们看到颜色发生变化的原因。
如果需要,可以通过设置关闭动态批处理
除了动态批处理,Unity还可以进行静态批处理。这对于静态几何体的工作方式不同,但也涉及到世界空间的转换。它发生在构建时。
1.3 Normals in World Space
除了动态批处理的对象之外,我们所有的法线都在对象空间中。但我们必须知道世界空间中的表面方向。因此,我们必须将法线从物体转换为世界空间。为此,我们需要对象的转换矩阵。
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);
return i;
}
法线现在在世界空间中,但有些看起来比其他的更亮。那是因为他们也被扩大了规模。因此,我们必须在转换后使它们正常化。
i.normal = mul(unity_ObjectToWorld, float4(v.normal, 0));
i.normal = normalize(i.normal);
虽然我们再次对向量进行了归一化,但对于没有统一比例的对象,它们看起来很奇怪。这是因为当一个表面在一个维度上被拉伸时,它的法线不会以相同的方式拉伸。
当刻度不均匀时,应将其反转为法线。这样,法线在再次归一化后将与变形表面的形状相匹配。对于均匀的比例来说,这并没有区别。
1.3.1 MVP矩阵
MVP矩阵分别为模型(Model),观察(View ),投影(projection);存在于局部空间(Local Space),世界空间(World Space),观察空间(View Space),裁剪空间(Clip Space),屏幕空间(Screen Space)
(以上是整体过程及变化过程)
M矩阵
- 作用:让顶点坐标从模型空间->世界空间
- 注意左右手坐标系的区分
- 过程:对顶点坐标分别进行了:缩放->旋转->平移(步骤固定,不能更换)
V矩阵
- 作用:让顶点坐标从世界空间->视觉/观察空间(以camera为中心的空间坐标系)
- 过程:
- 平移观察空间,让camera原点和世界坐标原点,坐标轴重合
- 移动摄像机
- 平移
- 旋转
- 对z分量取反(左手坐标系的原因)
P矩阵
- 作用:视觉/观察弓箭->裁剪空间
- 目的:判断顶点是否再可见范围内
- 不是真正的投影,为投影做准备
- 判断标准:对x,y,z分量进行缩放,用w分量做范围值,如果xyz都在w范围内,那么该点在裁剪空间
- 投影分为:
- 透视投影(左图)->近大远小,常用于3D游戏
- 正交投影(右图)->看到多大是多大,常用于2D游戏
如果可以的话,亲自推到一下MVP矩阵
因此,让我们转置世界到对象矩阵,并将其与顶点法线相乘。
i.normal = mul(
transpose((float3x3)unity_WorldToObject),
v.normal
);
i.normal = normalize(i.normal);
我们可以使用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);
return i;
}
What does UnityObjectToWorldNormal
look like?
// 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
);
}
1.4 Renormalizing
在顶点程序中生成正确的法线后,由于不同的单位长度向量之间进行线性插值不会产生另一个单位长度向量,因此我们必须再次归一化法线
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
i.normal = normalize(i.normal);
return float4(i.normal * 0.5 + 0.5, 1);
}
虽然这会产生更好的结果,但误差通常非常小。如果您更重视性能,则可以决定不在片段着色器中重新规范化。这是针对移动设备的常见优化。
二. Diffuse Shading
我们看到的物体本身不是光源,因为它们反射光。这种反射可以通过不同的方式发生。让我们首先考虑漫反射。 漫反射的发生是因为光线不仅仅是从表面上反弹。
相反,它穿透表面,反弹一会儿,分裂几次,直到它再次离开表面。实际上,光子和原子之间的相互作用比这更复杂,但我们不需要那么详细地了解现实世界的物理学。
表面漫反射的光量取决于光线照射到表面的角度。当表面以0°角正面撞击时,大多数光线都会被反射。随着此角度的增加,反射将减少。在90°时,没有光线再照射到表面,因此它保持黑暗。漫射光量与光方向与表面法线之间角度的余弦成正比。这被称为兰伯特余弦定律。
我们可以通过计算表面法线和光方向的点积来确定这个兰伯特反射率因子。我们已经知道正常,但还不知道光的方向。让我们从一个固定的光方向开始,直接从上面来。
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
i.normal = normalize(i.normal);
return dot(float3(0, 1, 0), i.normal);
}
What's a dot product?
从视觉上看,此操作将一个向量直接投影到另一个向量。仿佛在它身上投下了阴影。这样做,你最终会得到一个直角三角形,其底部的长度是点积的结果。如果两个向量都是单位长度,那就是它们角度的余弦。
2.1 Clamped Lighting
当表面被引导到光时,计算点积是有效的,但是当它被引导远离光时,则不行。在这种情况下,表面在逻辑上将处于自己的阴影中,并且它根本不应该接收到光。由于此时光方向与法线之间的角度必须大于 90°,因此其余弦和点积变为负数。由于我们不想要负光,因此我们必须钳夹结果。我们可以为此使用标准的 max 函数
return max(0, dot(float3(0, 1, 0), i.normal));
您经常会看到着色器使用饱和度而不是 max。此标准功能夹在 0 和 1 之间。
return saturate(dot(float3(0, 1, 0), i.normal));
这似乎没有必要,因为我们知道我们的点积永远不会产生大于1的结果。但是,在某些情况下,它实际上可以更有效率,具体取决于硬件。
事实上,我们可以将其委托给Unity的库。 UnityStandardBRDF 包含文件定义了方便的 DotClamped 函数。此函数执行点积,并确保它永远不会为负数。这正是我们所需要的。它还包含许多其他照明功能,还包括其他有用的文件,我们稍后需要这些文件。
#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);
}
What does DotClamped
look like?
在这里。显然,他们决定在针对低功能着色器硬件和面向PS3时最好使用饱和度。
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
}
2.2 Light Source
我们应该使用场景中的光源方向,而不是硬编码的光源方向。默认情况下,每个 Unity 场景都有一个表示太阳的光源。它是一种定向光,这意味着它被认为是无限远的。因此,它的所有光线都来自完全相同的方向。当然,这在现实生活中是不正确的,但太阳离得太远,以至于它是一个公平的近似值。
UnityShaderVariables 定义了 float4 _WorldSpaceLightPos0,其中包含当前光源的位置。或者光线来自的方向,在定向光的情况下。它有四个组件,因为这些是齐次坐标。因此,第四个分量是0表示我们的定向光。
float3 lightDir = _WorldSpaceLightPos0.xyz;
return DotClamped(lightDir, i.normal);
2.3 Light Mode
在生成正确结果之前,我们必须告诉Unity我们要使用哪些光源数据。为此,我们向着色器通道添加 LightMode 标记。
我们需要哪种灯光模式取决于我们如何渲染场景。我们可以使用正向或延迟渲染路径。还有两种较旧的渲染模式,但我们不会打扰它们。您可以通过播放器渲染设置选择渲染路径。它位于色彩空间选择的正上方。我们使用的是正向渲染,这是默认设置。
我们必须使用 ForwardBase 传递。这是通过正向渲染路径渲染内容时使用的第一个通道。它使我们能够访问场景的主要方向光。它还设置了其他一些内容,但我们稍后将介绍这些内容。
Pass {
Tags {
"LightMode" = "ForwardBase"
}
CGPROGRAM
…
ENDCG
}
2.4 Light Color
当然,光并不总是白色的。每个光源都有自己的颜色,我们可以通过unityLightingCommon中定义的fited4_LightColor0变量来获取。
What is fixed4
?
这些是低精度数字,它们以移动设备上的速度换取精度。在桌面上,固定只是 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);
return float4(diffuse, 1);
}
2.5 Albedo
大多数材料吸收部分电磁波谱。这给了它们颜色。例如,如果所有可见的红色频率都被吸收,那么逃逸的将出现青色。
材料的漫反射率的颜色称为其反照率。反照率在拉丁语中是白色的意思。因此,它描述了红色、绿色和蓝色通道的漫反射量。其余的被吸收。我们可以使用材质的纹理和色调来定义这一点。
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);
}
我们可以在inspector中将主纹理的标签更改为反射率
Properties {
_Tint ("Tint", Color) = (1, 1, 1, 1)
_MainTex ("Albedo", 2D) = "white" {}
}
三,Specular Shading
除了漫反射外,还有镜面反射。当光线在撞击表面后没有扩散时,就会发生这种情况。相反,光线从表面反射的角度和角度等于它撞击表面的角度。这就是导致您在镜子中看到的反射的原因
与漫反射不同,观看者的位置对于镜面反射很重要。只有最终直接反射到您的光线才可见。其余的去别的地方,所以你不会看到它。
因此,我们需要知道从表面到观看者的方向。这需要表面和相机的世界空间位置。 我们可以通过对象到世界的矩阵来确定顶点程序中表面的世界位置,然后将其传递给片元着色器。
struct Interpolators {
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);
return i;
}
可以通过 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);
return float4(diffuse, 1);
}
Don't Unity's shaders interpolate the view direction?
确实不能,Unity 的着色器在顶点程序中计算视图方向并进行插值。规范化在片段程序中完成,或者在功能较弱的硬件的顶点程序中完成
3.1 Reflecting Light
要知道反射光的去向,我们可以使用标准的反射函数。它获取入射光线的方向,并根据表面法线对其进行反射。因此,我们必须取反我们的光方向。
float3 reflectionDir = reflect(-lightDir, i.normal);
return float4(reflectionDir * 0.5 + 0.5, 1);
How does reflecting a vector work?
You can reflect a direction D with a normal N by computing D-2N(N·D).
在一面完全光滑的镜子的情况下,我们只能看到表面角度恰到好处的反射光。在所有其他地方,反射光会错过我们,表面对我们来说会显得黑色。但是物体并不完全光滑。它们有很多微观的凸起,这意味着表面法线可能会有很大差异。
因此,我们可以看到一些反射,即使我们的视图方向与反射方向不完全匹配。我们越偏离反射方向,我们看到的就越少。再一次,我们可以使用夹紧点积来计算有多少光到达我们。
return DotClamped(viewDir, reflectionDir);
3.2 Smoothness
此效果产生的高光的大小取决于材质的粗糙度。平滑的材质可以更好地聚焦光线,因此它们具有较小的高光。我们可以通过使其成为材料属性来控制这种平滑度。它通常被定义为一个介于 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
return pow(
DotClamped(viewDir, reflectionDir),
_Smoothness * 100
);
3.3 Blinn-Phong
我们目前正在根据Blinn反射模型计算反射。但最常用的模型是Blinn-Phong。它使用介于光方向和视图方向之间的矢量。法向量和半向量之间的点积决定了镜面反射的贡献。
float3 halfVector = normalize(lightDir + viewDir);
return pow(
DotClamped(halfVector, i.normal),
_Smoothness * 100
);
此方法会产生较大的高光,但可以通过使用较高的平滑度值来抵消。结果证明,在视觉上与现实的匹配比Phong好一点,尽管这两种方法仍然是近似值。一个很大的限制是,它可以为从后面照亮的对象产生无效的高光。
3.4 Specular Color
当然,镜面反射的颜色与光源的颜色相匹配。因此,让我们将其考虑在内。
float3 halfVector = normalize(lightDir + viewDir);
float3 specular = lightColor * pow(
DotClamped(halfVector, i.normal),
_Smoothness * 100
);
return float4(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
);
return float4(specular, 1);
}
3.5 Diffuse and Specular
漫反射和高光反射是照明难题的两个部分。我们可以将它们加在一起,使我们的图片更加完整。
return float4(diffuse + specular, 1);
四,Energy Conservation
将漫反射和高光反射加在一起存在问题。结果可以比光源更亮。当使用全白色镜面反射结合低平滑度时,这是非常明显的。
当光线照射到表面时,它的一部分会作为镜面光反射出来。它的其余部分穿透表面,要么以漫射光的形式回来,要么被吸收。但我们目前没有考虑到这一点。相反,我们的光既能反射又能全力扩散。
因此,我们最终可能会使光的能量增加一倍。 我们必须确保材料中漫反射部分和高光部分的总和永远不会超过1。这保证了我们不会无处不在地创造光明。如果总数小于 1,则没有问题。这只是意味着部分光线被吸收。
当我们使用恒定的镜面反射色调时,我们可以简单地通过将反照率色调乘以1减去镜面反射来调整反照率色调。但是手动执行此操作并不方便,特别是如果我们要使用特定的反照率色调。因此,让我们在着色器中执行此操作。
float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
albedo *= 1 - _SpecularTint.rgb;
扩散和高光贡献现在是链接在一起的。镜面反射越强,漫反射部分越暗。黑色镜面反射产生零反射,在这种情况下,您将看到全强度的反照率。白色镜面条色调产生完美的镜子,因此完全消除了反照率。
4.1 Monochrome
当镜面反射色调为灰度颜色时,此方法工作正常。但是当使用其他颜色时,它会产生奇怪的结果。例如,红色镜面反射色调只会减少漫反射部分的红色分量。结果,反照率将被染成青色。
为了防止这种着色,我们可以使用单色能量守恒。这只是意味着我们使用镜面反射颜色的最强成分来降低反照率。
albedo *= 1 -max(_SpecularTint.r, max(_SpecularTint.g, _SpecularTint.b));
4.2 Utility Function
如您所料,Unity具有实用程序功能来照顾节能。它是EnergyConservationBetweenDiffuseAndSpecular,并在UnityStandardUtils中定义。
#include "UnityStandardBRDF.cginc"
#include "UnityStandardUtils.cginc"
此函数将反照率和高光色作为输入,并输出调整后的反照率。但它也有第三个输出参数,称为一减反射率。这是1减去镜面强度,这是我们乘以反照率的因素。这是一个额外的输出,因为其他照明计算也需要反射率。
float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;
float oneMinusReflectivity;
albedo = EnergyConservationBetweenDiffuseAndSpecular(
albedo, _SpecularTint.rgb, oneMinusReflectivity
);
What does EnergyConservationBetweenDiffuseAndSpecular
look like?
在这里。它有三种模式,要么没有守恒,要么是单色的,要么是彩色的。这些由#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
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
}
4.3 Metallic Workflow
我们关心的材料基本上有两种。有金属,也有非金属。后者也被称为介电材料。目前,我们可以通过使用强烈的镜面反射色调来创建金属。我们可以通过使用弱单色镜面反射来制造电介质。
这是镜面反射工作流。 如果我们能在金属和非金属之间切换,那就简单多了。由于金属没有反照率,我们可以将该颜色数据用于其镜面色调。而且非金属无论如何都没有彩色镜面反射,所以我们根本不需要单独的镜面反射色调。这称为金属工作流。让我们开始吧。
我们可以使用另一个滑块属性作为金属切换,以替换镜面反射色调。通常,它应该设置为0或1,因为某些东西要么是金属,要么不是金属。两者之间的值表示混合了金属和非金属组件的材料。
Properties {
_Tint ("Tint", Color) = (1, 1, 1, 1)
_MainTex ("Albedo", 2D) = "white" {}
_Metallic ("Metallic", Range(0, 1)) = 0
_Smoothness ("Smoothness", Range(0, 1)) = 0.1
}
…
float _Metallic;
float _Smoothness;
现在,我们可以从反照率和金属特性中得出镜面反射色调。然后,反照率可以简单地乘以1减去金属值。
float3 specularTint = albedo * _Metallic;
float oneMinusReflectivity = 1 - _Metallic;
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还具有DefuseAndSpecularFromMetallic功能,该功能为我们解决了这个问题。
float3 specularTint; // = albedo * _Metallic;
float oneMinusReflectivity; // = 1 - _Metallic;
// albedo *= oneMinusReflectivity;
albedo = DiffuseAndSpecularFromMetallic(
albedo, _Metallic, specularTint, oneMinusReflectivity
);
What does DiffuseAndSpecularFromMetallic
look like?
在这里。请注意,它使用 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;
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,它还应该对我们的金属滑块应用 Gamma 校正。
[Gamma] _Metallic ("Metallic", Range(0, 1)) = 0
不幸的是,到目前为止,对于非金属来说,镜面反射现在已经变得相当模糊。为了改善这一点,我们需要一种更好的方法来计算光照。
五,Physically-Based Shading
Blinn-Phong长期以来一直是游戏行业的主力军,但现在基于物理的着色 ( 称为PBS - 风靡一时。这是有充分理由的,因为它更加现实和可预测。理想情况下,游戏引擎和建模工具都使用相同的着色算法。这使得内容创建变得更加容易。该行业正在慢慢趋同于标准的PBS实施。
Unity 的标准着色器也使用 PBS 方法。Unity实际上有多个实现。它根据目标平台、硬件和 API 级别决定使用哪个。该算法可通过UNITY_BRDF_PBS宏进行访问,该宏在 UnityPBSLighting 中定义。BRDF 代表 双向反射率分布函数。
#include "UnityPBSLighting.cginc"
What does UNITY_BRDF_PBS
look like?
它定义了 Unity 的 BRDF 函数之一的别名。默认情况下,UNITY_PBS_USE_BRDF1由 Unity 设置,作为平台定义。这将选择最佳着色器,除非着色器目标低于 3.0。
// 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
为了确保 Unity 选择最佳的 BRDF 函数,我们必须至少以着色器级别 3.0 为目标。我们通过编译指示语句来做到这一点。
CGPROGRAM
#pragma target 3.0
#pragma vertex MyVertexProgram
#pragma fragment MyFragmentProgram
Unity 的 BRDF 函数返回 RGBA 颜色,Alpha 分量始终设置为 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
// );
return UNITY_BRDF_PBS();
当然,我们必须用参数来调用它。每个函数都有八个参数。前两个是材料的漫反射和高光颜色。我们已经有了这些。
接下来的两个参数必须是反射率和粗糙度。这些参数必须采用一减形式,这是一种优化。我们已经从DefuseAndSpecularFromMetallic获得了oneMinusReflectivity。平滑度与粗糙度相反,因此我们可以直接使用它。
当然,表面法线和视图方向也是必需的。这些成为第五和第六个点。
return UNITY_BRDF_PBS(
albedo, specularTint,
oneMinusReflectivity, _Smoothness,
i.normal, viewDir
);
5.1 Light Structures
UnityLightingCommon 定义了一个简单的 UnityLight 结构,Unity 着色器用它来传递光源数据。它包含光的颜色,方向和ndol值,这是漫射项。请记住,这些结构纯粹是为了我们的方便。它不会影响已编译的代码。
我们拥有所有这些信息,因此我们所要做的就是将其置于轻巧的结构中,并将其作为第七个参数传递。
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
);
Why does the light data include the diffuse term?
既然 BRDF 函数拥有自己计算它所需的一切,为什么我们必须提供它呢?之所以如此,是因为光结构也用于其他环境。 实际上,GGX BRDF版本甚至不使用ndol。它自己计算它,就像它摆弄法线一样。与往常一样,着色器编译器将删除所有未使用的代码。因此,您不必担心。
他最后的论点是间接光。我们必须使用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;
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
);
}