【跟着Catlikecoding学渲染#6】阴影
一,Directional Shadows
我们的光照着色器能够产生相当逼真的结果,它会单独评估每个表面片段,当一个物体位于光源和另一个物体之间时,它可能会阻止部分或全部光线到达该另一个物体。照亮第一个物体的光线不再可用于照亮第二个物体。因此,第二个对象将至少部分未点亮。未点亮的区域位于第一个对象的阴影中。为了描述这一点,我们经常说拳头物体在第二个物体上投下了阴影。 实际上,在完全照明和完全阴影的空间之间有一个过渡区域,称为半影。它之所以存在,是因为所有光源都有一个体积。因此,有些区域只有部分光源是可见的,这意味着它们被部分阴影化。光源越大,表面离其阴影投射器越远,该区域就越大
但是Unity不支持半影,Unity 确实支持柔和阴影,但这是一种阴影过滤技术,而不是对半影的模拟
1.1 Enabling Shadows
没有阴影,很难看到物体之间的空间关系。为了说明这一点,我用几个拉伸的立方体创建了一个简单的场景。我在这些立方体上方放置了四排球体。中间行浮动球体,而外部行通过圆柱体连接到它们下面的立方体。 这些对象具有 Unity 的默认白色材质。该场景有两个定向光源,默认定向光源和一个稍弱的黄色光源。这些光源与前面的教程中使用的光源相同。 目前,阴影在项目范围内处于禁用状态。我们在前面的教程中做到了这一点。环境强度也设置为零,这样可以更轻松地看到阴影。
阴影是项目范围质量设置的一部分,可通过“编辑”/“项目设置”/“质量”找到。我们将以高质量水平启用它们。这意味着使用高分辨率、稳定的拟合投影、150 的距离和四个级联来支持硬阴影和软阴影
确保两个光源都设置为投射柔和阴影。它们的分辨率应取决于质量设置。
1.2 Shadow Mapping
Unity 如何将这些阴影添加到场景中?标准着色器显然有某种方法可以确定光线是否被阻挡。 你可以通过把光线投射到场景中,从光线投射到表面碎片,来弄清楚一个点是否位于阴影中。如果该射线在到达碎片之前击中了某些东西,那么它就会被阻挡。这是物理引擎可以做到的,但是对于每个片段和每个光这样做是非常不切实际的。然后你必须以某种方式将结果发送到GPU。
有几种技术可以支持实时阴影。每个都有其优点和缺点。Unity使用当今最常见的技术,即阴影映射。这意味着 Unity 以某种方式将阴影信息存储在纹理中。现在,我们将研究其工作原理。 通过窗口/帧调试器打开帧调试器,启用它,然后查看渲染步骤的层次结构。查看没有阴影的帧和启用了阴影的帧之间的差异。
禁用阴影后,所有对象都将照常渲染。我们已经熟悉这个过程。但是,启用阴影后,该过程将变得更加复杂。还有几个渲染阶段,以及更多的绘制调用。阴影很贵!
1.3 Rendering to the Depth Texture
启用定向阴影后,Unity 将开始深度传递到渲染过程中。结果将放入与屏幕分辨率匹配的纹理中。此通道渲染整个场景,但仅记录每个片段的深度信息。这与 GPU 用于确定片段最终位于先前呈现的片段顶部还是下方的信息相同。 此数据与片段空间中片段的 Z 坐标相对应。这是定义摄像机可以看到的区域的空间。深度信息最终存储为 0–1 范围内的值。查看纹理时,附近的纹素显示为深色。纹理越远,它就越轻。‘
What is clip space?
空间决定了相机看到什么。在场景视图中选择主摄像机时,您将在其前面看到一个金字塔线框,这表明它可以看到什么。在裁剪空间中,此金字塔是一个规则的立方体。模型-视图-投影矩阵用于将网格顶点转换为此空间。它被称为裁剪空间,因为最终位于此立方体外部的所有内容都会被剪切,因为它不可见。
此信息实际上与阴影直接无关,但 Unity 将在以后的传递中使用它。
1.4 Rendering to Shadow Maps
Unity 渲染的下一件事是第一个光源的阴影贴图。稍后,它还将渲染第二盏灯的阴影贴图。 同样,渲染整个场景,并且仅将深度信息存储在纹理中。但是,这次场景是从光源的角度渲染的。实际上,灯光充当相机。这意味着深度值告诉我们一束光在击中某物之前行进了多远。这可以用来确定某些东西是否被阴影遮蔽!
what about normal maps?
阴影贴图记录实际几何体的深度。法线贴图添加了粗糙表面的错觉,而阴影贴图则忽略了它们。因此,阴影不受法线贴图的影响。
因为我们使用的是定向光,所以他们的相机是正交的。因此,没有透视投影,光源相机的确切位置并不重要。Unity 将定位摄像机,以便它能够看到正常摄像机视图中的所有对象。
实际上,事实证明,Unity 不只是按光源渲染整个场景一次。每个光源渲染场景四次!纹理分为四个象限,每个象限都从不同的角度进行渲染。发生这种情况是因为我们选择使用四个阴影级联。如果要切换到两个级联,则每个光源将渲染场景两次。如果没有级联,它每盏灯只渲染一次。当我们查看阴影的质量时,我们将看到Unity为什么这样做。
1.5 Collection Shadows
我们有场景的深度信息,从相机的角度来看。从每盏灯的角度来看,我们也拥有这些信息。当然,这些数据存储在不同的剪辑空间中,但我们知道这些空间的相对位置和方向。因此,我们可以从一个空间转换到另一个空间。这使我们能够从两个角度比较深度测量值。从概念上讲,我们有两个向量,它们应该在同一点上结束。如果他们这样做,相机和灯光都可以看到那个点,所以它被点亮了。如果光源的矢量在到达点之前结束,则光源被阻挡,这意味着该点被阴影化
What about when the scene camera can't see a point?
这些点隐藏在更靠近相机的其他点后面。场景的深度纹理仅包含最近的点。因此,在评估隐藏点上不会浪费时间
Unity 通过渲染覆盖整个视图的单个四边形来创建这些纹理。它使用隐藏/内部屏幕空间阴影着色器进行此传递。每个片段从场景和光源的深度纹理中采样,进行比较,并将最终的阴影值渲染到屏幕空间阴影贴图中。亮起的纹素设置为 1,阴影纹素设置为 0。此时,Unity 还可以执行过滤,以创建柔和的阴影。
Why does Unity alternate between rendering and collecting?
每盏灯都需要自己的屏幕空间阴影贴图。但是,从光源角度渲染的阴影贴图可以重复使用。
1.6 Sampling the Shadow Maps
最后,Unity 完成了阴影渲染。现在,只需进行一次更改,即可正常渲染场景。浅色乘以存储在其阴影贴图中的值。这在应该阻挡光线时会消除光线。 渲染的每个片段都会对阴影贴图进行采样。还有最终隐藏在稍后绘制的其他对象后面的片段。因此,这些碎片最终可能会接收到最终隐藏它们的物体的阴影。在单步执行帧调试器时可以看到这一点。您还可以看到阴影出现在实际投射它们的对象之前。当然,这些错误只有在渲染帧时才会显现出来。完成后,图像是正确的。
1.7 Shadow Quality
从光源的角度渲染场景时,方向与场景照相机不匹配。因此,阴影贴图的纹素与最终图像的纹素不对齐。阴影贴图的分辨率最终也会有所不同。最终图像的分辨率由显示设置决定。阴影贴图的分辨率由阴影质量设置决定。 当阴影贴图的纹素最终渲染得比最终图像的纹素大时,它们将变得明显。阴影的边缘将出现锯齿。这在使用硬阴影时最为明显。
要使其尽可能明显,请更改阴影质量设置,以便我们仅获得最低分辨率的硬阴影,而不会级联。
现在很明显,阴影是纹理。此外,阴影的碎片出现在不应该出现的地方。我们稍后会对此进行研究。 阴影离场景摄像机越近,它们的纹素就越大。这是因为阴影贴图当前覆盖了场景照相机可见的整个区域。我们可以通过质量设置减少阴影覆盖的区域,从而提高相机附近的质量。
通过将阴影限制在靠近场景照相机的区域,我们可以使用相同的阴影贴图来覆盖更小的区域。结果,我们得到了更好的阴影。但我们失去了更远的阴影。阴影在接近最大距离时会逐渐消失。 理想情况下,我们可以近距离获得高质量的阴影,同时保持远处的阴影。由于远处的阴影最终会渲染到较小的屏幕区域,因此这些阴影可以使用较低分辨率的阴影贴图。这就是阴影级联的作用。启用后,多个阴影贴图将渲染到同一纹理中。每张地图都适合在一定距离内使用。
使用四个级联时,即使我们仍然使用相同的纹理分辨率,结果看起来也要好得多。我们只是更有效地使用纹素。缺点是我们现在必须将场景渲染三次。 渲染到屏幕空间阴影贴图时,Unity 负责从正确的级联中采样。您可以通过查找阴影纹素大小的突然变化来查找一个级联结束和另一个级联开始的位置。 您可以通过质量设置控制级联波段的范围,作为阴影距离的一部分。您还可以通过更改其着色模式在场景视图中可视化它们。不要只是着色,而是使用杂项/阴影级联。这将在场景顶部渲染级联的颜色。
How do I change the scene view's display mode?
场景视图窗口的左上角有一个下拉列表。默认情况下,它设置为“着色”。
级联波段的形状取决于“阴影投影”质量设置。默认值为“稳定拟合”。在此模式下,根据与摄像机位置的距离选择波段。另一个选项是“Close Fit”,它使用相机的深度。这将在相机的视图方向上生成矩形条带。
此配置允许更有效地使用阴影纹理,从而产生更高质量的阴影。但是,阴影投影现在取决于位置和方向或相机。因此,当相机移动或旋转时,阴影贴图也会发生变化。如果您可以看到阴影纹素,您会注意到它们移动了。这种效果被称为阴影边缘游泳,并且可以非常明显。这就是为什么另一种模式是默认模式。
Don't Stable Fit shadows also depend on the camera position?
它们可以,但 Unity 可以对齐地图,以便在摄像机位置发生变化时,纹素看起来一动不动。当然,级联波段确实会移动,因此波段之间的过渡点会发生变化。但是,如果你没有注意到bands,你也不会注意到它们移动了。
1.8 Shadow Acne
当我们使用低质量的硬阴影时,我们看到阴影出现在不应该出现的地方。不幸的是,无论质量设置如何,都可能发生这种情况。 阴影贴图中的每个纹素表示光线照射到表面的点。但是,纹素不是单点。它们最终覆盖了更大的区域。它们与光的方向对齐,而不是与表面对齐。因此,它们最终可能会像黑暗碎片一样粘入,穿过和离开表面。当部分纹素最终从投射阴影的曲面中探出时,曲面似乎会自己着色。这被称为阴影痤疮。
阴影粉刺的另一个来源是数字精度限制。当涉及非常小的距离时,这些限制可能会导致不正确的结果。
防止此问题的一种方法是在渲染阴影贴图时添加深度偏移。这种偏差被添加到从光到阴影投射表面的距离上,将阴影推入表面。
阴影偏差按光源配置,并通过 defaul 设置为 0.05
低偏倚会产生阴影粉刺,但大偏倚会带来另一个问题。当阴影投射对象被推离灯光时,它们的阴影也会被推开。因此,阴影将无法与对象完全对齐。当使用小偏差时,这并不是那么糟糕。但是,太大的偏差会使阴影看起来与投射它们的物体断开连接。这种效果称为彼得平移。
除了这种距离偏差之外,还有一个正常偏差。这是对阴影投射器的更微妙的调整。这种偏差将阴影投射器的顶点沿其法线向内推。这也减少了自阴影,但它也使阴影变小,并可能导致阴影中出现孔洞。
What are the best bias settings?
没有最佳设置。不幸的是,你必须尝试。Unity 的默认设置可能有效,但它们也可能产生不可接受的结果。不同的质量设置也会产生不同的结果。
1.9 Anti-Aliasing
您是否在质量设置中启用了抗锯齿功能?如果有,那么您可能已经发现了阴影贴图的另一个问题。它们不与标准的抗锯齿方法混合使用。
在质量设置中启用抗锯齿时,Unity 将使用多采样抗锯齿 MSAA。它通过沿三角形边缘执行一些超级采样来消除这些边缘的锯齿。细节在这里并不重要。重要的是,当 Unity 渲染屏幕空间阴影贴图时,它会使用覆盖整个视图的单个四边形来实现。因此,没有三角形边缘,因此 MSAA 不会影响屏幕空间阴影贴图。MSAA 确实适用于最终图像,但阴影值是直接从屏幕空间阴影贴图中获取的。当较暗表面旁边的浅色表面被阴影化时,这变得非常明显。浅色和深色几何体之间的边缘是抗锯齿的,而阴影边缘不是。
依赖于图像后处理的抗锯齿方法(如 FXAA)没有此问题,因为它们是在整个场景渲染后应用的。
Does this mean that I cannot combine MSAA with directional shadows?
您可以,但会遇到上述问题。在某些情况下,它可能不明显。例如,当所有表面颜色大致相同时,伪像将是微妙的。当然,你仍然会得到混叠的阴影边缘。
二,Casting Shadows
现在我们知道了 Unity 如何为定向光源创建阴影,现在是时候将对它们的支持添加到我们自己的着色器中了。目前,我的第一个光照着色器既不投射也不接收阴影。 让我们先处理投射阴影。我更改了示例场景中的球体和圆柱体,以便它们使用我们的材质。所以现在他们不再投下阴影。
我们知道 Unity 会多次渲染场景以进行定向阴影。一次用于深度传递,一次针对每个光源,用于每个阴影贴图级联。屏幕空间阴影贴图是一种屏幕空间效果,与我们无关。 要支持所有相关的通道,我们必须向着色器添加一个通道,其光照模式设置为 ShadowCaster。因为我们只对深度值感兴趣,所以它将比其他通路简单得多。
SubShader {
Pass {
Tags {
"LightMode" = "ForwardBase"
}
…
}
Pass {
Tags {
"LightMode" = "ForwardAdd"
}
…
}
Pass {
Tags {
"LightMode" = "ShadowCaster"
}
CGPROGRAM
#pragma target 3.0
#pragma vertex MyShadowVertexProgram
#pragma fragment MyShadowFragmentProgram
#include "My Shadows.cginc"
ENDCG
}
}
让我们为影子程序提供它们自己的包含文件,名为My Shadows.cginc。它们非常简单。顶点程序像往常一样将位置从对象空间转换为剪辑空间,而不执行任何其他操作。片段程序实际上不需要做任何事情,所以只需返回零。GPU为我们记录深度值。
#if !defined(MY_SHADOWS_INCLUDED)
#define MY_SHADOWS_INCLUDED
#include "UnityCG.cginc"
struct VertexData {
float4 position : POSITION;
};
float4 MyShadowVertexProgram (VertexData v) : SV_POSITION {
return mul(UNITY_MATRIX_MVP, v.position);
}
half4 MyShadowFragmentProgram () : SV_TARGET {
return 0;
}
#endif
这已经足以使阴影定向投射。
2.1 Bias
我们还必须支持shadow bias。在深度传递期间,bias为零,但在渲染阴影贴图时,bias对应于光源设置。为此,我们将深度偏差应用于顶点着色器中剪辑空间中的位置。 为了支持深度偏差,我们可以使用UnityApplyLinearShadowBias函数,该函数在UnityCG中定义。
float4 MyShadowVertexProgram (VertexData v) : SV_POSITION {
float4 position = mul(UNITY_MATRIX_MVP, v.position);
return UnityApplyLinearShadowBias(position);
}
How does UnityApplyLinearShadowBias
work?
它会增加剪辑空间中的 Z 坐标。使情况复杂化的是,它使用的是齐次坐标。它必须补偿透视投影,以便偏移不会随与摄像机的距离而变化。它还必须确保结果不会超出范围。
float4 UnityApplyLinearShadowBias (float4 clipPos) {
clipPos.z += saturate(unity_LightShadowBias.x / clipPos.w);
float clamped = max(clipPos.z, clipPos.w * UNITY_NEAR_CLIP_VALUE);
clipPos.z = lerp(clipPos.z, clamped, unity_LightShadowBias.y);
return clipPos;
}
为了支持正态偏差,我们必须根据正态向量移动顶点位置。因此,我们必须将法线添加到顶点数据中。然后,我们可以使用UnityClipSpaceShadowCasterPos函数来应用偏差。此功能也在 UnityCG 中定义。
struct VertexData {
float4 position : POSITION;
float3 normal : NORMAL;
};
float4 MyShadowVertexProgram (VertexData v) : SV_POSITION {
float4 position = UnityClipSpaceShadowCasterPos(v.position.xyz, v.normal);
return UnityApplyLinearShadowBias(position);
}
How does UnityClipSpaceShadowCasterPos
work?
它将位置转换为世界空间,应用法向偏差,然后转换为裁剪空间。确切的偏移量取决于法线方向和光照方向之间的角度,以及阴影纹素大小。
float4 UnityClipSpaceShadowCasterPos (float3 vertex, float3 normal) {
float4 clipPos;
// Important to match MVP transform precision exactly while rendering
// into the depth texture, so branch on normal bias being zero.
if (unity_LightShadowBias.z != 0.0) {
float3 wPos = mul(unity_ObjectToWorld, float4(vertex,1)).xyz;
float3 wNormal = UnityObjectToWorldNormal(normal);
float3 wLight = normalize(UnityWorldSpaceLightDir(wPos));
// apply normal offset bias (inset position along the normal)
// bias needs to be scaled by sine between normal and light direction
// (http://the-witness.net/news/2013/09/shadow-mapping-summary-part-1/)
//
// unity_LightShadowBias.z contains user-specified normal offset amount
// scaled by world space texel size.
float shadowCos = dot(wNormal, wLight);
float shadowSine = sqrt(1 - shadowCos * shadowCos);
float normalBias = unity_LightShadowBias.z * shadowSine;
wPos -= wNormal * normalBias;
clipPos = mul(UNITY_MATRIX_VP, float4(wPos, 1));
}
else {
clipPos = UnityObjectToClipPos(vertex);
}
return clipPos;
}
UnityObjectToClipPos 函数仅执行模型-视图-投影矩阵乘法,在使用立体渲染时需要注意。
// Tranforms position from object to homogenous space
inline float4 UnityObjectToClipPos( in float3 pos) {
#ifdef UNITY_USE_PREMULTIPLIED_MATRICES
return mul(UNITY_MATRIX_MVP, float4(pos, 1.0));
#else
// More efficient than computing M*VP matrix product
return mul(UNITY_MATRIX_VP, mul(unity_ObjectToWorld, float4(pos, 1.0)));
#endif
}
我们的着色器现在是一个功能齐全的阴影投射器。(功能齐全可还行,还少了一个自动写shader的功能xxx)
三,Receiving Shadows
该过程的第二部分是接收阴影。测试场景中的所有对象现在都使用我们的材质。
让我们首先关注主定向光的阴影。由于此光源包含在基本通道中,因此我们必须调整该光源。 当主定向光投射阴影时,Unity 将查找启用了 SHADOWS_SCREEN 关键字的着色器变体。因此,我们必须创建基本通道的两个变体,一个带有此关键字,一个没有此关键字。这与VERTEXLIGHT_ON关键字的工作方式相同。
#pragma multi_compile _ SHADOWS_SCREEN
#pragma multi_compile _ VERTEXLIGHT_ON
该传递现在有两个多编译指令,每个指令对应一个关键字。因此,有四种可能的变体。一个没有关键字,每个关键字一个,一个同时具有两个关键字。
// Snippet #0 platforms ffffffff:
SHADOWS_SCREEN VERTEXLIGHT_ON
4 keyword variants used in scene:
<no keywords defined>
VERTEXLIGHT_ON
SHADOWS_SCREEN
SHADOWS_SCREEN VERTEXLIGHT_ON
添加多编译编译指示后,着色器编译器将提示不存在_ShadowCoord。发生这种情况是因为UNITY_LIGHT_ATTENUATION宏在播放阴影时的行为不同。要快速解决此问题,请打开My Lighting.cginc文件,并在有阴影时将衰减设置为1。
UnityLight CreateLight (Interpolators i) {
UnityLight light;
#if defined(POINT) || defined(POINT_COOKIE) || defined(SPOT)
light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
#else
light.dir = _WorldSpaceLightPos0.xyz;
#endif
#if defined(SHADOWS_SCREEN)
float attenuation = 1;
#else
UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
#endif
light.color = _LightColor0.rgb * attenuation;
light.ndotl = DotClamped(i.normal, light.dir);
return light;
}
3.1 Sampling Shadows
为了获得阴影,我们必须对屏幕空间阴影贴图进行采样。为此,我们需要知道屏幕空间纹理坐标。与其他纹理坐标一样,我们将它们从顶点着色器传递到片段着色器。因此,在支持阴影时,我们需要使用额外的插值器。我们将从传递齐次剪辑空间位置开始,因此我们需要一个 float4。
struct Interpolators {
…
#if defined(SHADOWS_SCREEN)
float4 shadowCoordinates : TEXCOORD5;
#endif
#if defined(VERTEXLIGHT_ON)
float3 vertexLightColor : TEXCOORD6;
#endif
};
…
Interpolators MyVertexProgram (VertexData v) {
…
#if defined(SHADOWS_SCREEN)
i.shadowCoordinates = i.position;
#endif
ComputeVertexLightColor(i);
return i;
}
我们可以通过_ShadowMapTexture访问屏幕空间阴影。在适当的时候,它会在 AutoLight 中定义。朴素的方法是简单地使用片段的剪辑空间 XY 坐标来采样此纹理。
UnityLight CreateLight (Interpolators i) {
…
#if defined(SHADOWS_SCREEN)
float attenuation = tex2D(_ShadowMapTexture, i.shadowCoordinates.xy);
#else
UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
#endif
…
}
我们现在对阴影进行采样,但使用裁剪空间坐标而不是屏幕空间坐标。我们确实得到了阴影,但它们最终被压缩成屏幕中心的一个小区域。我们必须拉伸它们以覆盖整个窗口。 我
My shadows are upside down?
这是由于 API 的差异。后面几节有讲
在裁剪空间中,所有可见的 XY 坐标都在 −1–1 范围内,而屏幕空间的范围为 0–1。第一步通过将XY减半来解决这个问题。接下来,我们还必须偏移坐标,使它们在屏幕的左下角为零。由于我们正在处理透视转换,因此我们必须偏移坐标的程度取决于它们的距离。在这种情况下,偏移量等于减半之前的第四个齐次坐标。
#if defined(SHADOWS_SCREEN)
i.shadowCoordinates.xy = (i.position.xy + i.position.w) * 0.5;
i.shadowCoordinates.zw = i.position.zw;
#endif
投影仍然不正确,因为我们使用的是齐次坐标。我们必须通过将X和Y除以W来转换为屏幕空间坐标。
i.shadowCoordinates.xy =
(i.position.xy + i.position.w) * 0.5 / i.position.w;
结果会失真。阴影是拉伸和弯曲的。发生这种情况是因为我们在插值之前进行除法。这是不正确的,坐标应该在除法之前独立插值。因此,我们必须将分割移动到片段着色器。
Interpolators MyVertexProgram (VertexData v) {
…
#if defined(SHADOWS_SCREEN)
i.shadowCoordinates.xy =
(i.position.xy + i.position.w) * 0.5; // / i.position.w;
i.shadowCoordinates.zw = i.position.zw;
#endif
…
}
UnityLight CreateLight (Interpolators i) {
…
#if defined(SHADOWS_SCREEN)
float attenuation = tex2D(
_ShadowMapTexture,
i.shadowCoordinates.xy / i.shadowCoordinates.w
);
#else
UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
#endif
…
}
How does interpolation affect division?
这最好用一个例子来说明。假设我们在 XW 坐标对 (0, 1) 和 (1, 4) 之间进行插值。无论我们如何做到这一点,X / W从0开始,到1/4结束。但是,在这些点之间的中间呢? 如果我们在插值之前除以,那么我们最终会在0和1/4之间结束,即1/8。 如果我们在插值后除以,那么在中点,我们得到坐标(0.5,2.5),这导致除法0.5 / 2.5,即1/5,而不是1/8。因此,在这种情况下,插值不是线性的。
此时,阴影要么看起来正确,要么颠倒过来。如果它们被翻转,则意味着你的图形 API (Direct3D) 的屏幕空间 Y 坐标从 0 向下变为 1,而不是向上。要与此同步,请翻转顶点的 Y 坐标。
i.shadowCoordinates.xy =
(float2(i.position.x, -i.position.y) + i.position.w) * 0.5;
3.2 Using Unity's Code
Unity 的包含文件提供了函数和宏的集合,以帮助我们对阴影进行采样。他们负责处理 API 差异和平台限制。例如,我们可以使用UnityCG的ComputeScreenPos函数。
#if defined(SHADOWS_SCREEN)
i.shadowCoordinates = ComputeScreenPos(i.position);
#endif
What does ComputeScreenPos
look like?
它执行与我们相同的计算。当需要翻转 Y 坐标时,_ProjectParams.x 变量为 −1。此外,它还负责使用 Direct3D9 时的纹理对齐。执行单通道立体渲染时还需要特殊的逻辑。
inline float4 ComputeNonStereoScreenPos (float4 pos) {
float4 o = pos * 0.5f;
#if defined(UNITY_HALF_TEXEL_OFFSET)
o.xy = float2(o.x, o.y * _ProjectionParams.x) +
o.w * _ScreenParams.zw;
#else
o.xy = float2(o.x, o.y * _ProjectionParams.x) + o.w;
#endif
o.zw = pos.zw;
return o;
}
inline float4 ComputeScreenPos (float4 pos) {
float4 o = ComputeNonStereoScreenPos(pos);
#ifdef UNITY_SINGLE_PASS_STEREO
o.xy = TransformStereoScreenSpaceTex(o.xy, pos.w);
#endif
return o;
}
AutoLight 包含文件定义了三个有用的宏。它们是SHADOW_COORDS的、TRANSFER_SHADOW的和SHADOW_ATTENUATION的。启用阴影后,这些宏将执行与我们刚刚执行的相同工作。当没有阴影时,他们什么都不做。 SHADOW_COORDS在需要时定义阴影坐标的插值器。我使用_ShadowCoord名称,这是编译器之前报错的。
struct Interpolators {
…
// #if defined(SHADOWS_SCREEN)
// float4 shadowCoordinates : TEXCOORD5;
// #endif
SHADOW_COORDS(5)
…
};
TRANSFER_SHADOW在顶点程序中填充这些坐标。
Interpolators MyVertexProgram (VertexData v) {
…
// #if defined(SHADOWS_SCREEN)
// i.shadowCoordinates = i.position;
// #endif
TRANSFER_SHADOW(i);
…
}
SHADOW_ATTENUATION使用坐标对片段程序中的阴影贴图进行采样。
UnityLight CreateLight (Interpolators i) {
…
#if defined(SHADOWS_SCREEN)
float attenuation = SHADOW_ATTENUATION(i);
#else
UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
#endif
…
}
实际上,UNITY_LIGHT_ATTENUATION宏已经使用了SHADOW_ATTENUATION。这就是我们之前遇到编译器错误的原因。因此,我们只需使用该宏就足够了。唯一的变化是,我们必须使用插值器作为其第二个参数,而我们之前只使用零。
// #if defined(SHADOWS_SCREEN)
// float attenuation = SHADOW_ATTENUATION(i);
// #else
UNITY_LIGHT_ATTENUATION(attenuation, i, i.worldPos);
// #endif
重写代码以使用这些宏后,我们会收到新的编译错误。发生这种情况是因为Unity的宏不幸地对顶点数据和插值器结构做出了假设。
首先,假设顶点位置被命名为顶点,而我们将其命名为位置。其次,假设插值器位置被命名为 pos,但我们将其命名为 position。 让我们务实一点,也采用这些名字。无论如何,它们只在少数几个地方使用,所以我们不必改变太多。
struct VertexData {
float4 vertex : POSITION;
…
};
struct Interpolators {
float4 pos : SV_POSITION;
…
};
…
Interpolators MyVertexProgram (VertexData v) {
Interpolators i;
i.pos = mul(UNITY_MATRIX_MVP, v.vertex);
i.worldPos = mul(unity_ObjectToWorld, v.vertex);
…
}
我们的影子应该可以再次工作,这次是在Unity支持的尽可能多的平台上。
What do these macros look like?
最终使用的宏版本取决于启用了哪些着色器关键字以及支持哪些功能。定义SHADOWS_SCREEN后,最终将得到以下代码。
#define SHADOW_COORDS(idx1) unityShadowCoord4 _ShadowCoord : TEXCOORD##idx1;
#if defined(UNITY_NO_SCREENSPACE_SHADOWS)
UNITY_DECLARE_SHADOWMAP(_ShadowMapTexture);
#define TRANSFER_SHADOW(a) a._ShadowCoord = \
mul(unity_WorldToShadow[0], mul(unity_ObjectToWorld, v.vertex));
inline fixed unitySampleShadow (unityShadowCoord4 shadowCoord) {}
#if defined(SHADOWS_NATIVE)
fixed shadow =
UNITY_SAMPLE_SHADOW(_ShadowMapTexture, shadowCoord.xyz);
shadow = _LightShadowData.r + shadow * (1-_LightShadowData.r);
return shadow;
#else
unityShadowCoord dist =
SAMPLE_DEPTH_TEXTURE(_ShadowMapTexture, shadowCoord.xy);
// tegra is confused if we use _LightShadowData.x directly
// with "ambiguous overloaded function reference
// max(mediump float, float)"
unityShadowCoord lightShadowDataX = _LightShadowData.x;
unityShadowCoord threshold = shadowCoord.z;
return max(dist > threshold, lightShadowDataX);
#endif
}
#else // UNITY_NO_SCREENSPACE_SHADOWS
sampler2D _ShadowMapTexture;
#define TRANSFER_SHADOW(a) a._ShadowCoord = ComputeScreenPos(a.pos);
inline fixed unitySampleShadow (unityShadowCoord4 shadowCoord) {
fixed shadow =
tex2Dproj(_ShadowMapTexture, UNITY_PROJ_COORD(shadowCoord)).r;
return shadow;
}
#endif
#define SHADOW_ATTENUATION(a) unitySampleShadow(a._ShadowCoord)
请注意,仅当同时定义了UNITY_NO_SCREENSPACE_SHADOWS和SHADOWS_NATIVE时,才会使用阴影坐标的 Z 分量。 tex2Dproj函数与tex2D相同,但它也负责XY / W划分。在查看已编译的代码时,您可以看到这一点。
3.3 Multiple Shadow
主要定向光现在正在投射阴影,但第二定向光仍然没有。这是因为我们还没有在加法通道中定义SHADOWS_SCREEN。我们可以向其添加多编译语句,但SHADOWS_SCREEN仅适用于定向光源。若要获取关键字的正确组合,请将现有的多编译语句更改为同时包含阴影的语句。
#pragma multi_compile_fwdadd_fullshadows
这会在组合中添加四个额外的关键字,以支持不同的光源类型。
// -----------------------------------------
// Snippet #1 platforms ffffffff:
DIRECTIONAL DIRECTIONAL_COOKIE POINT POINT_COOKIE SHADOWS_CUBE SHADOWS_DEPTH
SHADOWS_SCREEN SHADOWS_SOFT SPOT
13 keyword variants used in scene:
POINT
DIRECTIONAL
SPOT
POINT_COOKIE
DIRECTIONAL_COOKIE
SHADOWS_DEPTH SPOT
DIRECTIONAL SHADOWS_SCREEN
DIRECTIONAL_COOKIE SHADOWS_SCREEN
POINT SHADOWS_CUBE
POINT_COOKIE SHADOWS_CUBE
SHADOWS_DEPTH SHADOWS_SOFT SPOT
POINT SHADOWS_CUBE SHADOWS_SOFT
POINT_COOKIE SHADOWS_CUBE SHADOWS_SOFT
四,Spotlight Shadows
现在我们已经处理了定向光,让我们继续关注聚光灯。禁用定向光源,并向场景添加一些带有阴影的聚光灯
查看帧调试器时,您会发现 Unity 对聚光灯阴影所做的工作较少。没有单独的深度通道,也没有屏幕空间阴影通道。仅渲染阴影贴图。
阴影贴图的工作方式与定向光源相同。它们是从光线角度渲染的深度图。但是,定向光和聚光灯之间存在很大差异。聚光灯有一个实际位置,它的光线不是平行的。所以聚光灯的摄像头有透视图,不能更随意地围绕着。因此,这些光源无法支持阴影级联。
尽管摄像机设置不同,但两种光源类型的阴影投射代码是相同的。正常偏差仅支持定向阴影,但对于其他光源,它仅设置为零。
4.1 Sampling the Shadow Map
由于聚光灯不使用屏幕空间阴影,因此采样代码必须不同。但是Unity的宏向我们隐藏了这种差异。
What do the macros look like, for spotlights?
通过将顶点位置转换为世界空间,以及从那里转换为光源的阴影空间,可以找到阴影坐标。
#if defined (SHADOWS_DEPTH) && defined (SPOT)
#define SHADOW_COORDS(idx1) \
unityShadowCoord4 _ShadowCoord : TEXCOORD##idx1;
#define TRANSFER_SHADOW(a) a._ShadowCoord = \
mul(unity_WorldToShadow[0], mul(unity_ObjectToWorld, v.vertex));
#define SHADOW_ATTENUATION(a) UnitySampleShadowmap(a._ShadowCoord)
#endif
我们通过简单地对屏幕空间阴影贴图进行采样来发现定向阴影。Unity 在创建该地图时负责阴影过滤,因此我们无需担心这一点。但是,聚光灯不使用屏幕空间阴影。因此,如果我们想使用柔和的阴影,我们必须在片段程序中进行过滤。
然后SHADOW_ATTENUATION宏使用 UnitySampleShadowmap 函数对阴影贴图进行采样。此函数在 UnityShadowLibrary 中定义,AutoLight 包含该库。使用硬阴影时,该函数对阴影贴图进行一次采样。使用柔和阴影时,它会对地图进行四次采样并平均结果。结果不如用于屏幕空间阴影的过滤效果好,但速度要快得多。
What does UnitySampleShadowmap
look like?
此功能有两个版本,一个用于聚光灯,一个用于点光源。这是聚光灯的那个。 _ShadowOffsets包含用于对创建柔和阴影求平均值的四个样本的偏移量。在下面的代码中,我只显示了这四个示例中的第一个。
#if !defined(SHADOWMAPSAMPLER_DEFINED)
UNITY_DECLARE_SHADOWMAP(_ShadowMapTexture);
#endif
// shadow sampling offsets
#if defined (SHADOWS_SOFT)
float4 _ShadowOffsets[4];
#endif
inline fixed UnitySampleShadowmap (float4 shadowCoord) {
// DX11 feature level 9.x shader compiler (d3dcompiler_47 at least)
// has a bug where trying to do more than one shadowmap sample fails
// compilation with "inconsistent sampler usage". Until that is fixed,
// just never compile multi-tap shadow variant on d3d11_9x.
#if defined (SHADOWS_SOFT) && !defined (SHADER_API_D3D11_9X)
// 4-tap shadows
#if defined (SHADOWS_NATIVE)
#if defined (SHADER_API_D3D9)
// HLSL for D3D9, when modifying the shadow UV coordinate,
// really wants to do some funky swizzles, assuming that Z
// coordinate is unused in texture sampling. So force it to
// do projective texture reads here, with .w being one.
float4 coord = shadowCoord / shadowCoord.w;
half4 shadows;
shadows.x = UNITY_SAMPLE_SHADOW_PROJ(
_ShadowMapTexture, coord + _ShadowOffsets[0]
);
…
shadows =_LightShadowData.rrrr +
shadows * (1-_LightShadowData.rrrr);
#else
// On other platforms,
// no need to do projective texture reads.
float3 coord = shadowCoord.xyz / shadowCoord.w;
half4 shadows;
shadows.x = UNITY_SAMPLE_SHADOW(
_ShadowMapTexture, coord + _ShadowOffsets[0]
);
…
shadows = _LightShadowData.rrrr +
shadows * (1-_LightShadowData.rrrr);
#endif
#else
float3 coord = shadowCoord.xyz / shadowCoord.w;
float4 shadowVals;
shadowVals.x = SAMPLE_DEPTH_TEXTURE(
_ShadowMapTexture, coord + _ShadowOffsets[0].xy
);
…
half4 shadows = (shadowVals < coord.zzzz) ?
_LightShadowData.rrrr : 1.0f;
#endif
// average-4 PCF
half shadow = dot(shadows, 0.25f);
#else
// 1-tap shadows
#if defined (SHADOWS_NATIVE)
half shadow =
UNITY_SAMPLE_SHADOW_PROJ(_ShadowMapTexture, shadowCoord);
shadow = _LightShadowData.r + shadow * (1 - _LightShadowData.r);
#else
half shadow =
SAMPLE_DEPTH_TEXTURE_PROJ(
_ShadowMapTexture, UNITY_PROJ_COORD(shadowCoord)
) < (shadowCoord.z / shadowCoord.w) ?
_LightShadowData.r : 1.0;
#endif
#endif
return shadow;
}
五.Point Light Shadows
现在尝试一些点光源。为点光源启用阴影时,将出现编译错误。显然,UnityDecodeCubeShadowDepth是未定义的。发生此错误的原因是 UnityShadowLibrary 依赖于 UnityCG,但未明确包含它。因此,我们必须确保首先包含UnityCG。为此,我们可以先将 UnityPBSLighting 包含在 My Lighting 中。
#include "UnityPBSLighting.cginc"
#include "AutoLight.cginc"
//#include "UnityPBSLighting.cginc"
它编译,但灯光范围内的所有对象最终都变为黑色。阴影贴图有问题。
通过帧调试器检查阴影贴图时,您会发现每个光源渲染的不是一个,而是六个贴图。发生这种情况是因为点光源在所有方向上都闪耀。因此,阴影贴图必须是立方体贴图。立方体贴图是通过渲染场景而创建的,摄像机指向六个不同的方向,每个立方体面一次。因此,点光源的阴影价格昂贵。
5.1 Casting Shadows
遗憾的是,Unit 不使用深度立方体贴图。显然,没有足够的平台支持它们。因此,我们不能依赖我的影子中片段的深度值。相反,我们必须输出片段的距离作为片段程序的结果。 渲染点光影贴图时,Unity 会查找定义SHADOWS_CUBE关键字的阴影投射器变体。SHADOWS_DEPTH 关键字用于定向阴影和聚光灯阴影。为了支持这一点,请将阴影投射器的特殊多编译指令添加到我们的传递中。
Pass {
Tags {
"LightMode" = "ShadowCaster"
}
CGPROGRAM
#pragma target 3.0
#pragma multi_compile_shadowcaster
#pragma vertex MyShadowVertexProgram
#pragma fragment MyShadowFragmentProgram
#include "My Shadows.cginc"
ENDCG
}
这增加了我们需要的变体。
// Snippet #2 platforms ffffffff:
SHADOWS_CUBE SHADOWS_DEPTH
2 keyword variants used in scene:
SHADOWS_DEPTH
SHADOWS_CUBE
由于点光源需要如此不同的方法,因此让我们为它们创建一组单独的程序函数。
#if defined(SHADOWS_CUBE)
#else
float4 MyShadowVertexProgram (VertexData v) : SV_POSITION {
float4 position =
UnityClipSpaceShadowCasterPos(v.position.xyz, v.normal);
return UnityApplyLinearShadowBias(position);
}
half4 MyShadowFragmentProgram () : SV_TARGET {
return 0;
}
#endif
为了计算出物体与光的距离,我们必须构建从光到碎片的世界空间向量。我们可以通过为每个顶点创建这些向量并对其进行插值来做到这一点。这需要一个额外的插值器。
#if defined(SHADOWS_CUBE)
struct Interpolators {
float4 position : SV_POSITION;
float3 lightVec : TEXCOORD0;
};
Interpolators MyShadowVertexProgram (VertexData v) {
Interpolators i;
i.position = UnityObjectToClipPos(v.position);
i.lightVec =
mul(unity_ObjectToWorld, v.position).xyz - _LightPositionRange.xyz;
return i;
}
float4 MyShadowFragmentProgram (Interpolators i) : SV_TARGET {
return 0;
}
#else
在片段程序中,我们取光矢量的长度并添加偏差。然后,我们将其除以光的范围,以使其适合0-1范围。_LightPositionRange.w 变量包含其范围的倒数,因此我们必须乘以此值。结果输出为浮点值。
float4 MyShadowFragmentProgram (Interpolators i) : SV_TARGET {
float depth = length(i.lightVec) + unity_LightShadowBias.x;
depth *= _LightPositionRange.w;
return UnityEncodeCubeShadowDepth(depth);
}
What does UnityEncodeCubeShadowDepth
do?
Unity 更喜欢使用浮点立方体贴图。如果可以,此函数不执行任何操作。当无法做到这一点时,Unity 将对值进行编码,以便将其存储在 8 位 RGBA 纹理的四个通道中。
// Encoding/decoding [0..1) floats into 8 bit/channel RGBA.
// Note that 1.0 will not be encoded properly.
inline float4 EncodeFloatRGBA (float v) {
float4 kEncodeMul = float4(1.0, 255.0, 65025.0, 16581375.0);
float kEncodeBit = 1.0 / 255.0;
float4 enc = kEncodeMul * v;
enc = frac(enc);
enc -= enc.yzww * kEncodeBit;
return enc;
}
float4 UnityEncodeCubeShadowDepth (float z) {
#ifdef UNITY_USE_RGBA_FOR_POINT_SHADOWS
return EncodeFloatRGBA(min(z, 0.999));
#else
return z;
#endif
}
5.2 Sampling the Shadow maps
现在我们的阴影贴图是正确的,点光影出现了。Unity的宏负责这些map的采样。
What do that macros look like, for point lights?
在这种情况下,构造的光矢量与投射阴影时相同。然后使用此矢量对阴影立方体贴图进行采样。请注意,插值器只需要三个分量,而不是四个分量。这次我们不是传递同质坐标
#if defined (SHADOWS_CUBE)
#define SHADOW_COORDS(idx1) \
unityShadowCoord3 _ShadowCoord : TEXCOORD##idx1;
#define TRANSFER_SHADOW(a) a._ShadowCoord = \
mul(unity_ObjectToWorld, v.vertex).xyz - _LightPositionRange.xyz;
#define SHADOW_ATTENUATION(a) UnitySampleShadowmap(a._ShadowCoord)
#endif
在本例中,UnitySampleShadowmap 将对立方体贴图而不是 2D 纹理进行采样。
samplerCUBE_float _ShadowMapTexture;
inline float SampleCubeDistance (float3 vec) {
return UnityDecodeCubeShadowDepth(texCUBE(_ShadowMapTexture, vec));
}
inline half UnitySampleShadowmap (float3 vec) {
float mydist = length(vec) * _LightPositionRange.w;
mydist *= 0.97; // bias
#if defined (SHADOWS_SOFT)
float z = 1.0/128.0;
float4 shadowVals;
shadowVals.x = SampleCubeDistance(vec + float3( z, z, z));
shadowVals.y = SampleCubeDistance(vec + float3(-z,-z, z));
shadowVals.z = SampleCubeDistance(vec + float3(-z, z,-z));
shadowVals.w = SampleCubeDistance(vec + float3( z,-z,-z));
half4 shadows =
(shadowVals < mydist.xxxx) ? _LightShadowData.rrrr : 1.0f;
return dot(shadows,0.25);
#else
float dist = SampleCubeDistance(vec);
return dist < mydist ? _LightShadowData.r : 1.0;
#endif
}
就像聚光灯阴影一样,对于硬阴影,阴影贴图采样一次,对于软阴影,阴影贴图采样四次。最大的区别在于 Unity 不支持对阴影立方体贴图进行过滤。因此,阴影的边缘要粗糙得多。因此,点光阴影既昂贵又有锯齿。
How can I make nice soft lantern shadows?
使用一个或多个阴影聚光灯。如果附近没有其他阴影投射对象,则可以将未阴影的光源与 Cookie 结合使用。这适用于聚光灯和点光源,并且渲染成本要低得多。