自己的第一篇博客,记录一下在unity中处理法线贴图的一些技术点。
首先,什么是法线贴图(Normal Mapping)呢?维基百科上的解释是“是一种模拟凹凸处光照效果的技术,是凸凹贴图的一种实现(原文:it is a technique used for faking the lighting of bumps and dents – an implementation of bump mapping)。而我一直把它当作一种能表现物体凹凸感、真实感的技术,并且实现起来并不复杂(至少网上一搜一大堆,无脑复制黏贴都行)。但是,真正自己来实现一遍,还是有些坑踩了,所以在此记录下。
PS. 不知道凹凸贴图的小伙伴我把维基百科的解释贴在这儿,“凹凸贴图就是让每个待渲染的像素在计算照明之前都要加上一个从高度图中找到的扰动,这样得到的结果表面表现更加丰富、细致,更加接近物体在自然界本身的模样。”
既然要实现法线贴图,法线图是必不可少的,我们需要像这样的一张图
嗯,看着就和一般的贴图不一样,整体偏向蓝紫色。这是为什么呢?要解释这个问题也很简单,看官方文档就行了(https://docs.unity3d.com/Manual/StandardShaderMaterialParameterNormalMap.html),不过如果你比较懒(比如我>-<),不想去看文档的话,就看我在这里的解释吧(^ - ^)。
图中rgb通道的实际上储存着法线的xyz方向,z分量代表向上(unity通常让y代表向上,但这里不是),我们知道rgb值是0-1的范围,而方向的取值范围是-1到1之间,所以在制作这张法线图的时候会有一个转换,让-1到1的值变成0-1的值(映射函数为rgb =(normal+1)/2);大多数情况下我们不需要让法线偏很多,或者说法线根本没变,就是一个朝上的vector(0,0,1),那么变成rgb值就是(0.5,0.5,1),这个值看起来就是蓝紫色了。那些看起来不是蓝紫色的地方,说明法线偏的比较厉害,所以变成了其他颜色。
另外,如果美术给了法线图,扔进unity是需要改一下texture type的,default的话后面会有麻烦,最好改成normal map(如图所示)
现在可以开始写代码来使用这张normal图了……
等等,再写代码使用之前还需要记录一件事,那就是其实这个图里所记录的法线方向是在tangent sapce下,我们要用的话需要转换,可以选择把tangent space下的法线转换到local space再一步步处理,或者干脆把涉及到的东西转换到tangent space下,两种都可以。
不过首先,什么是tangent space?我们知道local space,world space这些是因为定义的原点不同而有了各自的空间表达,那么tangent space的原点就是和他们这些空间的原点都不一样,这个空间的原点就是模型的顶点,z轴是该点的normal方向,如下图(unity让z代表向上,原来如此!)
那么x轴,y轴就应该是和该点相切的两条线,这样的线本来在空间中是有无数条的,但模型里会定义好一个tangent,这个东西的方向一般就是x轴,而y轴就可以通过cross(x,z)来求得了。
终于,可以开始写代码实现了:)
我们在vertex shader里的代码是这样的
v2f vert (appdata_tan v)//一定要用appdata_tan否则取不到变量TANGENT_SPACE_ROTATION就用不了了
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _MainTex);
o.texNormal = TRANSFORM_TEX(v.texcoord, _NormalTex);
TANGENT_SPACE_ROTATION;
//unity自带命令,实际上就是在算下面两行东西
//float3 binormal = cross(v.tan.xyz,v.normal) * v.tan.w;
//float3x3 rotation = float3x3(v.tan.xyz,binormal,v.normal);
float3 ld = mul(unity_WorldToObject, _WorldSpaceLightPos0.xyz);//将光向量从世界空间转到模型空间
o.lightDirection = mul(rotation, ld);//再从模型空间转到切线空间
UNITY_TRANSFER_FOG(o,o.vertex);
return o;
}
其中重要的有两点:
一是vertex shader传入的变量类型要是appdata_tan
,否则TANGENT_SPACE_ROTATION
用了会报错,因为这个东西要用模型自带的tangent
计算东西,创建shader时自动生成的那个appdata
就可以不用了,免得麻烦;
二是o.lightDirection = mul(rotation, ld);
这句代码,它表达的意思把光向量从local space转到tangent space,记住它!记住它!记住它!!!
呃。。。死记硬背不是个好方法,我们还是来看看为什么吧。首先这个rotation
是个float3x3
类型的矩阵,而在这里这个矩阵是按行存储的(我没有找到确切的文档说明是按行存储,但根据结果来看一定是),而我们在计算一个向量从一个空间到另一个空间都是把转换矩阵按列存储再左乘(这里要安利一个视频合集https://www.bilibili.com/video/av6731067,对于线性代数,各种矩阵转换有很直观的解释),而这边的这个rotation
矩阵是按行存储的,相当于是按列存储的rotation
矩阵的转置,这里就很有意思了,因为这个rotation
矩阵是个单位正交矩阵(这个不明白还是百度吧),而单位正交矩阵有个性质就是它的转置矩阵等于它的逆矩阵,所以这里相当于在左乘rotation
矩阵的逆矩阵,原本按列存储的时候每个列向量代表的是tangent space下的向量,所以左乘了是把某个向量从tangent space转到local space,那么左乘它的逆矩阵就是把某个向量从local space转到tangent space了。
PS. 这里如果不太明白的话需要去熟悉一下线性代数的相关内容,上面那个链接是个很好的入门。
其实还有隐藏的第三点:),如果是自己算的rotation矩阵,这句float3 binormal = cross(v.tan.xyz,v.normal) * v.tan.w
;为什么要乘个v.tan.w
?这是因为这个w
分量记录了方向,可正可负,在OpenGL与DirectX下是不一样的,所以要乘上。
再来就是fragment shader
fixed4 frag (v2f i) : SV_Target
{
// sample the texture
fixed4 col = tex2D(_MainTex, i.uv);
float3 normal = UnpackNormal(tex2D(_NormalTex, i.texNormal));//获取图中法线向量,把normal图的texture type设置正确会得到正确结果
normal.xy *= _NormalScale;//控制凹凸程度,若是直接normal*_NormalScale的话若_NormalScale为0则下面的diffuseLight项为0,只用UNITY_LIGHTMODEL_AMBIENT项会导致cube黑乎乎的,不好看
normal.z = 1 - saturate(sqrt(dot(normal.xy,normal.xy)));//因为xy拉伸了所以z要重新算,本来x*x + y*y + z*z = 1,所以z = 1 - sqrt(x*x + y*y),而dot(normal.xy,normal.xy)就是在表达x*x + y*y
float3 ambientLight = UNITY_LIGHTMODEL_AMBIENT.rgb;
float3 diffuseLight = _LightColor0.rgb * saturate(dot(i.lightDirection.xyz,normal));
float4 finalCol = float4((ambientLight + diffuseLight) * col.rgb,col.a);
// apply fog
UNITY_APPLY_FOG(i.fogCoord, finalCol);
return finalCol;
}
我们利用unity的内置函数UnpackNormal来获取那张蓝紫色贴图里的normal,这个方法的源代码在UnityCG.cginc中有,是这样子的
// Unpack normal as DXT5nm (1, y, 1, x) or BC5 (x, y, 0, 1)
// Note neutral texture like "bump" is (0, 0, 1, 1) to work with both plain RGB normal and DXT5nm/BC5
fixed3 UnpackNormalmapRGorAG(fixed4 packednormal)
{
// This do the trick
packednormal.x *= packednormal.w;
fixed3 normal;
normal.xy = packednormal.xy * 2 - 1;
normal.z = sqrt(1 - saturate(dot(normal.xy, normal.xy)));
return normal;
}
inline fixed3 UnpackNormal(fixed4 packednormal)
{
#if defined(UNITY_NO_DXT5nm)
return packednormal.xyz * 2 - 1;
#else
return UnpackNormalmapRGorAG(packednormal);
#endif
}
还记得之前说要把美术给的法线贴图在unity中设置好texture type么?设置了那张贴图就会以DXT5nm的压缩格式存储,这样就可以调用这个内置方法来得到正确的normal了,当然了不设也行,从源代码来看unity帮你做了这个公式rgb = (normal+1)/2的事情,也可以自己去做。不过设置了texture type在跨平台的时候我们就不用考虑其他事情,无脑调用方法就好,这个还是比较方便的,所以强烈建议要设置!
最后,一个应用了法线贴图的cube就这样诞生了!项目地址
2020.05.11更新
上文中我把计算都放到了tangent space底下做,但我们也能把法线转到world space底下进行计算,所以这次更新来讲讲如何把法线从tangent space转到world space。
PS. 这么做会有一些性能上的损失,首先我们需要在片元着色器中获取法线,然后逐片元的进行坐标转换,而如果把计算都放在tangent space的话,我们可以在顶点着色器中把光源方向、视线方向都转换到tangent space,然后在片元着色器中进行颜色计算,这是个逐顶点的过程。我们都认同一件事,片元比顶点多,那么逐片元会比逐顶点性能开销大,所以说要不要这么做,还是要由开发者自己来判断了。
首先我们需要一个矩阵,这个矩阵可以把tangent space中的物体转换到world space,如何构建这样一个矩阵呢?我们先要确定x,y,z三根坐标轴的方向,这里需要tangent space底下x,y,z轴在world space底下的表达,是这样的
float3 worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
fixed3 worldNormal = UnityObjectToWorldNormal(v.normal); //z轴方向
fixed3 worldTangent = UnityObjectToWorldDir(v.tangent.xyz); //x轴方向
fixed3 worldBinormal = cross(worldNormal, worldTangent) * v.tangent.w; //y轴方向
这一步不太清楚的小伙伴可以去看冯乐乐的《Unity Shader入门精要》的4.6.2小节,明确一下数学概念。确定好三根轴以后那么矩阵就可以构建出来了,注意这里是按列排序的矩阵,最后一列存放worldPos这个变量,不浪费空间。
o.TtoW0 = float4(worldTangent.x, worldBinormal.x, worldNormal.x, worldPos.x);
o.TtoW1 = float4(worldTangent.y, worldBinormal.y, worldNormal.y, worldPos.y);
o.TtoW2 = float4(worldTangent.z, worldBinormal.z, worldNormal.z, worldPos.z);
在vertex shader中做好了这些操作后,接下来我们去fragment shader中进行使用。
float3 normal = UnpackNormal(tex2D(_NormalTex, i.texNormal)); //取出贴图中的法线,此时法线在tangent space中
normal.xy *= _NormalScale; //缩放法线
normal.z = 1 - saturate(sqrt(dot(normal.xy,normal.xy))); //计算缩放后的z
float3 worldNormal = normalize(float3(dot(i.TtoW0.xyz, normal), dot(i.TtoW1.xyz, normal), dot(i.TtoW2.xyz, normal))); //将法线从tangent space转到world space
接下来,就是大家表演的时间了。有了world space下的法线,无论做Blinn Phong还是Lambert,或者PBR等等,都是可以的。
至于法线贴图中的法线到底放在tangent space底下好,还是local space、或者world space(可以这么做但比较少见)好,仁者见仁智者见智。
参考
Unity Shader - 表面凹凸技术汇总 https://www.jianshu.com/p/fea6c9fc610f
【Unity Shaders】法线纹理(Normal Mapping)的实现细节https://blog.csdn.net/candycat1992/article/details/41605257
【光能蜗牛的图形学之旅】Unity切线空间问题和推理思考https://www.jianshu.com/p/af800402f5db