渲染TA实战:三方向映射 UE4
大家好我是来自搜狐畅游引擎部TA组的小源小榞小圆,这次是分享关于三方向映射纹理的做法。
三方向映射在制作地形贴图,和一些需要在任意角度覆盖模型的材质会比较常用到。比如苔藓、污渍、锈迹、以及最常用到的地形材质。
在正文开始前,在这里插播一条对同期进入公司的Crossous同学的感谢,靠谱的Crossous迅速地帮忙解决了一些我提出的unity问题!赞美Crossous!
Step 1 构建三方向遮罩:
通过将顶点法线和 float3(0, 0, 1) 点积可以求出顶点法线在指向场景上方向量的投影(这里为UE为标准哈,unity自行转换一下)。求绝对值后减去一个系数,可以将投影长度较小的部分移动成为负值,这可以方便我们在后续将他们给裁掉。之后再乘一个系数是为了将遮罩为正的绝大部分缩放到大于1, 仅留下边缘部分0-1之间的数作为过渡。调整系数2控制遮罩边缘的硬度。系数1我尝试比较好的值是0.56左右,不过也可以根据边缘硬度调整。
然后你就可以得到这样一个遮罩。
我们把另外两个轴向的遮罩也做一下,加在一起除个三看看效果:
可以看到三个方向的遮罩有重叠的部分。而因为我们是使用顶点法线和轴向做点积求得的遮罩,所以其数值实际是从中心到边缘逐渐变小的。又因为三个轴向的采用了相同的系数,所以想要把相交部分平均分开,只需要用当前遮罩减去相邻的遮罩。如下:
上图是其中一块的遮罩,需要注意的是,在减去相邻遮罩的时候,需要将原本的遮罩的负值部裁切掉。对应材质里的 max(x , 0)。
我们求出两块遮罩(第三部分就是前两块的剩余),然后线性插值一点颜色看看:
嗯,乍看起来效果不错,但是可以看到绿色的部分占据了一些间隙,这可能是我们不希望出现的问题。修复这个小问题只需要加一个小小的系数:
这个系数也是随便给个合适的值就可以。或许你会发现,现在遮罩似乎出现了一些微妙的不平衡,但……这点大小的差别谁会在意呢?
好的现在我们有了三方向的遮罩,我们就可以用这三方向的遮罩来混合贴图了。贴图采样用世界空间坐标来作为UV:
一点点友善的提醒:Texture Sample节点的细节面板里,把Sampler Source修改为Shared Warp。我忘记这个限制是来自DX还是HLSL的了,一个Shader最多只能拥有16个Sampler,在做地形材质的时候,采样器数量非常容易超过限制。
好的,这里似乎就要结束了,但三方向映射还有一个比较令人头疼的点:法线。
如果我们直接像采样颜色或者粗糙度这些贴图一样去采样法线、然后用遮罩和线性插值混合,就会得到相当奇怪的光照。
这是因为,通常情况下,我们在游戏里给场景、角色、道具所使用的的法线都是切线空间法线。当我们直接使用模型的UV来采样法线贴图的时候,切线空间的法线会被“正确的”读取到相应的UV位置上去,这可以让引擎能正确的理解这张法线的颜色所表示的方向。如下图:
这是一张DX模式下的法线贴图,R通道越接近1,表示当前像素越向右侧偏转,越接近0,则越像左侧偏转。绿通道同理,接近1则向下,接近0则向上。注意,这里的上下左右的偏转,都是基于UV空间来说的。如果我们的法线贴图不再贴合UV空间(也就是目前我们所遇到的状况,我们使用了世界空间坐标、而非模型的UV来采样了贴图),就会让着色器产生困惑,这到底是是朝那里?
因为模型的UV绝大部分情况下都是不连续的,所以我们最后实际映射到模型上的法线,在UV坐标下看,很可能是这样的……
解决这个切线空间法线导致的问题目前我们尝试了两种方式:
第一种,直接使用矩阵,将三方向采样的切线空间法线转换到模型对应的切线空间:
为了解决这个问题,需要用一点点矩阵工具:我们既然知道,光照效果出现问题是因为切线空间不匹配,那我们把每个像素的法线向量,旋转到模型所对应的切线空间不就万事大吉。
下图可以看到,我们使用世界坐标来作为UV采样纹理时的UV情况。这里需要注意每个通道的顺序:排在前面的是U、后面的是V。V的正方向应该在U正方向的顺时针90度方向,如果V出现在了U的逆时针90度方向,则需要考虑调转轴向。
知道这个有啥用呢?知道这个,我们不就知道,我们在采样贴图的时候,所假定的切线方向了呀,还是世界空间的切线方向。同时,我们还能读取到模型顶点的世界坐标法线……
法线转换时候熟悉的味道出现了,还记得我们可以tangent、normal做叉乘,得到bitangent,然后可以用这仨向量构建一个从切线空间转到世界空间的矩阵吗?我们现在可以在搞一下:
上图是X轴方向投射的结果,但如果是X轴的负方向呢?从X轴的负方向看过去,UV做标记就变成了VU坐标,这时候我们只需要在把负方向投影的矩阵里的副切线翻转就可以了。还记得我们之前用世界空间法线和轴向做点积的时候吗?轴向的一侧结果是正值,另一侧则是负值,我们用它就可以了:
好的,那我们把三个轴向的矩阵都构建一下:
这里我们可以注意到,实际上从Z轴向来看的映射、和从Y轴的映射被合并在了一起计算,因为这俩轴向的映射的世界空间切线都是X轴方向,而模型顶点法线的取值固定,我们可以使用同一个矩阵来进行转换。减少一些运算量(考虑到三方向映射要将同一张图片采样3次,已经是比较大的开销了,能省点就省点吧)。
第二种,我们可以直接通过通道调整,将三方向映射的切线空间法线,转换到世界空间下,再分别和顶点法线进行混合,随后使用矩阵变换将世界空间法线转换到切线空间(如果不再进行其他法线计算的话,甚至可以直接使用世界空间法线进行光照计算,而略过到切线空间的矩阵转换)。
第二种做法的好处显而易见,首先比第一种少了数次矩阵变换,另外从切线空间到世界空间的矩阵很方便获取,不需要我们自己构建变换矩阵。以从Y轴方向为例,使用XZ轴作为UV来进行贴图采样的时候,切线空间的法线的R通道对应世界坐标的X方向,G通道为Z轴的反方向,而B通道垂直于XZ平面。
一个常用的法线混合计算方式是:
float3 normal = normalize(float3(baseNormal.xy + additionalNormal.xy , baseNormal.z * additionalNormal.z));
但是我们注意到,一般我们用到上边这个混合公式的时候,是B通道近似垂直于模型表面的时候,因此我们在混合模型顶点法线和Y轴方向投射的法线时,应当是:
float3 normalWorldSapce = normalize(float3(vertexnormal.x + yAxisProjectionNormal.x , vertexnormal.y * -yAxisProjectionNormal.y , vertexnormal.x + yAxisProjectionNormal.z));
在UE材质蓝图的实现里就是下边这个样子:
完成三个方向投射的法线贴图的转换、并将他们和顶点法线混合后,再使用我们做好的遮罩将他们进行混合,最后进行一次从世界空间到切线空间的转换即可。
然后我们就完成了三方向映射。
/*一些额外内容*/
鉴于国内Unity用户基数也相当的大,并且我司目前也主要使用Unity引擎,所以在写这篇文章的时候,部门老大也想在Unity里也实现一下。我这边在URP(Universal Render Pipeline)下实现一个三方向映射的Shader。(Unity版本:2021.3.2.f1c1)
为了比较方便的引用引擎内置PBR光照,我们直接拷贝一份Lit Shader过来进行修改,少写一点字。
先给shader改个名字,叫啥无所谓不要重名就成。然后下边翻翻看Lit Shader,略过茫茫多的ShaderProperties和宏定义,只看主干,Shader分为俩SubShader,其中第二个SubShader是在第一个subshader失效时启用的,所以我们主要关心第一个subshader。第一个subshader里有一堆Pass,其中最主要会直接影响画面渲染结果的就是ForwardLit和GBuffer,俩分别对应前向管线和延迟管线。管线选择可以在URP的配置文件中选择,这里篇幅(懒)原因就不展开细说,毕竟也不是我们这篇文章的主题,我们就使用前向管线来制作一版三方向映射的Shader。所以我们目前也只需要关注ForwardLit pass。
然后我们看ForwardLit pass的内容,在Lit shader这个文件里,ForwardLit只include两个文件,这俩文件包含了ForwardLit pass的大部分代码。
为了方便修改(改坏不愁)我们把LitForwardPass.hlsl文件单独拷贝一份并引用进我们的shader,LitInput.hlsl对我们的帮助不大,可以直接注释掉。
LitForwardPass里最主要有三个函数:InitializeStandardLitSurfaceData负责进行贴图采样,之后传递给InitializeInputData进行雾、投影等计算,再传递入UniversalFragmentPBR进行光照计算并输出给颜色。
我们只要自定 InitializeSurfaceData 这个部分就可以完成我们需要的效果。所以我们把这个函数注释掉,开始自己写这个部分。
我们还需要先知道SurfaceData这个结构体所包含的对象有哪些,这个结构体定义在SurfaceData.hlsl中。因为我们在Shader里移除了LitInput.hlsl的引用,而这个文件里引用了SufaceData.hlsl,所以我们还需要把这个结构放在CustomLitForwardPass.hlsl里或者引用一下。
接下来我们就可以开始整活了。
先简单写一下Properties需要的各个参数。
在.hlsl文件里申明对应的Properties变量,我这里写在了CustomLitForwardPass里Attributes之前。
因为我们需要使用世界坐标来进行采样,所以我们需要VertexShader部分计算WorldPosition并传递出来。正好原版的LitForwardPass.hlsl有这个部分,只是被包裹在一个宏定义里,我们注释掉这个宏就行。同时我们也需要用到世界空间的法线和切线用来做矩阵转换,这个部分也是Shader原本就有的内容,暴力注释掉宏就可以。
然后就可以开始写我们的“InitializeStandardLitSurfaceData”了。
首先是遮罩的计算、贴图的采样和混合,基础颜色和粗糙度AO使用普通的线性插值混合就可以了,切线空间的法线贴图经历了先转换到世界空间、和顶点法线混合后,也使用线性插值进行了混合,最后重新从世界空间转换到切线空间。
然后就是为我们的surfaceData赋能:使用我们计算好的albedo、packed、normal来初始化surfaceData的参数。
到这里就快要结束了,我们返回Unity查看Shader效果,会发现没有法线效果,这是因为在InitializeInputData函数里还有关于法线的宏定义,因为我们没有使用:LitShader的GUI,这个是否使用了法线和细节贴图的宏会保持关闭。给它注释掉法线就正常了。
最后我们在Unity里实现的效果如下图:
Over
欢迎加入我们!
感兴趣的同学可以在官网投递简历: