半透明材质的渲染效果
一些关于材质的物理模型
非物理(pbr)方式
基于经验模型的半透明材质实现方式
原理
采取的实现方法是基于经验模型的BSDF + BTDF
方程大致由两部分组成
N.L部分表示经验模型的BSDF
V.(-L)部分表示经验模型的BTDF
接下来会着重介绍V.(-L)部分的细节和实现
厚度的影响
光的透射,需要考虑物体的局部厚度
在物体比较厚的位置,光线从物体穿过需要衰减更多能量,具有更小的亮度。
所以需要模型的厚度贴图
厚度贴图中亮度大表示厚度更小。亮度小表示厚度更大。
后面会会介绍,这个厚度贴图是如何烘焙的。
实现代码
half3 vLTLight =vLight + vNormal * fLTDistortion;
half fLTDot = pow(saturate(dot(vEye, -vLTLight)),iLTPower)* fLTScale;
half3 fLT = fLightAttenuation *(fLTDot + fLTAmbient)* fLTThickness;
outColor.rgb +=cDiffuseAlbedo *cLightDiffuse * fLT;
fLTAmbient
表示所有方位上穿过材质的光线,也是表示半透明光线强度的最小值。
iLTPower
表示背光扩散程度,指数的幂值,类似Phong模型中的高光范围(值越大高光范围越小),值越大,背光扩散范围越小。
fLTDistortion
偏转光的向量,模拟光从表面离开时表面对光传播方向的影响,类似pbr中的菲涅耳值对物体表面的影响。
fLTThickness
用来计算光在介质中传播的衰减。
fLTScale
统一放大光对半透明材质的影响,因为上述实现中存在pow运算,这会让光对材质的影响数值变得过小,需要使用scale来中和这部分影响。
最终效果
物理(pbr)方式(wip)
基于物理的渲染的理论基础是BSSDF。
理论
在这张图中,你可以把带红色轮廓的绿色圆圈想象成单个像素的着色区域。来自右侧的大橙色箭头代表某一特定方向射入的光线,其中大部分光线在表面反射,产生镜面反射;然而,其中一部分光线会折射进入表面,与介质中的粒子相遇。光线在这些粒子中散射,导致部分波长被吸收,从而使光线呈现出该材质特有的反照率颜色。最终,未被完全吸收的光线以随机方向折射回表面,可能最终进入眼睛,使你得以看到它。这里的关键在于,散射后的次表面光线全部从该像素范围内射出,这使得我们可以简化问题,只考虑击中像素中心的光线,并估算返回的光量。实际上,我们就是假设所有散射光都从光线最初进入表面的位置射出:
然而,当在游戏或图形学中讨论次表面散射时,通常并非指这种情况。一般来说,这个术语只在材质特别半透明时才会被提及,此时散射光能够比典型材质散射得更远。如下图所示:
在这种情况下,散射光不再局限于像素范围内,而是散射到可能被相邻像素覆盖的区域。换句话说,我们已经超越了仅考虑单个像素入射光的范畴,而需要全局考虑其他区域的光照来模拟光线在表面内扩散的可见效果。正如你所想象的,这极大地增加了实时渲染器的复杂性,不过稍后我们会详细讨论这一点。
暂且假设我们在进行离线渲染,并具备光线追踪及其他高级功能。在这种情况下,我们如何计算次表面散射的贡献呢?如果我们追求极高的准确性,就会采用体积路径追踪技术来模拟光线在介质内的散射和折射。虽然这种方法听起来可能非常昂贵(实际上也确实如此!),但它正逐步成为高端动画和视觉特效工作室的可行方案。不论如何,这种方法能够模拟光线的完整路径以及表面下发生的各种散射事件,从而给出一个能考虑复杂几何和非均匀散射参数的较为准确的结果。
该领域的大部分工作源于 Jensen 在 2001 年发表的论文,该论文首次向图形学界引入了双向表面散射分布函数(BSSDF)的概念。这篇论文催生了各种有趣的后续研究,涵盖离线和实时渲染两个领域。由于全面讨论这些内容超出了本文的范围,所以我在此将内容简化为游戏中常用的基本近似方法。
评估任意 BSSDF 就意味着需要计算如下积分:
graphics.pixar.com/library/App…
新的积分归结为“对表面上每个点,计算辐照度并与一个以距离为参数的散射函数相乘”。这种方法虽然简单了不少,但仍无法解决“为了给单一点着色需要考虑表面上所有点”的问题。不过,它为我们提供了一个有用的框架和简化的思维模型。将“散射只依赖于距离”这一假设基本上将散射函数转化为一种模糊/滤波核,只不过它是应用于任意网格表面而非二维图像。因此,你经常会看到 R(r)部分被称为扩散分布(有时也叫扩散核),这是我在本文余下部分常用的术语。如果你想象人类皮肤的扩散分布,会发现它类似于在一个完全黑暗的房间中,用一束极窄的全白激光照射皮肤时的效果:
实现
Texture-Space Diffusion
之所以称为“纹理空间”,是因为所有的散射/扩散模拟都是在利用头部或面部网格 UV 参数化的二维空间中完成的,而不是在屏幕空间的每个像素上进行。算法的简化解释大致如下:
- 使用一个特殊的顶点着色器绘制网格,该着色器设置 output.position = float4(uv * 2.0f - 1.0f, 0.0f, 1.0f);,以及一个像素着色器,该像素着色器输出动态与预计算光照的辐照度,从而在纹理空间中计算出辐照图。
- 使用多个高斯核对辐照图进行模糊处理,每个核进行水平和垂直方向的两次通道处理(它们将皮肤的扩散分布近似为多个可分离高斯函数的和)。
- 利用摄像机的投影矩阵,按照真实场景绘制网格;随后在像素着色器中采样卷积后的辐照图,并应用漫反射反照率图,从而获得具有次表面散射效果的漫反射结果。
显然,就质量而言,其结果不言自明,因此这也不应成为推广的主要障碍。此外,该方法相当灵活,只要所使用的扩散分布能够表示为高斯函数之和,就能适用于多种情况。然而,仔细思考后,会发现一些明显的问题:
- 在纹理空间中进行着色在性能和内存使用上可能难以调优。因为在像素着色器中着色时,随着网格离摄像机越来越远,像素着色调用会自然减少(网格在屏幕空间中占据的区域变小);但在纹理空间着色时,无论网格在屏幕上多小,都必须对离屏辐照缓冲区中的每个纹素进行着色。因此,你需要根据角色与摄像机的距离手动缩减缓冲区大小。理想状态下,缓冲区的 mip 级别应与网格在主光栅化路径中采样的级别相匹配,但由于网格复杂度及摄像机位置的不同,可能会使用不同的 mip 级别。如果屏幕上同时存在多个角色,为辐照缓冲区分配足够的内存也会变得十分困难,尤其是在突然的摄像机切换使得某角色进入特写镜头时。
- 纹理空间扩散依赖于表面的局部邻域在 UV 映射中也保持局部性。大多数情况下这一假设成立,但任何 UV 缝都会破坏这一假设,导致出现难看的伪影,除非采取额外的补救措施。此外,对于诸如耳朵等薄表面也不适用,因为耳朵的两侧在 UV 空间中可能并不接近。演示中通过渲染一个半透明阴影图来记录每个纹素最近深度的 UV 坐标,从而使得另一侧的表面能够在光线击中网格的位置采样模糊后的辐照图,以解决薄表面前向散射的问题。
- 通常,人脸等物体的 UV 映射存在大量拉伸和扭曲,这会导致当结果映射回世界空间时,扩散分布本身也会产生扭曲:
Nvidia 的演示通过在纹理空间渲染一个“拉伸图”来解决该问题,该图记录了 U/V 扭曲因子,用于修改扩散分布的权重。你可以预先计算该图,但在皮肤绑定或混合形状出现极端变形的情况下,其结果可能不准确,此时实时计算拉伸图会产生更好的效果。
- 针对角色集成一种特殊的纹理空间渲染路径,其额外的复杂性可能会带来较高的维护成本,而且可能与通常在屏幕或摄像机空间中进行的其他处理(例如,将光源分箱到视锥对齐的网格中)不兼容。
Screen-Space Subsurface Scattering (SSSS)
当发现纹理空间扩散在实时游戏场景中既困难又昂贵后,一些人开始探索利用屏幕空间缓冲区中存储的光照数据的更廉价选项。这个想法由 Jorge Jimenez 的工作普及,他在第一版 GPU Pro 中发表了一篇非常受欢迎的文章。
从高层次来看,这与纹理空间扩散的假设类似:你希望采样点周围表面的 3D 局部性,在光栅化并投影到 2D 时得以保留。当这种情况发生(而且通常确实如此)时,你只需从相邻纹素中进行几次纹理采样,即可将光照与扩散分布进行卷积。这比在展开的 UV 空间中对表面进行着色更具吸引力(尤其是在 2009 年代的硬件和 API 上),因为你可以借用典型的延迟渲染方法,将每个像素的辐照度输出到第二个渲染目标,从而避免必须手动确定表面所需的着色率,也能防止意外对被其他不透明表面遮挡的像素进行着色。
正因如此,许多引擎最终采用了 SSSS 技术并在发布的游戏中使用它。但这并非没有缺点,下面列举了你可能遇到的一些问题:
- 由于工作在屏幕空间,你只能收集最终被光栅化的表面的辐照度。这意味着在发生复杂遮挡(例如鼻子遮挡部分面颊)的情况下,信息可能会缺失。这与 SSAO 或 SSR 中出现的问题基本相同。
- 通常,你能采集的样本数量是有限的,这会导致采样不足。如果对所有像素重复使用相同的采样模式,可能会出现色带或“块状”现象;而使用随机采样则可能产生噪点。后者可以通过去噪和/或 TAA 来缓解。
-
在屏幕空间中操作需要多遍渲染,这意味着你无法在单个像素着色器中完全完成表面的着色。如果其他部分均为正向渲染,这将显得不太理想,因为可能需要额外占用一些仅用于部分材质的渲染目标的内存。
-
如果你只希望对需要的像素执行 SSSS 通道,就需要一种系统来标记这些像素并选择性地运行着色器。Jorge 的原始实现采用了模板缓冲,但我从未见过哪位图形程序员喜欢使用模板缓冲。另一种选择是对每个像素的值进行分支,或者运行一个通道来生成屏幕空间块的列表。
-
与纹理空间扩散类似,你无法真正考虑光线穿过诸如耳朵等薄表面的传输情况。虽然在某些情况下表面两侧都可能被摄像机看到,但一般而言你不能依赖这一点。必须单独处理这种情况,例如使用阴影图深度缓冲和/或预计算的厚度图。建议阅读 Jorge 的演示幻灯片以获取更多思路。
Pre-integrated Subsurface Scattering
前面两种技术都侧重于将扩散问题转化为采样问题:一种是在纹理空间中采集局部辐照度,另一种是在屏幕空间中采样。实际上,预积分次表面散射采用的是一种完全不同的策略:它完全不试图实时采样周围表面上的光照,而是预先计算出光在材质内部散射后的结果,然后在渲染时根据被着色点的局部属性直接查找出相应的散射贡献。
Eric Penner 提出的关键见解是:如果一个表面是完全平坦且入射光均匀分布,那么次表面散射实际上是不可见的——就像在 Photoshop 中对一个颜色均匀的图像应用高斯模糊一样,效果不会有任何变化。换句话说,只有当入射辐照度存在局部变化或表面存在曲率时,次表面散射才会显现出来。
我们可以用简单的实验来验证这一点。比如,对一个均匀光照的平面进行光线追踪时,无论是否考虑次表面散射,结果几乎没有区别;而当我们渲染一个曲面(例如球体)时,启用了次表面散射后,原本沿着 N⋅L 下降的入射辐照度会因为光线“绕过”零点而显得更加柔和,阴影区域也会出现明显的光扩散效果。此外,在存在阴影的场景中,光线甚至会“渗透”进被遮挡区域,使得阴影边缘变得更加柔和自然。
为此,我们可以将问题转化为一个预积分问题。思路是:对一个圆形区域进行积分,将所有入射光的贡献合并,然后将结果转换成一个一维查找表(例如使用 θ 或 cos(θ) 作为参数)。接着,在实际渲染时,我们可以根据每个着色点的局部曲率或入射角度,从这个查找表中直接获取预计算好的散射效果值。为了将这一方法应用到任意网格上,还需要为每个着色点分配一个等效的球体,其半径取决于局部曲率。虽然可以利用像素四边导数来计算曲率,但这种方法在细节较多的法线图上可能会产生不连续的问题,因此很多时候更倾向于预先计算曲率图以获得更平滑一致的效果。
执行此操作的方法是取一个圆,然后通过将入射辐照度乘以扩散轮廓进行积分来计算该圆上每个点的最终衰减:
这最终会成为圆周表面上的一种卷积,这非常符合我们将 SSS 视为过滤操作的思维模型。对整个圆执行此操作将为你提供每个值的结果θθ在范围内[−π,π]但结果是对称的,所以我们可以丢弃一半的结果,只保留[0,π]. 我们也可以从参数化切换到θ参数化cos(θ)在范围内[−1,1],这给了我们一个很好的一维查找表,我们可以使用任何结果进行索引N⋅L可视化一下,我们会得到如下结果:
这可以立即用于渲染,但有一个主要问题:它仅适用于具有特定半径和扩散轮廓的球体。但是,我们可以通过计算一系列半径的衰减结果,轻松地将其扩展为 2D 查找表:
此时,您可能会想“这对于球体来说很棒,但是我们如何将其用于任意网格?”,这是一个合理的问题。要使用这个新的 2D 查找纹理,我们基本上需要获取网格上的每个着色点,并根据该点的局部曲率将其映射到具有特定半径的球体。Konstantin Kolchin和Hiroyuki Kubo之前的研究已经发现次表面散射外观与表面曲率有关。这个想法其实很简单:找出局部曲率,将其映射到球体半径,然后使用它来选择预积分衰减查找纹理的适当行。Penner 提出了一种在像素着色器中即时执行此操作的方法,方法是利用像素四边形导数和少量几何图形:
预积分次表面散射技术的最大优势在于:所有繁重的积分计算都在预处理阶段完成,渲染时仅需通过简单的查表操作即可得到次表面散射贡献,从而不会增加任何额外的运行时开销。这使得该方法非常适用于实时前向渲染,并且可以在保持高帧率的同时呈现出令人满意的次表面散射效果。
当然,这种方法也有一些局限性:
-
由于依赖于预计算查找表,当材质或局部散射参数发生变化时,就需要重新生成查找表,灵活性有所降低。
-
对于复杂几何体,尤其是存在空隙或薄弱部分的区域,简单的距离或曲率估计可能会高估散射效果。
-
如何精确计算局部曲率并将其映射到合适的等效球体半径,也需要精细调控和额外的数据预处理。
最后贴一下UE的实现:
附录
AO 烘焙原理
基于光线追踪(Ray Tracing)
这是最准确但计算量较大的方法,主要用于高质量烘焙。基本步骤如下:
- 在 UV 空间展开模型,确定烘焙目标纹理的分辨率。
- 对每个像素(Texel)转换到 3D 空间,找到对应的表面点及法线方向。
- 在该点的半球方向上随机采样多条光线,检查光线是否与场景中的几何体相交。
- 统计光线未被遮挡的比例,得出 AO 值。
- 存储 AO 值到贴图。
for each texel in AO_map:
surface_point = UV_to_World(texel)
normal = Get_Normal(surface_point)
occlusion = 0
for i = 0 to num_samples:
direction = Sample_Hemisphere(normal)
if Ray_Hits_Object(surface_point, direction):
occlusion += 1
AO_map[texel] = 1 - (occlusion / num_samples) // 计算遮挡比例
烘焙厚度贴图过程
1.反转表面法线
2.烘培AO
3.将烘培结果颜色翻转存到贴图中
blender烘焙厚度贴图的具体过程可以参考:
blender.stackexchange.com/questions/1…
引用
www.slideshare.net/slideshow/c…
blender.stackexchange.com/questions/1…