三面着色器的法线贴图

三面贴图是处理复杂几何图形的绝佳解决方案,而在没有明显的拉伸和/或纹理接缝的情况下,很难或不可能使用传统的UV进行纹理化。 这也是一种因三心二意而直截了当的法线贴图错误实现而困扰的技术。

编辑:请注意,本文中的着色器代码是为在Unity中使用而编写的。 讨论的所有技术都可以与其他引擎一起使用,但是可能需要进行修改才能使其正常工作。

此处提供示例Unity着色器:
https://github.com/bgolus/Normal-Mapping-for-a-Triplanar-Shader


目录

  1. 三面映射
    “试玩”现在怎么办?
  2. 问题
    法线贴图不是这样
    天真的方法(也就是只使用网格切线)
  3. 切线空间法线贴图
    侧面切线
  4. 寻求解决方案
    基本的麻烦
    详细融合
  5. 三面法线贴图
    UDN混合
    泛白混合
    重新定向的法线贴图
  6. “地面真相”
    追逐真相
  7. 三面法线贴图的其他技术
    屏幕空间偏导数
    交叉积切线重构
  8. 三面融合
    融合细节
  9. 其他想法
    镜面紫外线
    Unity表面着色器
    其他渲染器的三面法线(非Unity)

“试玩”现在怎么办?

快速回顾一下有人提到三面映射的含义。 世界空间三面投影映射是一种使用世界空间位置从三个方向将纹理应用于对象的技术。 三架飞机。 这也称为“无紫外线纹理化”,“世界空间投影紫外线”和其他一些名称。 此技术还存在对象空间变化。 想象一下从正上方,侧面和正面看物体,并从那些方向向物体施加纹理。

地形是一种流行的用例,因为它通常很难对几何图形进行纹理化。 基本地形通常从上方具有单个UV投影。 我们可以称其为“单平面”投影UV。 对于起伏的丘陵来说,这看起来不错,但是在陡峭的悬崖上,单个投影方向可能看起来很糟糕。

在上述地形上,从单个UV投影在近垂直悬崖上延伸的纹理非常明显。 让我们看一下三面贴图的外观。

纹理拉伸完全消失了!

有很多优秀的文章介绍了三轨映射实现的基础知识。 该技术也有优缺点,并且有一些限制。

三面映射

目标受众:中级开发人员,可以在他们选择的引擎中轻松创建材质和/或着色器…

www.martinpalko.com

我将跳过大部分内容,直接讲这篇文章。


法线贴图不是这样

三面体法线贴图的常见“天真”解决方案是仅使用网格切线。 通常,它似乎“足够接近”。 然后,您将依赖于纹理的模糊性,以至于法线错误不会变得显而易见。 不要这样 有更便宜的方法,看起来更好。

我看到的另一种技术是尝试使用少量叉积和世界法线在顶点或片段着色器中构造与世界空间矩阵的切线。 如果正确完成,这是一项非常好的技术。 但是结果常常比天真的方法好一点,因为人们并不真正了解自己在做什么。 这不是我指的更便宜的方法之一,因此很可能这也不是您想要的。

我指的一种廉价解决方案可以追溯到2003年。它来自Nvidia的GPU Gems 3。

GPU Gems 3:使用GPU生成复杂的程序地形 https://developer.nvidia.com/gpugems/GPUGems3/gpugems3_ch01.html

这是一个好又便宜的近似值。 着色器代码看起来有些奇怪,就像看起来不应该奇怪一样。 我很少看到它实际使用过的东西。 我怀疑这是一个糟糕的实现案例,使人们认为它不起作用。 的确,如果您在Unity中逐字实施此技术,它的外观甚至可能比朴素的方法差。

因此,在深入研究GPU Gems 3文章的内容之前,我们先来看一下朴素的方法及其存在的问题。

天真的方法(也就是只使用网格切线)

这是大多数人尝试的第一件事,乍看之下“行之有效”。 因此,让我们使用基本的岩石纹理和法线贴图进行尝试。

这是使用Unity的默认球体,方向光从正上方射出,没有明显的错误。 您期望它们有凹凸不平,所以这是什么问题呢? 好吧,这是最好的情况,UV包裹在默认球体上的方式,光方向和法线贴图的模糊方向都可以一起使用。 它使一切看起来都正常运行。 或至少足以让大多数人忽略或察觉。 但是,让我们将法线贴图更改为更明显的内容并按照照明方向进行操作。

所以现在照明是从左边来的,但是那些颠簸是进入还是熄灭? 左侧看起来像是在外面,右侧看起来像是在里面,顶部看起来很好。 在顶部,照明看起来像来自完全不同的方向,具体取决于您要看的是什么部分。 这就是为什么幼稚的方法不好的原因,有时您会很幸运,有时却会完全错误。

为了进行比较,这是它的外观。

凸起正在弹出而不是向外弹出,它们都与光的方向对齐。 对于此法线贴图,两面之间的柔和混合有些奇怪,但忽略了以上内容可以被视为“地面真理”。

他们在这里并排。


侧面切线

因此,让我们讨论一下切线空间法线贴图是什么以及为什么朴素的方法实际上不起作用。

切线空间法线贴图和法线贴图通常会给着色器新手造成混乱,他们只是被认为是“魔术师”。 某些谈论它们的文章立即进入数学或着色器代码,而没有首先在基本术语中进行解释,这无济于事。 所以这就是我的尝试。

法线贴图是相对于纹理方向的方向。 除此之外,还有大量的实现细节和细节,但是它们并没有改变这个基本前提。 对于Unity,红色是“正确”的位置(x轴),绿色是“向上”的位置(y轴),蓝色是垂直于表面的垂直度(z轴)。 所有这些都与纹理UV和顶点法线有关。 有时称为+ Y法线贴图。¹

如果只看法线地图并弄懂它会遇到麻烦,请不要担心。 我个人希望一次查看一个频道,以便更清楚地看到每个方向。

对于实时图形,网格的顶点存储UV的切线或“从左到右”方向。 它与顶点法线和双切线(有时称为双法线 ²)一起传递到片段着色器,作为与世界空间变换矩阵的切线。 这样,法线贴图中存储的方向可以从切线空间旋转到世界空间。 关键是切线仅与存储的纹理UV的方向匹配。 如果三面投影的映射UV与此不匹配(保证在大多数网格中都不匹配!),则您得到的法线看起来像是颠倒的,侧向的或面向您希望的其他任何方式。

在这里,我们在其上绘制了切线(x)和切线(y)对齐的纹理。 由于网格的切线是基于UV的,因此可以很好地近似这些网格的切线。 以下是使用此纹理的球体网格。 它在使用存储在网格中的UV和生成的三面UV之间交替,两者都缩放,因此纹理被平铺了多次。 您可以在球体的左侧看到方向大致对齐,但是在顶部和右侧却有很大不同。 如果再次查看朴素的方法,您会看到这种差异导致法线贴图似乎在被翻转和旋转的位置。

编辑:此处有关网格切线和三面贴图的附加说明。 一般来说,如果您要进行三面贴图,则不需要,甚至不应该在顶点存储UV。 通过扩展,网格也不应有切线。 对于导入到Unity或随附于Unity的网格,默认行为是Unity导入或生成网格的切线。 这是天真的方法可行的唯一原因。 默认情况下,在代码中创建的网格没有切线,甚至没有UV或法线,并且朴素的方法会更加残破。


基本的麻烦

三边形贴图真正想要的是为三边形纹理UV的每个“面”计算唯一的切线和双切线。 但是,如果您还记得刚开始的时候,我说不要这样做。 为什么? 因为我们可以用很少的数据得出令人惊讶的近似值。 我们可以使用世界轴作为切线。 更简单,更便宜的是,我们可以在(aka swizzle)周围交换一些法线贴图组件,并获得相同的结果!

  //基本的Swizzle // Triplanar uvs
 float2 uvX = i.worldPos.zy;  // x面向平面
 float2 uvY = i.worldPos.xz;  // y面向平面
 float2 uvZ = i.worldPos.xy;  // z面向平面//切线空间法线贴图
 half3 tnormalX = UnpackNormal(tex2D(_BumpMap,uvX));
 half3 tnormalY = UnpackNormal(tex2D(_BumpMap,uvY));
 half3 tnormalZ = UnpackNormal(tex2D(_BumpMap,uvZ)); //获取表面法线的符号(-1或1)
 half3 axisSign = sign(i.worldNormal); //翻转正切法线z以考虑表面法向面
 tnormalX.z * = axisSign.x;
 tnormalY.z * = axisSign.y;
 tnormalZ.z * = axisSign.z; //旋转切线法线以匹配世界方向和三边形
 half3 worldNormal = normalize(
     tnormalX.zyx * blend.x +
     tnormalY.xzy * blend.y +
     tnormalZ.xyz * blend.z
     ); 

我在这里省略了大部分着色器。 我们稍后再讨论。 主要要看的是前三行。 请注意,每个平面的UV以及法线贴图的旋转分量相同。 如上文所述,切线法线贴图应与其UV的方向对齐。 由于我们使用UV的世界位置,因此就是对齐!

您可能已经注意到我突然切换为使用多维数据集。 这是因为基本的swizzle方法适用于方形,体素样式的地形。 如果在轴向对齐的平面墙上使用,这实际上是事实。 不幸的是,它在圆形表面上效果不佳。

那么,我们可以以某种方式将某些圆度添加到模糊的法线贴图中吗?

详细融合

好吧,让我们在这里停留片刻。 我们将讨论切线空间法线贴图混合。 专门用于诸如如何使用细节法线的事情,而根本不涉及三面贴图。 你为什么问? 坚持我。

有几种技术可以做到这一点。 人们想到的第一个想法是“将它们加在一起”,但这并不是真正的解决方案,它只是使两个法线变得平坦。 某些更聪明的人可能会认为“叠加混合是什么?”它可以正常工作,但其受欢迎程度完全取决于它在Photoshop中的简单操作而不会完全破坏所有内容。 这不是因为它是远程正确的,甚至不是特别便宜。 这是技术的另一种情况,它比正确执行方法要昂贵得多。 基本上有两种最常用的竞争近似法,即所谓的“ Whiteout”和“ UDN”常规混合。 它们在实现和结果上都非常相似。 UDN稍微便宜一些,但可以稍微弄平边缘,Whiteout看起来稍好一些,但价格稍贵。 对于现代台式机和控制台GPU,没有理由不使用Whiteout方法而不是UDN方法。 但是,UDN混合仍可用于移动设备。

您可以在Stephen Hill的博客中了解有关不同法线贴图技术的更多信息:

详细融合
http://blog.selfshadow.com/publications/blending-in-detail/


那么,切线空间法线贴图融合如何全部应用于三边形贴图? 我们可以将每个平面视为单独的法线贴图混合,其中要与之混合的“法线贴图”之一是顶点法线! 该文章的UDN混合实际上最终变得特别便宜,因为它只是将x和y法线贴图值添加到顶点法线! 让我们看看它是什么样的。

UDN混合

  // UDN混合// Triplanar uvs
 float2 uvX = i.worldPos.zy;  // x面向平面
 float2 uvY = i.worldPos.xz;  // y面向平面
 float2 uvZ = i.worldPos.xy;  // z面向平面//切线空间法线贴图
 half3 tnormalX = UnpackNormal(tex2D(_BumpMap,uvX));
 half3 tnormalY = UnpackNormal(tex2D(_BumpMap,uvY));
 half3 tnormalZ = UnpackNormal(tex2D(_BumpMap,uvZ)); //将世界法线旋转到切线空间并应用UDN混合。
 //这些应该标准化,但这只是次要的视觉效果
 //差异,直到混合之后才跳过。
 tnormalX = half3(tnormalX.xy + i.worldNormal.zy,i.worldNormal.x);
 tnormalY = half3(tnormalY.xy + i.worldNormal.xz,i.worldNormal.y);
 tnormalZ = half3(tnormalZ.xy + i.worldNormal.xy,i.worldNormal.z); //旋转切线法线以匹配世界方向和三边形
 half3 worldNormal = normalize(
     tnormalX.zyx * blend.x +
     tnormalY.xzy * blend.y +
     tnormalZ.xyz * blend.z
     ); 

看起来不错,不是吗? UDN混合非常便宜且有效,因此非常受欢迎。 但是掺混物有一个缺点。 由于数学运算的方式,法线贴图会以大于45度的角度稍微变平。 这会导致混合法线中的细节略有丢失。

泛白混合

该文章中的Whiteout混合不存在UDN混合所遇到的问题。 通过那篇文章来看,它也只是稍微贵一点,所以让我们尝试一下。

  //遮瑕混合// Triplanar uvs
 float2 uvX = i.worldPos.zy;  // x面向平面
 float2 uvY = i.worldPos.xz;  // y面向平面
 float2 uvZ = i.worldPos.xy;  // z面向平面//切线空间法线贴图
 half3 tnormalX = UnpackNormal(tex2D(_BumpMap,uvX));
 half3 tnormalY = UnpackNormal(tex2D(_BumpMap,uvY));
 half3 tnormalZ = UnpackNormal(tex2D(_BumpMap,uvZ)); //将世界法线旋转到切线空间并应用Whiteout混合
 tnormalX = half3(
     tnormalX.xy + i.worldNormal.zy,
     abs(tnormalX.z)* i.worldNormal.x
     );
 tnormalY = half3(
     tnormalY.xy + i.worldNormal.xz,
     abs(tnormalY.z)* i.worldNormal.y
     );
 tnormalZ = half3(
     tnormalZ.xy + i.worldNormal.xy,
     abs(tnormalZ.z)* i.worldNormal.z
     ); //旋转切线法线以匹配世界方向和三边形
 half3 worldNormal = normalize(
     tnormalX.zyx * blend.x +
     tnormalY.xzy * blend.y +
     tnormalZ.xyz * blend.z
     ); 

它们与直接比较它们非常相似。

在这里,您可以看到UDN混合的法线看起来没有错,但是与Whiteout相比,看起来确实有些扁平。

三边法线贴图有两个完全合理的近似值。 两者在照明方面都没有明显的视觉问题。 对于大多数用例而言,两者都足够好。 两者都比幼稚的选项,甚至直线法线贴图都快。 像直线刷毛技术一样,对于轴对齐的墙壁来说,变白也是地面真理! 我怀疑除非有人并排显示Whiteout,否则没人会知道Whiteout并不是完美的,即使那样,要找出问题也相当困难。

GPU Gems 3中的技术呢? 实际上,它与上述两个着色器的想法相同。 它正在执行相同的法线贴图分量旋转,但是它删除了法线贴图的“ z”分量,而改用零。 为什么?

如果仔细看一下GPU Gems 3代码,它实际上与UDN混合效果相同! 两者均未实际使用法线贴图的z分量。 它们的实现最终比我编写的UDN混合着色器快了几条指令,但是产生的结果相同!

  // GPU Gems 3 blend // Triplanar uvs
 float2 uvX = i.worldPos.zy;  // x面向平面
 float2 uvY = i.worldPos.xz;  // y面向平面
 float2 uvZ = i.worldPos.xy;  // z面向平面//切线空间法线贴图
 half3 tnormalX = UnpackNormal(tex2D(_BumpMap,uvX));
 half3 tnormalY = UnpackNormal(tex2D(_BumpMap,uvY));
 half3 tnormalZ = UnpackNormal(tex2D(_BumpMap,uvZ)); //将tangimet法线旋转到世界空间,将“ z”清零
 half3 normalX = half3(0.0,tnormalX.yx);
 half3 normalY = half3(tnormalY.x,0.0,tnormalY.y);
 half3 normalZ = half3(tnormalZ.xy,0.0); // Triblend法线并添加到世界法线
 half3 worldNormal = normalize(
     normalX.xyz * blend.x +
     normalY.xyz * blend.y +
     normalZ.xyz * blend.z +
     i.worldNormal
     ); 

GPU Gems 3混合是这三个着色器中最快的选择。³但是,即使对于移动VR,使用Whiteout混合的额外费用也不大。 对于某些内容和挑剔的艺术家来说,质量差异可能是一个问题。

重新定向的法线贴图

那么我不断显示的“地面真相”图像又如何呢? 我上面链接的“详细混合”文章介绍了除UDN和Whiteout混合之外的其他几种方法。 这包括他们称为“重定向法线贴图”的方法。 这种方法很棒。 它最终比GPU Gems 3或Whiteout方法要贵一些,但是非常接近“基本事实”! 实际上,本文上面所有“地面真理”的示例图像都在使用这种技术! 即使它产生更复杂的着色器,它也仍然可能比幼稚的方法更快。

在“细节混合”中,显示的RNM函数对传递给它的法线贴图做出了一些假设,而这些假设对于Unity而言并非如此。 需要对原始代码进行较小的修改。 在文章的评论中,斯蒂芬·希尔提供了将RNM与Unity结合使用的示例。 使用该功能,三边混合着色器看起来像这样。

  //重定向的法线贴图混合// Triplanar uvs
 float2 uvX = i.worldPos.zy;  // x面向平面
 float2 uvY = i.worldPos.xz;  // y面向平面
 float2 uvZ = i.worldPos.xy;  // z面向平面//切线空间法线贴图
 half3 tnormalX = UnpackNormal(tex2D(_BumpMap,uvX));
 half3 tnormalY = UnpackNormal(tex2D(_BumpMap,uvY));
 half3 tnormalZ = UnpackNormal(tex2D(_BumpMap,uvZ)); //获取法线的绝对值,以确保混合的正切线“ z”
 half3 absVertNormal = abs(i.worldNormal); //旋转世界法线以匹配切线空间并应用RNM混合
 tnormalX = rnmBlendUnpacked(half3(i.worldNormal.zy,absVertNormal.x),tnormalX);
 tnormalY = rnmBlendUnpacked(half3(i.worldNormal.xz,absVertNormal.y),tnormalY);
 tnormalZ = rnmBlendUnpacked(half3(i.worldNormal.xy,absVertNormal.z),tnormalZ); //获取表面法线的符号(-1或1)
 half3 axisSign = sign(i.worldNormal); //将符号重新应用于Z
 tnormalX.z * = axisSign.x;
 tnormalY.z * = axisSign.y;
 tnormalZ.z * = axisSign.z; // Triblend法线并添加到世界法线
 half3 worldNormal = normalize(
     normalX.xyz * blend.x +
     normalY.xyz * blend.y +
     normalZ.xyz * blend.z +
     i.worldNormal
     ); 

这是我上面链接的功能。

  // Unity3d的重定向法线映射
 // http://discourse.selfshadow.com/t/blending-in-detail/21/18

 float3 rnmBlendUnpacked(float3 n1,float3 n2)
 {
     n1 + = float3(0,0,1);
     n2 * = float3(-1,-1,1);
    返回n1 * dot(n1,n2)/n1.z-n2;
 } 

我在其中说“ for Unity3d”,但是这对于已经拆包到标准化的-1到1范围内的任何切线空间法线贴图都应该有效。


追逐真相

除了重定向法线贴图仍然不是事实。

以上所有方法仍是近似值。 对于书呆子的读者,我确实在“地面真理”上使用了惊吓语录,因为它确实是不正确的。 问题是三面正态映射的地面真相的真实定义很难确定。 人们可能会认为任何一个投影平面本身都应该看起来与使用切线相同的网格以相同的UV投影烘焙的网格看起来相同。 类似于任何基本地形着色器将执行的操作。 但是这种方法也不正确,这种投影方式是切线空间法线贴图不当使用 。 具有法线贴图的三边形贴图唯一一次可以被视为真正的地面真理是将其应用于轴对齐的框!

在描述切线空间法线贴图的工作原理时,我在上面也撒了些谎。 尤其是咬切感,我大部分都掩饰了。 我将其描述为切线纹理示例中的“绿色箭头”。 这并非完全正确。 对于切线空间法线贴图,将的切线定义为法线和切线的交点。 切线和双切线均应垂直于法线,并且彼此垂直。 如果查看三边形切线纹理示例,则可以看到切线和切线箭头开始偏斜,并最终在拐角处变为三角形。 结果是切线和切线垂直于法线,但彼此垂直。

要正确并匹配应计算切线空间的方式,将消除双切线和所得切线空间矩阵的这种偏斜。 结果是,使用正确的切线空间时,法线方向将显示出轻微的旋转方向。 使用倾斜的切线空间时会出现不同的旋转。

问题是这样的任何投影法线贴图在技术上都是错误的。 使用的法线贴图不是以与我们显示它们相同的切线空间生成的。 它们可能是为完美的平面生成的。 法线贴图仅应与最初为其计算的几何形状和切线空间一起使用。 这意味着在地形上的法线贴图上使用重复纹理,或将其作为细节法线,或用于三边形贴图,是不正确的用法。 因此,在这些情况下没有真实的事实依据 当然,我们一直都在使用这样的法线贴图,没有人真正注意到任何错误。

因此,这取决于决定您要达到的基本事实。 最基本的是,您需要确定法线贴图应该代表什么。 是由与纹理的投影对齐的表面位移产生的法线,还是表面法线? 如果是沿投影的位移,则Whiteout混合更接近地面真相! 如果是沿表面法线的位移,则RNM是更接近的方法。 在我看来,RNM保留了更多法线贴图的细节,并且整体上看起来更好。 归根结底,这取决于个人喜好和表现。

天真的方法除外。 总是错的。


上述方法的主要优点是,它们无需进行矩阵旋转即可在切线和世界空​​间之间转换,反之亦然。 但是,您可能仍需要切线空间矢量,例如视差映射技术。 那你怎么得到那些呢?

屏幕空间偏导数

使用屏幕空间偏导数可以重建切线空间旋转矩阵。 这个想法是通过Morten Mikkleson推广的“导数映射”的方式来实现的。 该技术被错误地称为“不带切线的法线贴图”,但它与切线空间法线贴图不同。 这是三轨制图的绝佳选择,并且有很多好处。 出于本文的目的,我将不涉及派生映射。 如果您想跳下那个兔子洞,请尝试上面的两个链接以及该链接(也有指向其他两个链接的链接):

Triplanar

我们正在建立一个相当大的世界,并且希望能够使用模块化零件。 最大的技术艺术之一…

therobotseven.com

但是,您可以使用屏幕空间偏导数来帮助切线空间法线贴图。 您可以使用它们在片段着色器中为任意投影重建与世界旋转矩阵的切线。 然后可以将其应用于每侧的法线贴图。 ChristianSchüler在本文中将此矩阵称为切线框架,因为它与通常切线空间中的矩阵不完全相同。

后续:没有预先计算的切线的法线贴图

这篇文章是我2006年ShaderX 5文章[4]的后续文章,该文章关于没有预先计算切线基础的法线贴图。 在…

www.thetenthplanet.de

  //屏幕空间部分导数的切线框架// Triplanar uvs
 float2 uvX = i.worldPos.zy;  // x面向平面
 float2 uvY = i.worldPos.xz;  // y面向平面
 float2 uvZ = i.worldPos.xy;  // z面向平面//切线空间法线贴图
 half3 tnormalX = UnpackNormal(tex2D(_BumpMap,uvX));
 half3 tnormalY = UnpackNormal(tex2D(_BumpMap,uvY));
 half3 tnormalZ = UnpackNormal(tex2D(_BumpMap,uvZ)); //归一化表面法线
 half3 vertexNormal = normalize(i.worldNormal); //计算每个平面的切线框架
 half3x3 tbnX = cotangent_frame(vertexNormal,i.worldPos,uvX);
 half3x3 tbnY = cotangent_frame(vertexNormal,i.worldPos,uvY);
 half3x3 tbnZ = cotangent_frame(vertexNormal,i.worldPos,uvZ); //应用切线框架和三重法线
 half3 worldNormal = normalize(
     mul(tnormalX,tbnX)* blend.x +
     mul(tnormalY,tbnY)* blend.y +
     mul(tnormalZ,tbnZ)* blend.z
     ); 

这是为与Unity一起使用而修改的cotangent_frame()函数。

  // http://www.thetenthplanet.de/archives/1180的Unity版本
 float3x3 cotangent_frame(float3正常,float3位置,float2 uv)
 {
     //获取像素三角形的边缘向量
     float3 dp1 = ddx(position);
     float3 dp2 = ddy(position)* _ProjectionParams.x;
     float2 duv1 = ddx(uv);
     float2 duv2 = ddy(uv)* _ProjectionParams.x;  //解线性系统
     float3 dp2perp = cross(dp2,normal);
     float3 dp1perp = cross(normal,dp1);
     float3 T = dp2perp * duv1.x + dp1perp * duv2.x;
     float3 B = dp2perp * duv1.y + dp1perp * duv2.y;  //构造一个尺度不变框架
    浮动invmax = rsqrt(max(dot(T,T),dot(B,B)));  //矩阵已转置,请使用mul(VECTOR,MATRIX)
    返回float3x3(T * invmax,B * invmax,normal);
 } 

我不一定建议使用此选项进行生产三面贴图,因为它比上述选项更昂贵,尽管也可以认为它非常接近“地面真理”的版本。 它还存在一些问题,即几何的多边形形状在法线中可能会作为硬边变得明显。 这是因为切线是从实际几何曲面计算而来,而不是从平滑插值中计算出来的。 这是否是一个问题取决于您使用的反照率,法线贴图和几何形状。

附带说明一下,您可以利用它来获得低多边形样式的多面法线。

  half3 normal = normalize(cross(dp1,dp2)); 

克里斯汀·舒勒(ChristianSchüler)在该文章中就法线贴图提出了许多观点,这些观点值得我们思考。 关于我们如何处理切线空间法线贴图错误,他比我做的要详细得多。

交叉积切线重构

复制在网格上如何计算切线呢? 即使我提到这是不正确的,也可以做到。 Unity的地形着色器使用非常相似的技术来计算顶点着色器中的切线。 在这里,它是在片段着色器中完成的。

  //切线重建// Triplanar uvs
 float2 uvX = i.worldPos.zy;  // x面向平面
 float2 uvY = i.worldPos.xz;  // y面向平面
 float2 uvZ = i.worldPos.xy;  // z面向平面//切线空间法线贴图
 half3 tnormalX = UnpackNormal(tex2D(_BumpMap,uvX));
 half3 tnormalY = UnpackNormal(tex2D(_BumpMap,uvY));
 half3 tnormalZ = UnpackNormal(tex2D(_BumpMap,uvZ)); //获取表面法线的符号(-1或1)
 half3 axisSign = sign(i.worldNormal); //为每个轴构造与世界矩阵的切线
 half3 tangentX = normalize(cross(i.worldNormal,half3(0,axisSign.x,0)));
 half3 bitangentX = normalize(cross(tangentX,i.worldNormal))* axisSign.x;
 half3x3 tbnX = half3x3(tangentX,bitangentX,i.worldNormal); half3 tangentY = normalize(cross(i.worldNormal,half3(0,0,axisSign.y))));
 half3 bitangentY = normalize(cross(tangentY,i.worldNormal))* axisSign.y;
 half3x3 tbnY = half3x3(tangentY,bitangentY,i.worldNormal); half3 tangentZ = normalize(cross(i.worldNormal,half3(0,-axisSign.z,0))));
 half3 bitangentZ = normalize(-cross(tangentZ,i.worldNormal))* axisSign.z;
 half3x3 tbnZ = half3x3(tangentZ,bitangentZ,i.worldNormal); //将切线应用于世界矩阵和triblend
 //使用lamp(),因为叉积可能是NAN
 half3 worldNormal = normalize(
    钳(mul(tnormalX,tbnX),-1,1)* blend.x +
    钳位(mul(tnormalY,tbnY),-1,1)* blend.y +
    钳位(mul(tnormalZ,tbnZ),-1,1)* blend.z
     ); 

这与在顶点着色器中使用插值进行计算并不完全相同。 但同样,正如所讨论的那样,这也是错误的,因此区别并不是真正的问题。


融合细节

如果您曾经从事过三行制图,那么您可能会熟悉一些看起来像这样的代码:

  float3 blend = abs(normal.xyz);
 blend / = blend.x + blend.y + blend.z; 

划分的原因是将总和标准化。 着色器中的normalize()函数返回一个具有标准化大小的向量,但是我们希望向量的分量的标准化总和为1.0。 如果我们不对其进行任何操作,则“标准化” float3的总和基本上总是大于1.0,介于1.0和1.7321之间。 因此,如果使用法线的绝对值来混合反照率纹理,则拐角将过于明亮。 除以总和可确保新总和始终为1.0。

现在基本混合非常柔和,因此下一步通常是想出一些方法来增强混合。 这就是我经常使用的内容。

  float3 blend = abs(normal.xyz);
 blend =(混合-0.2)* 7.0;
 blend = max(混合,0);
 blend / = blend.x + blend.y + blend.z; 

这有助于提高一些技巧,但这是一段奇怪的代码,因为* 7.0是没有用的! 将7.0更改为任何非零数字或将其完全删除都没有任何效果,但是我看到的三边形实现中有一半显示了这段代码。 稍加思索就能解释为什么它是不必要的。 将数字本身除以1总是将其与某个非零值相乘不会改变它。

尽我所能告诉我,这段代码似乎源自我之前引用的同一篇GPU Gems 3文章! 不幸的是,文章出错的部分似乎是使用最多的部分。

但是, - 0.2很好,在此之后就省略乘法。 还可以通过滥用点乘积在除法运算之前对组件求和来进行其他优化。

  float3 blend = abs(normal.xyz);
 blend = max(混合-0.2,0);
 blend / =点(blend,float3(1,1,1)); 

这是一个较小的优化,原始代码使用6条指令,而优化版本使用4条指令。但是没有理由不这样做,因为结果相同,并且删除了无用的幻数。

这是相当小的,但是如果您的几何图形主要是平坦的墙壁就足够了。 但是,如果我们想要更尖锐的过渡怎么办? 您可以增加相减后的值,但是在其余部分之前,拐角会变得明显更锐利,并且过高会开始变黑。 值-0.55大约是出现明显问题之前可以达到的最大值。 这是因为在拐角处归一化的向量为(0.577,0.577,0.577),所以减去更多的值将导致max()函数将拐角变为(0.0,0.0,0.0),然后您不仅会失去值,但您被零除!

但是,我更喜欢另一种技术。 使用指数。 如果您对固定的混合锐度没问题,那么使用4的硬编码幂与上述优化选项一样快,我认为它看起来更好。

  float3 blend = pow(normal.xyz,4);
 blend / =点(blend,float3(1,1,1)); 

这是一个细微的差异,但与4条指令的成本相同。 如果要使用材质属性更好地控制混合清晰度,则价格会更高一些。 Unity将其显示为5条指令,但实际上它更像11条指令。 其他硬编码的幂将更少(每增加2的幂将增加1条指令,因此pow(normal,8)是5条指令,16是6条指令,依此类推。

较低的功率对于砖块来说可能仍然看起来并不好,但是我故意使用“最坏的情况”纹理来使混合变得明显。 具有不同的纹理和光照,稍微柔和的混合可能会有所帮助。

还有一些更高级的非对称混合形状也可以完成,这对于某些纹理或用例可能会更好。

  //非对称三边形Blendfloat3 blend = 0; //仅用于侧面混合
 float2 xzBlend = abs(normalize(normal.xz));
 blend.xz = max(0,xzBlend-0.67);
 blend.xz / =点(blend.xz,float2(1,1)); //顶部混合
 blend.y =饱和((abs(normal.y)-0.675)* 80.0);
 blend.xz * =(1-blend.y); 

如果您具有纹理的高度数据,那么还有高度混合效果更好。 有些人作弊,只是使用纹理亮度,它可以作为某些纹理的高度的近似值。

  //高度图Triplanar Blendfloat3 blend = abs(normal.xyz);
 blend / = dot(blend,float(1,1,1)); //每个平面纹理的高度值。 通常是
 //打包到另一个纹理中,或打包成(不太理想的情况)作为单独的纹理 
 //纹理。
 float3 heights = float3(heightX,heightY,heightZ)+(blend * 3.0); // _HeightmapBlending是介于0.01和1.0之间的值
 float height_start = max(max(heights.x,heights.y),heights.z)-_HeightmapBlending;
 float3 h = max(heights-height_start.xxx,float3(0,0,0));
 blend = h /点(h,float3(1,1,1)); 

魔术blend * 3.0可以减少混合区域。 这将产生类似于宝石3的混合形状。 高度图混合趋向于在较宽的起始混合区域中最有效,但边缘要紧。 单独的增能方法太光滑了,使混合物显示出不良的纹理拉伸。

上面的高度混合代码大致基于本文中的方法。

Octopoid 高度混合着色器
http://untitledgam.es/2017/01/height-blending-shader/


镜面紫外线

使用基本的三层UV计算会导致某些侧面的纹理被镜像。 在大多数情况下,这并不是一个问题。 但是,这可能导致两个平面的角在大致相同的UV下显示相同的纹理的情况,从而使这种镜像更加明显。

不过这并不难解决。 最简单的解决方案是稍微偏移所有3个平面,以减少完全重叠的机会。 将Y平面的UV添加0.33 ,将Z平面的UV添加0.67 ,就可以完成。 但是,如果您确实希望纹理对齐,则可能会导致不必要的对齐问题。

或者,您也可以翻转UV。 如果您使用的是带有明显方向的纹理(例如文本),这可能会非常有用。 将UV的x分量乘以表面法线的轴符号,然后对切线空间法线贴图进行相同操作以撤消翻转。

  //投射的紫外线翻转// Triplanar uvs
 float2 uvX = i.worldPos.zy;  // x面向平面
 float2 uvY = i.worldPos.xz;  // y面向平面
 float2 uvZ = i.worldPos.xy;  // z面向平面//获取表面法线的符号(-1或1)
 half3 axisSign = sign(i.worldNormal); //翻转UV以校正镜像
 uvX.x * = axisSign.x;
 uvY.x * = axisSign.y;
 uvZ.x * = -axisSign.z; //切线空间法线贴图
 half3 tnormalX = UnpackNormal(tex2D(_BumpMap,uvX));
 half3 tnormalY = UnpackNormal(tex2D(_BumpMap,uvY));
 half3 tnormalZ = UnpackNormal(tex2D(_BumpMap,uvZ)); //翻转法线以校正翻转的UV
 tnormalX.x * = axisSign.x;
 tnormalY.x * = axisSign.y;
 tnormalZ.x * = -axisSign.z; 

当然,您可以根据需要同时使用翻转和偏移。

Unity表面着色器

在这种情况下,Unity的Surface着色器会造成很多痛苦。 当前,尚无办法告诉表面着色器不要将网格的切线应用于世界变换到surf()函数输出的法线。 结果是您必须手动将世界空间法线转换为切线空间 ,或手动修改生成的着色器代码。 可以将法线转换为切线空间,但是不必要地昂贵。 手工修改生成的代码是很痛苦的。 我个人的选择是手写顶点片段着色器,该着色器遵循与表面着色器相同的一般结构,但格式设置稍微更合理。 如果Unity提供了完全跳过表面着色器中切线空间的选项,那就太好了。

这里有一个功能性的Surface Shader示例,将世界法线转换为surf函数内的切线空间:
https://github.com/bgolus/Normal-Mapping-for-a-Triplanar-Shader/blob/master/TriplanarSurfaceShader.shader

其他渲染器的三面法线(非Unity)

上面讨论的技术可以在任何渲染器中实时或以其他方式使用。 特技将了解渲染器的世界坐标系和法线贴图方向。 Unity是左手Y空间世界坐标系和+ Y法线贴图。 结果是,Z平面的许多直线都添加了负号,而其他直线则没有。 如果Unity得心应手,并且选择了Y,则不需要这些。 使用-Y法线贴图的渲染器可能需要增加更多带有负号的线,因为世界空间方向和许多(如果不是全部)平面的法线贴图的Y轴将颠倒! 这是三颚法线贴图会困扰大多数人的最大并发症之一。 只需知道坐标系和法线贴图的方向就可以弄清楚,但是即使那样也容易出错。 尝试一次看着一张脸,然后翻转标志直到看起来正确,然后再检查背面! 一开始会经历很多尝试和错误。 只是将其视为科学方法的一种应用!


¹^虚幻引擎和其他一些工具使用的是-Y法线贴图。 这意味着绿色通道与Unity和其他+ Y工具使用的通道相反。 在这种情况下,绿色是“下降”的程度。 区别最初来自用于UV处理的OpenGL和DirectX约定。 OpenGL中的UV位置“ 0.0,0.0”在左下方,在DirectX中则在左上方。 这意味着默认情况下,UV在OpenGL中“向上和向右”流动,在DirectX中“向下和向右”流动。 最终,使用最常用的约定并在着色器中翻转法线贴图很简单。 由于Unity必须同时支持OpenGL和DirectX,因此它实际上使DirectX平台的纹理颠倒了!

²^ Bitangent与Binormal。 绝大多数文献和着色器将切线空间的三个向量称为法线,切线和双法线。 “双切线向量”可以说是第三个向量的更准确的描述性名称,因为它是第二个曲面切线。 切线是接触但不与曲线或曲面相交的线。 反对“ bitangent”一词的人指出,它在数学界已经具有特定的,无关的含义。 传统上,切线是一条线,它是曲线上两个点的切线。 因此,显然这并非遥不可及。 但是,从技术上讲,标准双态也具有先前的含义,尽管它更接近于我们的用途。 双法线是法线向量和曲线的切向量的叉积。 但是,曲线明确表示无限细的线,而不是表面。 如果将法线视为直接指向远离表面的方向,则线的法线是垂直于切线的任何方向。 因此,binormal是该行的另一个普通名称,因此是一个好名字。 对于表面,术语“双法线”意义不大,因为它不再垂直于该表面,因此不再是法线。 这就是为什么双切线之所以具有吸引力的原因,是因为它模拟了双法线中双前缀的使用,但更准确地将其描述为它的附加切线。

因此,基本上,两者都是错误的。 如果要讲究它,这里的“切线”一词也有一些错误,因为它被用来描述一个单位矢量,它既是表面切线又是纹理坐标的x导数,它本身就是切线。 因此,这是一条直线,它是两条曲线的切线 ,所以也许该切线应称为bitangent ? 并且binormal / bitangent应该是bibitangent吗? 还是胆怯的? 横切的? 弗雷德? …

您可以看到该参数可以继续运行一段时间。 命名很难。 我个人对binormal不赞成biangent。 无论哪种方式,最好了解在切线空间中法线贴图都用于指代同一事物。

在台式机上,即使Whiteout的代码似乎没有做更多的工作,GPU Gems 3和UDN混合的工作也比Whiteout混合便宜得多。 这是因为Gems 3混合不需要使用法线贴图的z分量。 在大多数现代游戏引擎中,法线贴图仅存储x和y分量,并重建z分量。 Unity的UnpackNormal()函数所做的部分工作是z分量重建。 由于宝石3不使用z,因此着色器可以跳过对其的重建。 这导致GPU Gems 3混合三面体着色器比Whiteout节省了大约14条指令。 这似乎使Gems 3混合着色器成为移动设备的明确选择,但事实并非如此。 在移动设备上,Unity为所有3个分量存储法线贴图,而不是重建z。 特别是这样,因此不必付出重建的成本,也不必以降低视觉质量为代价来节省一些纹理内存。 结果是Gems 3和Whiteout着色器在移动设备上的性能几乎相同。 在某些情况下,根据着色器编译器选择进行优化的方式,Whiteout混合甚至可能更快一些。