GPU 光线追踪是当今的热门话题,所以让我们来谈谈它!今天我们将光线追踪一个单个球体。
使用片段着色器。
是的,我知道。并不特别花哨。你可以在 Shadertoy 上搜索并获得数百个示例(https://www.shadertoy.com/results?query=sphere)。甚至已经有一些很棒的教程教你如何做 球体Imposter
(https://paroj.github.io/gltut/Illumination/Tutorial 13.html),
这就是我们要做的。那么我为什么要写另一篇关于它的文章呢?它甚至不是正确类型的 GPU 光线追踪!
好吧,因为光线追踪部分并不是我真正要关注的部分。这篇文章更多的是关于如何在 Unity 中将不透明的光线追踪或光线行进物体注入到光栅化场景中。但也介绍了一些处理渲染球体Imposter的额外技巧,这些技巧并不总是显而易见或被我见过的其他教程所涵盖。在这篇文章的最后,我们将得到一个紧凑的四边形上的球体Imposter,它支持多个灯光、阴影投射、阴影接收和正交相机,用于内置的前向渲染器,几乎完美地模拟了一个高多边形网格。无需额外的 C# 脚本。
我的第一个球体Imposter
如引言中所述,这是一个已经被广泛探索的领域。绘制球体的准确高效的数学方法已经为人所知。所以我只是要从 Inigo Quilez 的代码中窃取适用的函数,来创建一个基本的光线追踪球体着色器,我们可以将其贴到立方体网格上。
https://www.iquilezles.org/www/articles/intersectors/intersectors.htm
Inigo 的示例都是用 GLSL 编写的。所以我们需要稍微修改一下代码才能让它适用于 HLSL。幸运的是,对于这个函数来说,这实际上只需要将 vec
替换成 float
。
float sphIntersect( float3 ro, float3 rd, float4 sph )
{float3 oc = ro - sph.xyz;float b = dot( oc, rd );float c = dot( oc, oc ) - sph.w*sph.w;float h = b*b - c;if( h<0.0 ) return -1.0;h = sqrt( h );return -b - h;
}
该函数接受 3 个参数:ro
(光线起点)、rd
(归一化的光线方向)和 sph
(球体位置 xyz 和半径 w)。它返回光线从起点到球体表面的长度,或者在未命中时返回 -1.0
。简单明了。所以我们只需要这三个向量,我们就可以得到一个漂亮的球体。
光线起点可能是最容易获得的点。对于 Unity 着色器来说,它将是相机位置。方便地传递给全局着色器 _WorldSpaceCameraPos
中的每个着色器。对于正交相机来说,它稍微复杂一些,但幸运的是,我们不必担心。
不祥的预兆
对于球体位置,我们可以使用我们正在应用着色器的物体的世界空间位置。这可以通过 unity_ObjectToWorld._m03_m13_m23
从物体的变换矩阵中轻松提取。我们可以将半径设置为某个任意值。为了没有特别的理由,让我们选择 0.5
。
最后是光线方向。这只是从相机到我们代理网格的世界位置的方向。通过在顶点着色器中计算它并将向量传递给片段着色器,我们可以很容易地获得它。
float3 worldPos = mul(unity_ObjectToWorld, v.vertex);
float3 rayDir = _WorldSpaceCameraPos.xyz - worldPos;
请注意,在顶点着色器中对其进行归一化非常重要。你需要在片段着色器中执行此操作,否则插值的值将无法正常工作。我们正在插值的值是表面位置,而不是实际的光线方向。
但是经过所有这些,我们得到了光线追踪球体所需的三个值。
现在我说上面的函数返回光线长度。所以要获得球体表面的实际世界空间位置,你将归一化的光线乘以光线长度,然后加上光线起点。你甚至可以通过从球体位置减去表面位置并进行归一化来获得世界法线。我们将光线长度传递给 clip()
函数,以隐藏球体外部的任何东西,因为该函数在未命中时返回 -1.0
。
深度查找器
球体Imposter的最后一个要点是 z 深度。如果我们希望我们的球体与世界正确地相交,我们需要从片段着色器中输出球体的深度。否则,我们将被迫使用我们用来渲染的网格的深度。这实际上比听起来容易得多。由于我们已经在片段着色器中计算了世界位置,我们可以应用我们在顶点着色器中使用的相同视图和投影矩阵来获得 z 深度。Unity 甚至包含一个方便的 UnityWorldToClipPos()
函数,使它变得更加容易。然后,它需要一个使用 SV_Depth
的输出参数,其中包含剪切空间位置的 z 除以其 w。
将所有这些与一些基本的光照结合起来,你就会得到类似这样的东西:
它看起来像一个球体,但实际上是一个立方体。
让所有男人都为之惊叹的一个非常圆的立方体
Shader "Basic Sphere Impostor"
{Properties{}SubShader{Tags { "RenderType"="AlphaTest" "DisableBatching"="True" }LOD 100Pass{Tags { "LightMode" = "ForwardBase" }CGPROGRAM#pragma vertex vert#pragma fragment frag#include "UnityCG.cginc"struct appdata{float4 vertex : POSITION;};struct v2f{float4 pos : SV_POSITION;float3 rayDir : TEXCOORD0;float3 rayOrigin : TEXCOORD1;};v2f vert (appdata v){v2f o;// get world position of vertex// using float4(v.vertex.xyz, 1.0) instead of v.vertex to match Unity's codefloat3 worldPos = mul(unity_ObjectToWorld, float4(v.vertex.xyz, 1.0));// calculate and world space ray direction and origin for interpolationo.rayDir = worldPos - _WorldSpaceCameraPos.xyz;o.rayOrigin = _WorldSpaceCameraPos.xyz;o.pos = UnityWorldToClipPos(worldPos);return o;}// https://www.iquilezles.org/www/articles/spherefunctions/spherefunctions.htmfloat sphIntersect( float3 ro, float3 rd, float4 sph ){float3 oc = ro - sph.xyz;float b = dot( oc, rd );float c = dot( oc, oc ) - sph.w*sph.w;float h = b*b - c;if( h<0.0 ) return -1.0;h = sqrt( h );return -b - h;}half3 _LightColor0;half4 frag (v2f i, out float outDepth : SV_Depth) : SV_Target{// ray originfloat3 rayOrigin = i.rayOrigin;// normalize ray vectorfloat3 rayDir = normalize(i.rayDir);// sphere positionfloat3 spherePos = unity_ObjectToWorld._m03_m13_m23;// ray box intersectionfloat rayHit = sphIntersect(rayOrigin, rayDir, float4(spherePos, 0.5));// above function returns -1 if there's no intersectionclip(rayHit);// calculate world space position from ray, front hit ray length, and ray originfloat3 worldPos = rayDir * rayHit + rayOrigin;// world space surface normalfloat3 worldNormal = normalize(worldPos - spherePos);// basic lightinghalf3 worldLightDir = _WorldSpaceLightPos0.xyz;half ndotl = saturate(dot(worldNormal, worldLightDir));half3 lighting = _LightColor0 * ndotl;// ambient lightinghalf3 ambient = ShadeSH9(float4(worldNormal, 1));lighting += ambient;// output modified depthfloat4 clipPos = UnityWorldToClipPos(worldPos);outDepth = clipPos.z / clipPos.w;return half4(lighting, 1.0);}ENDCG}}
}
纹理化球体
好吧,这并不太令人兴奋。我们应该在上面放一个纹理。为此,我们需要 UV,幸运的是,对于球体来说,这些 UV 非常容易获得。
等距矩形 UV
让我们在上面贴一个等距矩形纹理。为此,我们只需要将法线方向输入到 atan2()
和 acos()
中,我们就会得到类似这样的东西:
float2 uv = float2(// atan 返回 -pi 到 pi 之间的值// 所以我们除以 pi * 2 来得到 -0.5 到 0.5atan2(normal.z, normal.x) / (UNITY_PI * 2.0),// acos 在顶部返回 0.0,在底部返回 pi// 所以我们将 y 翻转以与 Unity 的 OpenGL 风格对齐// 纹理 UV,所以 0.0 在底部acos(-normal.y) / UNITY_PI
);fixed4 col = tex2D(_MainTex, uv);
地球,最后的疆域。
看看,我们得到一个完美的……等等。这是什么!?
那是格林威治子午线吗?
这是一个 UV 缝!我们怎么会出现 UV 缝呢?好吧,这取决于 GPU 如何为 mip 贴图计算 mip 层级。
缝合
GPU 通过所谓的屏幕空间偏导数来计算 mip 层级。粗略地说,这是值从一个像素到它旁边的一个像素(向上或向下)的变化量。GPU 可以为每组 2x2 像素计算此值,因此 mip 层级由这些 2x2“像素四边形”中 UV 的变化量决定。当我们在这里计算 UV 时,atan2()
突然在两个像素之间从大约 0.5
跳到大约 -0.5
。这使得 GPU 认为整个纹理在这两个像素之间显示。因此,它会使用它拥有的绝对最小的 mip 贴图来响应。
那么我们如何解决这个问题呢?当然,通过禁用 mip 贴图!
不不不! 我们绝对不会这样做。 但这是你通常会找到的解决大多数 mip 贴图相关问题的方案。相反,Marco Tarini 提供了一个很好的解决方案。
http://vcg.isti.cnr.it/~tarini/no-seams/
这个想法是使用两个 UV 集,它们在不同的位置有缝合。对于我们的特定情况,由 atan2()
计算的经度 UV 已经是 -0.5
到 0.5
的范围,所以我们只需要一个 frac()
来将它们转换为 0.0
到 1.0
的范围。然后使用相同的偏导数来选择变化最小的 UV 集。神奇的函数 fwidth()
给出了值在任何屏幕空间方向上的变化量。
// -0.5 到 0.5 的范围
float phi = atan2(worldNormal.z, worldNormal.x) / (UNITY_PI * 2.0);
// 0.0 到 1.0 的范围
float phi_frac = frac(phi);float2 uv = float2(// 使用一个小偏差来优先考虑第一个“UV 集”fwidth(phi) < fwidth(phi_frac) - 0.001 ? phi : phi_frac,acos(-worldNormal.y) / UNITY_PI
);
现在我们没有缝合了!
我保证它没有隐藏在另一边
** 后记:我注意到这种技术可能只在使用 Direct3D、集成英特尔 GPU 或(某些?)Android OpenGLES 设备时才能正常工作。在桌面设备上使用 OpenGL 时,* fwidth()
函数可能使用比 GPU 用于确定 mip 层级的精度更高的导数,这意味着缝合仍然可见。Metal 保证始终以更高的精度运行。Vulkan 可以通过使用粗导数函数来强制以较低的精度运行,但截至撰写本文时,Unity 似乎没有正确地转译粗导数或精导数。我写了一篇后续文章,其中介绍了一些替代解决方案:
https://bgolus.medium.com/distinctive-derivative-differences-cce38d36797b
或者,你可以直接使用立方体贴图。Unity 可以为你将导入的等距矩形纹理转换为立方体贴图。但这意味着你将失去各向异性过滤。立方体贴图纹理采样的 UVW 本质上只是球体的法线。不过,你确实需要翻转 x 轴或 z 轴,因为立方体贴图被假定为从球体的“内部”进行观察,而在这里我们希望它映射到外部。
粗糙边缘(又名导数再次出现)
此时,如果我们将现有的光线追踪球体着色器与使用相同等距矩形 UV 的实际高多边形网格球体进行比较,你可能会注意到一些奇怪的事情。看起来光线追踪球体周围有一个轮廓,而网格没有。一个非常锯齿的轮廓。
Imposter的粗糙“轮廓”。
原因是我们讨厌的导数再次出现了。我们错过了另一个 UV 缝!在网格上,导数是针对每个像素四边形、每个三角形计算的。事实上,如果一个三角形只接触到一个 2x2 像素四边形中的一个像素,GPU 仍然会为所有 4 个像素运行片段着色器!这样做的好处是,它可以准确地计算出合理的导数,从而防止在真实网格上出现此问题。但我们在球体外部没有一个好的 UV,该函数在未命中时只返回一个常数 -1.0
,因此我们在球体外部有错误的 UV。如果在着色器中注释掉 clip()
和 outDepth
行,我们可以清楚地看到这一点。
隐藏的 UV 缝
我们想要的是让 UV 接近球体可见边缘的值,或者可能刚刚超过边缘。这令人惊讶地难以计算。但我们可以通过找到光线到球体中心的最近点来获得一个相当接近的值。在球体边缘,这是 100% 准确的,但当离球体越来越远时,它会开始向相机方向弯曲。但这很便宜,足以消除这个问题,并且与完全正确的修复几乎没有区别。
更棒的是,当球体相交函数返回 -1.0
时,我们可以通过用一个 dot()
替换光线长度来应用此修复。两个向量的点积的一个超级能力是,如果至少一个向量是归一化的,则输出是另一个向量沿归一化向量方向的幅度。这对于获取某个方向上的距离非常有用,例如相机沿视图光线距离球体枢轴的距离。
// 相同的球体相交函数
float rayHit = sphIntersect(rayOrigin, rayDir, float4(0,0,0,0.5));
// 如果是 -1.0,则剪切以在未命中时隐藏球体
clip(rayHit);
// 点积获取最靠近球体的点处的光线长度
rayHit = rayHit < 0.0 ? dot(rayDir, spherePos - rayOrigin) : rayHit;
不再有缝合。
物体缩放和旋转
所以一切都进展顺利,但如果我们想做一个更大的球体或旋转它怎么办?我们可以移动网格位置,球体会随之移动,但其他所有东西都被忽略了。
我们可以手动更改球体半径,但随后你必须手动保持你正在使用的网格同步。所以,从物体变换本身提取缩放比例会更容易。我们可以应用一个任意的旋转矩阵,但同样,如果我们能直接使用物体变换,那就更容易了。
或者,我们可以做一些更简单的事情,在物体空间中进行光线追踪!这带来了一些其他的好处,我们将在后面介绍。但在那之前,我们想要在着色器代码中添加几行。首先,我们想要使用 unity_WorldToObject
矩阵将光线起点和光线方向在顶点着色器中转换为物体空间。在片段着色器中,我们不再需要从变换中获取世界空间物体位置,因为球体现在可以位于物体的原点。
// 顶点着色器
float3 worldSpaceRayDir = worldPos - _WorldSpaceCameraPos.xyz;
// 只想旋转和缩放 dir 向量,所以 w = 0
o.rayDir = mul(unity_WorldToObject, float4(worldSpaceRayDir, 0.0));
// 需要对起点向量应用完整的变换
o.rayOrigin = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos.xyz, 1.0));// 片段着色器
float3 spherePos = float3(0,0,0);
仅通过添加上面的代码到我们的着色器,你就可以旋转和缩放游戏物体,球体也会按预期进行缩放和旋转。它甚至支持非均匀缩放!请记住,着色器中的所有这些“世界空间”位置现在都在物体空间中。所以我们需要将法线和球体表面位置转换为世界空间。只需确保使用物体空间法线作为 UV。
// 现在获取物体空间表面位置,而不是世界空间
float3 objectSpacePos = rayDir * rayHit + rayOrigin;// 仍然需要在物体空间中对其进行归一化以用于 UV
float3 objectSpaceNormal = normalize(objectSpacePos);float3 worldNormal = UnityObjectToWorldNormal(objectSpaceNormal);
float3 worldPos = mul(unity_ObjectToWorld, float4(objectSpacePos, 1.0));
大、小和可怕的三明治地球。
其他优势包括更好的整体精度,因为对所有内容使用世界空间会在远离原点时导致一些精度问题。在使用物体空间时,这些问题至少可以部分避免。这也意味着我们可以删除几个地方对 spherePos
的使用,因为它都是零,从而简化代码。
使用四边形
到目前为止,我们一直在使用立方体网格。在某些情况下,使用立方体确实有一些好处,但我承诺在本文的标题中使用四边形。而且,实际上没有充分的理由为一个球体使用整个立方体。在侧面有很多浪费的空间,我们在那里支付了渲染球体的成本,而我们知道它不会在那里。尤其是默认的 Unity 立方体,它有 24 个顶点!为什么还要浪费计算额外的 20 个顶点?
公告牌着色器
有很多公告牌着色器的示例。它们的基本原理是忽略物体的变换的旋转(和缩放!),而是将网格对齐到某个方向以面向相机。
面向视图的公告牌
这可能是最常见的版本。这是通过将枢轴位置转换为视图空间,并将顶点偏移量添加到视图空间位置来实现的。这样做相对便宜。请记住更新光线方向以匹配。
// 从变换矩阵中获取物体的世界空间枢轴
float3 worldSpacePivot = unity_ObjectToWorld._m03_m13_m23;// 转换为视图空间
float3 viewSpacePivot = mul(UNITY_MATRIX_V, float4(worldSpacePivot, 1.0));// 物体空间顶点位置 + 视图枢轴 = 公告牌四边形
float3 viewSpacePos = v.vertex.xyz + viewSpacePivot;// 从视图空间位置计算物体空间光线 dir
o.rayDir = mul(unity_WorldToObject,mul(UNITY_MATRIX_I_V, float4(viewSpacePos, 0.0))
);// 应用投影矩阵以获取剪切空间位置
o.pos = mul(UNITY_MATRIX_P, float4(viewSpacePos, 1.0));
但是,如果我们只是将上面的代码添加到我们的着色器中,球体就会出现一些问题。它在边缘被剪切,尤其是在球体位于侧面或靠近相机时。
想得太超出了范围。
这是因为四边形是一个平面,而球体不是。球体有一定的深度。由于透视,球体的体积将覆盖比四边形更多的屏幕!
艺术家对犯罪现场的再现
你可能会使用的解决方案是将公告牌按某个任意量进行缩放。但这并不能完全解决问题,因为你必须将四边形放大很多。尤其是在你靠近球体或具有非常宽的视场时。这在一定程度上违背了使用四边形而不是立方体的初衷。事实上,与立方体相比,即使是相对较小的缩放比例增加,现在也有更多像素渲染了空的空间。
面向相机的公告牌
幸运的是,我们可以做得更好。一个部分的解决方案是使用面向相机的公告牌,而不是面向视图的公告牌,并将四边形稍微拉向相机。面向视图的公告牌和面向相机的公告牌之间的区别在于,面向视图的公告牌与视图所面向的方向对齐。面向相机的公告牌面向相机的位置。区别可能很细微,代码也稍微复杂一些。
我们不再在视图空间中执行操作,而是需要构建一个旋转矩阵,将四边形旋转到面向相机。这听起来比实际操作更可怕。你只需要获取从物体位置指向相机的向量、前进向量,并使用叉积来获取向上向量和向右向量。将这三个向量放在一起,你就得到了一个旋转矩阵。
float3 worldSpacePivot = unity_ObjectToWorld._m03_m13_m23;// 枢轴和相机之间的偏移量
float3 worldSpacePivotToCamera = _WorldSpaceCameraPos.xyz - worldSpacePivot;// 相机向上向量
// 用作一个相当任意的向上方向起点
float3 up = UNITY_MATRIX_I_V._m01_m11_m2;// 前进向量是归一化的偏移量
// 这是从枢轴到相机的方向
float3 forward = normalize(worldSpacePivotToCamera);// 叉积获取一个垂直于输入向量的向量
float3 right = normalize(cross(forward, up));// 另一个叉积确保向上向量垂直于两者
up = cross(right, forward);// 构建旋转矩阵
float3x3 rotMat = float3x3(right, up, forward);// 上面的旋转矩阵是转置的,这意味着组件是
// 顺序错误,但我们可以通过交换
// 矩阵和向量在 mul() 中的顺序来解决
float3 worldPos = mul(v.vertex.xyz, rotMat) + worldSpacePivot;// 光线方向
float3 worldRayDir = worldPos - _WorldSpaceCameraPos.xyz;
o.rayDir = mul(unity_WorldToObject, float4(worldRayDir, 0.0));// 剪切空间位置输出
o.pos = UnityWorldToClipPos(worldPos);
这更好,但仍然不好。球体仍然剪切了四边形的边缘。实际上,现在是所有四个边缘。至少它是居中的。好吧,我们忘记将四边形移向相机了!从技术上讲,我们也可以按任意量缩放四边形,但让我们回到这一点。
float3 worldPos = mul(float3(v.vertex.xy, 0.3), rotMat) + worldSpacePivot;
我们忽略了四边形的 z,并添加了一个小的(任意的)偏移量以将其拉向相机。与任意缩放相比,这种选择的好处是,当距离较远时,它应该更紧密地限制在球体的边界内,并且当距离较近时,由于透视变化而进行缩放,就像球体本身一样。只有当非常靠近时,它才会开始覆盖比需要更多的屏幕空间。我在上面的示例中选择了 0.3
,因为它是在靠近时不会覆盖太多屏幕空间,同时仍然覆盖所有可见球体,直到你非常非常靠近。
你知道,你可能可以用一些数学方法来计算出在给定距离下拉动或缩放四边形的确切值……
完美的透视公告牌缩放
等等!我们可以用一些数学方法来计算出这个值!我们可以计算出相机到枢轴向量和相机到球体可见边缘之间的角度。事实上,它始终是一个直角三角形,直角位于球体的表面!还记得你老朋友 SOHCAHTOA 吗?我们知道相机到枢轴的距离,那是斜边。我们也知道球体的半径。由此,我们可以计算出从将该角度投影到四边形的平面所形成的直角三角形的底边。有了它,我们可以缩放四边形,而不是修改 v.vertex.z
。
// 获取直角三角形的正弦值,斜边是 // 球体枢轴距离,对边使用球体半径
float sinAngle = 0.5 / length(viewOffset);// 转换为余弦
float cosAngle = sqrt(1.0 - sinAngle * sinAngle);// 转换为正切
float tanAngle = sinAngle / cosAngle;// 上面的两行等效于此,但速度更快
// tanAngle = tan(asin(sinAngle));// 获取直角三角形对边,直角位于球体枢轴处,乘以 2 以获取四边形大小
float quadScale = tanAngle * length(viewOffset) * 2.0;// 按计算的大小缩放四边形
float3 worldPos = mul(float3(v.vertex.xy, 0.0) * quadScale, rotMat) + worldSpacePivot;
考虑物体缩放
在这篇文章的开头,我们将所有内容转换为使用物体空间,这样我们就可以轻松地支持旋转和缩放。我们仍然支持旋转,因为四边形的朝向实际上并不重要。但四边形不会像立方体那样随着物体的变换进行缩放。解决这个问题最简单的方法是从变换矩阵的轴中提取缩放比例,并将我们使用的半径乘以最大缩放比例。
// 获取物体缩放比例
float3 scale = float3(length(unity_ObjectToWorld._m00_m10_m20),length(unity_ObjectToWorld._m01_m11_m21),length(unity_ObjectToWorld._m02_m12_m22)
);
float maxScale = max(abs(scale.x), max(abs(scale.y), abs(scale.z)));// 将球体半径乘以最大缩放比例
float maxRadius = maxScale * 0.5;// 使用新的半径更新我们的正弦计算
float sinAngle = maxRadius / length(viewOffset);// 执行其余的缩放代码
现在你可以均匀地缩放游戏物体,球体仍然会完美地限制在四边形内。
椭球体边界?
也应该可以计算出椭球体或非均匀缩放球体的精确边界。不幸的是,这开始变得有点困难了。所以我现在不会花精力去解决这个问题。我将把它留作“读者的练习”。(也就是说,我不知道怎么做。)
视锥体剔除
使用四边形的另一个问题是 Unity 的视锥体剔除。它不知道四边形在着色器中被旋转了,因此,如果游戏物体被旋转,使其以边缘朝向观察者,它可能会被视锥体剔除,而球体仍然可见。解决这个问题的方法是使用一个自定义的四边形网格,其边界已通过 C# 代码手动修改为一个盒子。或者,你可以使用一个四边形网格,其中一个顶点向前推了 0.5,另一个顶点向后推了 0.5,位于 z 轴上。我们已经在着色器中通过用 0.0
替换 v.vertex.z
来展平网格。
阴影投射
所以现在我们得到了一个漂亮渲染的球体,它位于一个四边形上,可以被照亮、纹理化,并且可以移动、缩放和旋转。所以让我们让它投射阴影!为此,我们需要在着色器中创建一个阴影投射器通道。幸运的是,相同的顶点着色器可以在这两个通道中重复使用,因为它只创建了一个四边形,并将光线起点和方向传递下去。当然,这些对于阴影来说与相机完全相同,对吧?然后,片段着色器实际上只需要输出深度,这样你就可以删除所有讨厌的 UV 和光照代码。
哦。
光线起点和方向需要来自光源,而不是相机。我们用来表示光线起点的值始终是当前相机位置,而不是光源。好消息是,这并不难修复。我们可以用 UNITY_MATRIX_I_V._m03_m13_m23
替换任何对 _WorldSpaceCameraPos
的使用,它从逆视图矩阵中获取当前视图的世界位置。现在,只要阴影是用透视投影渲染的,它就应该可以正常工作!
哦。哦,不。
方向阴影使用正交投影。
正交痛苦
透视投影和光线追踪的优点是,光线起点位于相机的位置。这很容易获得,即使对于任意视图也是如此,如上所示。对于正交投影,光线方向是前进视图向量。这很容易从逆视图矩阵中再次获得。
// 视图空间中的前进方向是 -z,所以我们想要负向量
float3 worldSpaceViewForward = -UNITY_MATRIX_I_V._m02_m12_m22;
但是我们如何获得正交光线起点呢?如果你尝试在线搜索,你可能会看到很多示例使用 C# 脚本来获取逆投影矩阵。或者滥用当前的 unity_OrthoParams
,它包含有关正交投影的宽度和高度的信息。然后,你可以使用剪切空间位置来重建光线起源的近视平面位置。这些方法的问题在于,它们都获取的是相机的正交设置,而不是当前光源的设置。所以我们必须在着色器中计算逆矩阵!
float4x4 inverse(float4x4 m) {float n11 = m[0][0], n12 = m[1][0], n13 = m[2][0], n14 = m[3][0];float n21 = m[0][1], n22 = m[1][1], n23 = m[2][1], n24 = m[3][1];float n31 = m[0][2], n32 = m[1][2], n33 = m[2][2], n34 = m[3][2];float n41 = m[0][3], n42 = m[1][3], n43 = m[2][3], n44 = m[3][3]; float t11 = n23 * n34 * n42 - n24 * n33 * n42 + n24 * n32 * n43 - n22 * n34 * n43 - n23 * n32 * n44 + n22 * n33 * n44;// ... 等等,还有多少行?
好吧,我们不要这样做。这些只是超过 30 行函数的前几行,而且越来越长,越来越复杂。一定有更好的方法。
几乎是视平面
事实证明,你不需要任何这些。我们实际上并不需要光线起点位于近平面。光线起点实际上只需要是沿着前进视图向量拉回的网格位置。只要足够远,以确保它没有从球体的体积内部开始。至少假设相机本身还没有位于球体内部。并且相机位置处的“近平面”而不是实际的近平面完全符合这个要求。
我们已经在顶点着色器中知道了顶点的世界位置。所以我们可以将世界位置转换为视图空间。将 viewSpacePos.z
设置为零,然后转换回世界空间。这将产生一个可用于正交投影的光线起点!
// 将世界空间顶点位置转换为视图空间
float4 viewSpacePos = mul(UNITY_MATRIX_V, float4(worldPos, 1.0));// 将视图空间位置展平到相机平面上
viewSpacePos.z = 0.0;// 转换回世界空间
float4 worldRayOrigin = mul(UNITY_MATRIX_I_V, viewSpacePos);// 正交光线 dir
float3 worldRayDir = worldSpaceViewForward;// 以及到物体空间
o.rayDir = mul(unity_WorldToObject, float4(worldRayDir, 0.0));
o.rayOrigin = mul(unity_WorldToObject, worldRayOrigin);
实际上,我们甚至不需要做所有这些。还记得上面提到的 dot()
的超级能力吗?我们只需要相机到顶点位置向量和归一化的前进视图向量。我们已经有了相机到顶点位置向量,那是原始的透视世界空间光线方向。我们知道前进视图向量,可以通过从上面提到的矩阵中提取它来获得。方便的是,此向量已经归一化了!所以我们可以删除上面的代码中的两个矩阵乘法,并改为执行以下操作:
float3 worldSpaceViewPos = UNITY_MATRIX_I_V._m03_m13_m23;
float3 worldSpaceViewForward = -UNITY_MATRIX_I_V._m02_m12_m22;// 原始的透视光线 dir
float3 worldCameraToPos = worldPos - worldSpaceViewPos;// 正交光线 dir
float3 worldRayDir = worldSpaceViewForward * -dot(worldCameraToPos, worldSpaceViewForward);// 正交光线起点
float3 worldRayOrigin = worldPos - worldRayDir;o.rayDir = mul(unity_WorldToObject, float4(worldRayDir, 0.0));
o.rayOrigin = mul(unity_WorldToObject, float4(worldRayOrigin, 1.0));
** 这里有一个小问题。这对于倾斜投影(即剪切的正交投影)不起作用。为此,你确实需要逆投影矩阵。但是剪切的透视投影是可以的!*
面向光源的公告牌
还记得我们是如何做面向相机的公告牌的吗?以及用于缩放四边形以考虑透视的那些花哨的数学方法吗?对于正交投影,我们不需要任何这些。只需要执行面向视图的公告牌,并将四边形按物体的变换的最大缩放比例进行缩放。但是也许我们不要删除所有这些代码。我们可以照常使用现有的旋转矩阵构建,只是将 forward
向量更改为负的 worldSpaceViewForward
向量,而不是 worldSpacePivotToCamera
向量。
透视点
事实上,现在可能是讨论聚光灯和点光源如何使用透视投影的好时机。如果我们想要支持方向光、聚光灯和点光源阴影,我们需要在同一个着色器中同时支持透视和正交投影。Unity 还使用此通道来渲染相机深度纹理。这意味着我们需要检测当前投影矩阵是否是正交的,并在两种路径之间进行选择。
好吧,我们可以通过检查投影矩阵的特定组件来找出我们正在使用哪种类型的投影矩阵。如果投影矩阵的最后一个组件是 0.0
,则它是透视投影矩阵,如果它是 1.0
,则它是正交投影矩阵。
bool isOrtho = UNITY_MATRIX_P._m33 == 1.0;// 公告牌代码
float3 forward = isOrtho ? -worldSpaceViewForward : normalize(worldSpacePivotToCamera);
// 执行其余的公告牌代码// 四边形缩放代码
float quadScale = maxScale;
if (!isOrtho)
{// 执行完美的缩放代码
}// 光线方向和起点代码
float3 worldRayOrigin = worldSpaceViewPos;
float3 worldRayDir = worldPos - worldSpaceRayOrigin;
if (isOrtho)
{worldRayDir = worldSpaceViewForward * -dot(worldRayDir, worldSpaceViewForward);worldRayOrigin = worldPos - worldRayDir;
}o.rayDir = mul(unity_WorldToObject, float4(worldRayDir, 0.0));
o.rayOrigin = mul(unity_WorldToObject, float4(worldRayOrigin, 1.0));// 不要担心,我稍后会展示整个顶点着色器
现在,我们得到了一个可以正确处理正交投影和透视投影的顶点函数!片段着色器中不需要更改任何内容来考虑这一点。哦,我们实际上可以使用同一个函数来表示阴影投射器通道和前向照明通道。现在,你也可以使用正交相机了!
阴影偏差
现在,如果你一直在关注,你将得到一个输出深度的阴影投射器通道。但我们没有调用阴影投射器通常用于应用偏移的任何常用函数。目前,这并不明显,因为我们还没有进行自阴影,但如果我们不修复它,这将是一个问题。
我们不会使用内置的 TRANSFER_SHADOW_CASTER_NORMALOFFSET(o)
宏来表示顶点着色器,因为我们需要在片段着色器中进行偏差。幸运的是,在物体空间中进行光线追踪还有另一个好处。阴影投射器顶点着色器宏调用的第一个函数假设传递给它的位置位于物体空间中!我的意思是,这是有道理的,因为它假设它正在处理起始的物体空间顶点位置。但这意味着我们可以直接使用阴影投射器宏调用的偏差函数,使用我们光线追踪的位置,它们就会正常工作!
是的,实际上仍然只是一个四边形。
Tags { "LightMode" = "ShadowCaster" }ZWrite On ZTest LEqualCGPROGRAM
#pragma vertex vert
#pragma fragment frag_shadow#pragma multi_compile_shadowcaster// 是的,我知道顶点函数缺失fixed4 frag_shadow (v2f i,out float outDepth : SV_Depth) : SV_Target
{// 光线起点float3 rayOrigin = i.rayOrigin; // 归一化光线向量float3 rayDir = normalize(i.rayDir); // 光线球体相交float rayHit = sphIntersect(rayOrigin, rayDir, float4(0,0,0,0.5)); // 上面的函数在没有相交时返回 -1clip(rayHit); // 计算物体空间位置float3 objectSpacePos = rayDir * rayHit + rayOrigin; // 输出修改后的深度// 是的,我们将 objectSpacePos 作为两个参数传递// 第二个用于物体空间法线,在本例中// 是归一化的位置,但该函数将其转换为// 世界空间并进行归一化,所以我们不必这样做float4 clipPos = UnityClipSpaceShadowCasterPos(objectSpacePos, objectSpacePos);clipPos = UnityApplyLinearShadowBias(clipPos);outDepth = clipPos.z / clipPos.w; return 0;
}
ENDCG
就是这样。这适用于所有阴影投射器变体。方向光阴影、聚光灯阴影、点光源阴影以及相机深度纹理!你知道,如果我们想支持多个灯光……
** 我没有添加对 GLES 2.0 点光源阴影的支持。这需要将距离光源的距离作为阴影投射器通道的颜色值输出,而不是仅仅硬编码 *0*
。添加它并不难,但这会使着色器变得更加混乱,因为需要添加一些 *#if*
和我们需要计算的特殊情况数据。所以我没有包含它。*
** 编辑:我忘记了在处理 OpenGL 平台上的深度时的一件事。OpenGL 的剪切空间 z 是 -w 到 +w 的范围,所以你需要执行一个额外的步骤将其转换为片段着色器输出深度所需的 0.0 到 1.0 的范围。*
#if !defined(UNITY_REVERSED_Z) // 基本上只有 OpenGL
outDepth = outDepth * 0.5 + 0.5;
#endif
阴影接收
所以现在我们得到了一个有效的阴影投射。那么阴影接收呢?这将进入 Unity 特定内容的阴暗面。如果你不是凡人,现在就转身吧……或者,如果你不太关心 Unity 的内置前向渲染路径。(或者至少跳到下一节关于 深度 的内容。)
点亮它
在早期,我发布了一个带有基本漫反射光照设置的着色器。如果你一直关注这篇文章,那么前向基本通道的光照代码现在应该看起来像这样。
// 世界空间表面法线和位置
float3 worldNormal = UnityObjectToWorldNormal(objectSpaceNormal);
float3 worldPos = mul(unity_ObjectToWorld, float4(objectSpacePos, 1.0));// 基本光照
half3 worldLightDir = UnityWorldSpaceLightDir(worldPos);
half ndotl = saturate(dot(worldNormal, worldLightDir));
half3 lighting = _LightColor0 * ndotl;// 环境光照
half3 ambient = ShadeSH9(float4(worldNormal, 1));
lighting += ambient;// 应用光照
col.rgb *= lighting;
没什么特别的。获取你的世界法线和世界位置。获取世界光线方向。执行一个钳位点积。将光线颜色乘以点积,添加环境光照,并将纹理乘以光照。这有点像你开始学习光照着色器教程时的代码。但我们显然缺少阴影。
对于传统的向前基本照明着色器,我们想要在一些地方添加一些宏,Unity 会自动为我们提供所需的内容。将 SHADOW_COORDS(#)
添加到 v2f
结构体中,在顶点函数中调用 TRANSFER_SHADOW(o);
,然后在片段着色器中调用 UNITY_LIGHT_ATTENUATION(atten, i, worldPos);
。我们当然可以这样做,至少对于向前基本通道来说可以这样做。在桌面和主机上,Unity 的方向光的阴影使用屏幕空间阴影。也就是说,阴影贴图被渲染,然后它们被投射到从相机深度纹理中事先计算出的世界位置上,并保存在屏幕空间纹理中。所以上面的宏只是将屏幕空间位置传递下去,你可以从剪切空间位置中廉价地计算出它。
通常,这是通过上面提到的 TRANSFER_SHADOW(o);
宏来完成的,并从顶点着色器传递到片段着色器。但我们已经在片段着色器中计算了剪切空间位置。我们可以重复使用它,使用宏调用的同一个 ComputeScreenPos(clipPos)
函数来计算屏幕空间位置。然后,我们可以使用最终的内置宏,让它完成剩下的工作。
我们确实想要使用 UNITY_LIGHT_ATTENUATION(atten, i, worldPos);
宏。它为我们处理额外的功能,例如光线饼干。以及另一个我将在稍后提到的原因。
但有一个小问题。内置的阴影宏期望你传递一个包含屏幕空间位置的结构体。而我们的 v2f
结构体没有它,如果我们不必这样做,我们也不想把它添加到该结构体中。
谢天谢地,我们不需要这样做,我们可以创建一个虚拟结构体!它只需要 SHADOW_COORDS(0)
宏来添加其他宏期望的结构体元素,然后我们就可以自己设置它添加的值。
// 虚拟结构体
struct shadowInput {SHADOW_COORDS(0)
);// 世界空间位置和剪切空间位置
float3 worldPos = mul(unity_ObjectToWorld, float4(surfacePos, 1.0));
float4 clipPos = UnityWorldToClipPos(float4(worldPos, 1.0));#if defined (SHADOWS_SCREEN)
// 为屏幕空间阴影设置阴影结构体
shadowInput shadowIN;
#if defined(UNITY_NO_SCREENSPACE_SHADOWS)
// 移动阴影
shadowIN._ShadowCoord = mul(unity_WorldToShadow[0], float4(worldPos, 1.0));
#else
// 屏幕空间阴影
shadowIN._ShadowCoord = ComputeScreenPos(clipPos);
#endif // UNITY_NO_SCREENSPACE_SHADOWS
#else
float shadowIN = 0;
#endif // SHADOWS_SCREEN// 宏创建一个名为 atten 的变量,其中包含阴影
UNITY_LIGHT_ATTENUATION(atten, shadowIN, worldPos);// 将方向光照乘以 atten
half3 lighting = _LightColor0 * ndotl * atten;
现在,我们可以接收方向阴影了!
捕捉阴影。
多个灯光
所以我说过我们确实想要使用上面的 UNITY_LIGHT_ATTENUATION
宏。这是真正的原因。它还处理其他灯光类型!Unity 的内置前向渲染器通过为每个灯光再次渲染物体来绘制多个灯光。所以我们需要一个前向添加通道。而我们现在用于前向基本通道的唯一的阻止它与前向添加通道一起工作的东西是环境光照。所以你可以复制片段着色器函数并删除两行环境光照代码。
或者,你可以在 #if defined(UNITY_SHOULD_SAMPLE_SH)
中放置这三行环境光照代码,它只对基本通道为真。然后,你可以为这两个通道共享完全相同的函数。
RTX 关闭!
碎片深度
使用 SV_Depth
有一个很大的问题。它禁用了早期深度拒绝。基本上,这意味着如果你在视锥体中,你将支付渲染Imposter的成本。即使它位于其他东西的后面,并且不可见。通常,GPU 可以使用深度缓冲区来跳过对位于相机更近的其他物体后面的网格运行片段着色器。但由于 GPU 在片段着色器运行之后才知道深度是多少,因此它无法做到这一点。
“那么
SV_DepthLessEqual
或SV_DepthGreaterEqual
呢?”
是的!这是一个很棒的问题,佩蒂尼奥先生。你怎么知道(https://mynameismjp.wordpress.com/2010/11/14/d3d11-features/) 我在想这个?
保守深度输出
SV_DepthLessEqual
和 SV_DepthGreaterEqual
语义是 SV_Depth
的替代品,它们告诉 GPU 仍然执行早期深度拒绝,这是为着色器模型 5.0 添加的。但是要使用它,我们必须确保网格比我们要渲染的球体更靠近或更靠近相机。为此,我们想要将网格拉向相机。现在,面向相机的四边形位于球体的中心。
问题是我们需要将顶点移近相机,而不会修改它们的屏幕空间位置。我们已经为它们计算出了完美的边界,所以如果我们最终取消了这些操作,那就很不幸了。
一个选择是计算比球体枢轴更靠近相机 maxRadius
的视平面的剪切空间位置。然后替换已经计算出的剪切空间位置的 z。剪切空间有一个非常酷的功能,你可以更改剪切空间位置的 z,而不会影响它在屏幕上的位置或导致插值问题。
// 着色器末尾的常用剪切空间
o.pos = UnityWorldToClipPos(worldPos);// 获取球体枢轴沿 // 前进视图向量更靠近 `maxRadius` 的位置
float4 nearerClip = UnityWorldToClipPos(worldSpacePivotPos — worldSpaceViewForward * maxRadius);// 转换应用“透视除法”以获取真实的深度 Z
float nearerZ = nearerClip.z / nearerClip.w// 用新的值替换原始的剪切空间 z
o.pos.z = nearerZ * o.pos.w;
但这种技术有一个很大的缺陷。如果你将相机移得太靠近或试图穿过我们的Imposter球体,那么当我们应该仍然看到它时,它就会消失。问题是“更近的深度”被放置在相机的后面。我们可以尝试对此进行更多工作。例如,尝试将 z 限制为近平面。或者,更确切地说,是将 z 限制在近剪切平面的内部,因为在近剪切平面上仍然会导致它被剔除。
// 限制为近剪切平面的内部
o.pos.z = min(o.pos.w - 1.0e-6f, nearerZ * o.pos.w);
但……这实际上并没有按预期工作*。
当我 说你可以更改剪切空间位置的 z 而不会出现任何问题时,我撒了点谎。这在一种情况下会失败,那就是当网格的某些顶点位于相机后面时。我们试图解决的正是这种情况。即使进行了钳位,四边形仍然比它应该的更被剪切。所以这失败了。
老实说,我不太了解这个问题,无法解释原因。
但有一个更便宜的解决方案,它在一般情况下表现良好,并且不会在“某些顶点位于相机后面”的情况下失败!我们可以沿着光线方向将顶点移动一个球体半径。对于正交投影,这实际上只是世界位置减去前进视图乘以球体半径。对于透视投影,如果我们使用归一化的光线方向,它实际上不会拉得足够远。所以我们需要再次调用我们的朋友 dot()
,以找出我们需要偏移多远才能正确地将四边形的表面拉近一个球体半径。
// 这将顶点推向相机
// 在顶点着色器中的 UnityWorldToClipPos 行之前添加
worldPos += worldSpaceRayDir / dot(normalize(viewOffset), worldSpaceRayDir) * maxRadius;// 着色器末尾的常用剪切空间
o.pos = UnityWorldToClipPos(worldPos);
现在,当你的相机靠近时,它仍然会与球体进行近剪切,但结果与剪切实际球体网格非常相似。一般来说,如果网格没有被剪切,那么光线偏移四边形也不会被剪切。
添加了这一点之后,只需要将片段着色器中的 SV_Depth
语义替换为适当的选项。对于任何不是 OpenGL 的内容,你应该使用 SV_DepthLessEqual
。这是因为 Unity 为非 OpenGL 平台使用反向 Z 深度。反向 Z 深度意味着距离更远的物体具有比更近的物体更小的深度值。所以实际上,我们只需要检查 UNITY_REVERSED_Z
关键字是否处于活动状态。对于 OpenGL……好吧,实际上这都是无用的。我们无法保证 OpenGL 平台支持与 SV_DepthGreaterEqual
等效的功能,直到 OpenGL 4.2。 基本上,你可能被迫在任何不使用反向 Z 深度的平台上使用 SV_Depth
。然后,所有这些将四边形拉近相机以减少过度阴影的操作对于这些平台来说都是毫无意义的。但我们至少可以在着色器中处理这两种情况。
** 编辑:运行 OpenGL 4.2+ 的 Unity 仍然使用常规的 z 深度。你可以为它使用 *SV_DepthGreaterEqual*
,但实际上,任何支持 OpenGL 4.2 的平台,你都希望改为运行 Direct3D、Vulkan 或 Metal。*
// 这样更新片段着色器函数
half4 frag_(forward/shadow) (v2f i
#if UNITY_REVERSED_Z && SHADER_TARGET > 40, out float outDepth : SV_DepthLessEqual
#else
// 该设备可能无法使用保守深度, out float outDepth : SV_Depth
#endif) : SV_Target
收尾工作
还有一些小细节需要完善着色器。支持“每个顶点”非重要灯光、雾和基本实例化。这些并不十分有趣,所以我将快速介绍一下。
“每个顶点”非重要灯光
由于我们实际上没有很多顶点,所以我们还需要在片段着色器中调用“顶点灯光”函数。这实际上只是复制和粘贴顶点灯光函数,将其放在一个 #if
中,并将返回值添加到 lighting
中。
#if defined(VERTEXLIGHT_ON)
// “每个顶点”非重要灯光
half3 vertexLighting = Shade4PointLights(unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,unity_LightColor[0].rgb, unity_LightColor[1].rgb,unity_LightColor[2].rgb, unity_LightColor[3].rgb,unity_4LightAtten0,worldPos, worldNormal);lighting += vertexLighting;
#endif
或者至少它应该这么简单。VERTEXLIGHT_ON
是由 #pragma multi_compile_fwdbase
控制的关键字之一。但似乎,如果你在顶点着色器中没有这个函数,那么具有该关键字的着色器变体将永远不会创建。所以你必须用自己的多编译行来强制执行它。
#pragma multi_compile _ VERTEXLIGHT_ON
雾
与这篇文章中介绍的许多内容一样,Unity 的内置宏假设你正在从顶点着色器中输出某种值。对于桌面,这只是将原始的 clipPos.z
传递给片段着色器,然后片段着色器在调用那里的雾宏时计算实际的雾衰减。所以,我们可以在前向通道的片段着色器末尾添加带有 UNITY_APPLY_FOG(clipPos.z, col);
的常用宏。
对于移动设备,衰减是在顶点着色器中计算的。但我们需要使用我们在片段着色器中计算的 clipPos.z
,所以如果你想要同时支持移动设备和桌面,我们不能只使用常用的 UNITY_APPLY_FOG(clipPos.z, col)
宏。所以我们必须计算衰减并将它传递给宏,但只在移动设备上这样做。
// 雾
float fogCoord = clipPos.z;#if (SHADER_TARGET < 30) || defined(SHADER_API_MOBILE)
// 宏计算雾衰减
// 并创建一个 unityFogFactor 变量来保存它
UNITY_CALC_FOG_FACTOR(fogCoord);
fogCoord = unityFogFactor;
#endifUNITY_APPLY_FOG(fogCoord, col);
实例化
要将实例化添加到着色器中,请复制和粘贴 Unity 关于此内容的文档中提到的适当宏:
GPU 实例化
https://docs.unity3d.com/Manual/GPUInstancing.html
转到将实例化添加到顶点和片段着色器部分,并将宏复制到 appdata
和 v2f
结构体、顶点函数以及片段函数中。忽略 BUFFER
和 PROP
宏。但你确实需要在片段着色器中使用 UNITY_SETUP_INSTANCE_ID(i);
。在实例化着色器中,unity_ObjectToWorld
和 unity_WorldToObject
矩阵是实例化属性。由于我们在片段着色器中使用它们,因此我们也需要实例 ID。
完成的着色器
话不多说,这是完成的着色器,完整代码如下。
完整代码(https://gist.github.com/bgolus/1188cd89968b977d5c468bf7bbb3250b)
其他想法
表面着色器和着色器图
因为我知道下一个问题每个人都会问的是“如何在表面着色器/着色器图中做这个?”。以下是这些问题的答案。
你不能。*
好吧,你可以构建光线起点和方向。你可以进行球体的光线追踪。你当然也可以执行所有过程式 UV 操作。你甚至可以更新表面法线,使其像球体一样被照亮。
你不能做的一件事是从片段着色器中调整用于光照和阴影的深度或世界位置。因此,深度相交看起来会很奇怪,阴影看起来会很奇怪,并且非常靠近表面的灯光看起来也不正确。因为它们都将使用原始网格表面的位置。
因此,在 Unity 的任何渲染器中使用这种技术的唯一选择是使用手写的顶点片段着色器。至少目前是这样。我希望有一天你能够在着色器图中输出修改后的深度值。但截至撰写本文时,他们还没有提到要添加此功能。
** 人们指出,HDRP 的着色器图确实具有在主节点上设置深度以执行每个片段深度功能的能力。不过,它使用的是 *SV_Depth*
而不是 *SV_DepthLessEqual*
,因此不需要执行四边形的射线方向偏移。感谢 Rémy 提醒我。希望他们能将此功能添加到 URP 中。
https://portal.productboard.com/unity/1-unity-graphics/tabs/7-shader-graph
抗锯齿
我的许多其他文章都是关于抗锯齿的,为什么我在这里跳过了它?因为这是一个没有完美解决方案的难题。
Inigo Quiles 在这里有一个关于如何处理光线追踪球体的抗锯齿的优秀示例:
https://www.shadertoy.com/view/MsSSWV
基本原理是使用光线到点距离计算(这也用于修复外部边缘的 UV),以近似地了解光线在屏幕空间中距离球体边缘有多近。这可以为你提供一个渐变,可以使用类似于我在 Alpha to Coverage 文章中使用的函数来锐化,然后将其用作输出 alpha。也可以用于非 MSAA 和非不透明用例中的 alpha 混合。
使用原始着色器的 4x MSAA 与使用 Alpha to Coverage 的比较。
// 将此添加到通道中,位于 CGPROGRAM 之外,以启用
// alpha to coverage
AlphaToMask On
// 光线到球体枢轴距离
float rayToPointDist = length(rayDir * dot(rayDir, -rayOrigin) + rayOrigin);// fwidth 获取 ddx 和 ddy 偏导数的总和
// float fDist = fwidth(rayToPointDist);// fwidth 是对此的粗略近似
float fDist = length(float2(ddx(rayToPointDist), ddy(rayToPointDist)));// 锐化光线到点距离
// 以球体半径为中心,根据导数 +/- 半个像素
float alpha = (0.5 - rayToPointDist) / max(fDist, 0.0001) + 0.5;// 根据锐化的 alpha 剪切
// 不要根据光线命中未命中进行剪切
clip(alpha);// 将 alpha 限制在 0 到 1 的范围内,并在
// 采样纹理后将其应用于输出 alpha
col.a = saturate(alpha);
这似乎应该足够好了,对吧?那么为什么我说没有完美的解决方案呢?为什么我没有默认实现它呢?对外部边缘进行抗锯齿并不能解决与光栅化网格或从片段着色器输出深度的其他着色器相交时的锯齿问题。当启用 MSAA 光栅化三角形时,会为三角形覆盖的每个子样本计算深度,但片段着色器只对每个像素运行一次。这意味着两个相交网格的每个子样本覆盖可以准确地确定到子样本计数。此着色器正在从片段着色器中写入深度,因此每个像素只有一个深度。然后,相同的深度用于所有子样本。因此,相交处没有 AA。从技术上讲,在光栅化几何体和输出深度的片段着色器之间仍然存在一些 AA,因为会考虑相交三角形的平面。但在两个深度写入着色器之间将不会存在任何 AA。
使用原始着色器的 4x MSAA 与使用 Alpha to Coverage 的比较。请注意,两种方法的相交处都是相同的。与视平面对齐的光栅化表面在与Imposter相交处显示锯齿。以角度观察的光栅化表面显示抗锯齿,但它等效于与视平面对齐的表面相交。
上面的 Shadertoy 示例可以处理相交,因为它在一个通道中渲染所有这些球体,并对分析形状执行每个像素排序和合成。它甚至没有执行任何 MSAA。
据我所知,没有一种有效的方法可以在启用 MSAA 的情况下处理片段着色器深度写入,同时仍然只对每个像素运行一次片段着色器。这将导致使用 sample
插值修饰符来强制片段着色器对每个子样本运行。当 MSAA 的全部目的是不这样做时,这对于性能来说并不理想。但它看起来确实很不错。
使用原始着色器的 4x MSAA 与强制每个子样本渲染的着色器的比较。
使用原始着色器的 4x MSAA 与强制每个子样本渲染的着色器的比较。请注意,超级采样情况下的所有相交处都得到了适当的抗锯齿。
// 更新 v2f 结构体以使用插值的 ray dir 和 ray origin 向量的样本修饰符,以强制片段
// 着色器对每个子样本运行,并为插值
// 值获取每个子样本位置的唯一计算
struct v2f
{float4 pos : SV_POSITION;sample float3 rayDir : TEXCOORD0;sample float3 rayOrigin : TEXCOORD1;UNITY_VERTEX_INPUT_INSTANCE_ID
};// 将此添加到 CGPROGRAM 块中,作为通道,因为
// 样本修饰符是着色器模型 5.0 的功能
#pragma target 5.0// 你可能还想对纹理 mip 层级进行偏差
// 因为如果我们已经进行了超级采样,为什么不呢!
half4 col = tex2Dbias(_MainTex, float4(uv, 0, -1));
Alpha to Coverage 的 4x MSAA 与强制每个子样本渲染的着色器的比较。
原始着色器的 4x MSAA 与 Alpha to Coverage 与强制超级采样相交比较。
延迟渲染
我没有在示例着色器中包含延迟渲染通道。没有理由认为这不能与延迟渲染一起使用。它甚至会更容易编写。但我试图使着色器尽可能简单。
想了解更多游戏开发知识,可以扫描下方二维码,免费领取游戏开发4天训练营课程