作者:王子饼干 ——镇江虹视游戏科技有限公司联合创始人、《多洛可小镇》制作人
原文链接:UnityShader入门精要笔记8:基础纹理B
原文发布时间:2020年3月10日
文章类型: 授权转载
不懂数学者禁入此门
笔记当前使用的Unity版本:“2019.3.3”
笔记当前Unity最新的版本:“2020.1.0.Alpha 25”
在笔记7当中,我们已经讲过了最基础的,如何使用对纹理表面进行采样,以及图片的Warp Mode,在该篇笔记中,我们会增加一些难度,通过法线贴图为模型表面增加光照的细节。以及使用纹理遮罩来控制模型表面的高光。
所谓凹凸映射(Bump Mapping)就是通过一张纹理贴图来轻微的修改模型表面的法线,让它可以为模型提供更多的细节。当我们开始这个章节之前,我们应该先把素材准备一下。
这张图是我们之前所使用的纹理图,现在我们需要为它生成一张法线贴图,我们使用PS2019CC打开这张图。并且用滤镜>3D>生成法线图来制作法线图
有很多可以调整的地方,不过它默认的设置就已经够用了,所以我们直接把它保存,并且导入到Unity中。
法线映射不会真正的改变模型表面,也就是它不会让一个方块,或者一个胶囊体变得凹凸不平,而是看起来凹凸不平。也就是通过法线来计算光照,让它看起来像是凹凸不平的。另外一种和法线映射类似的技术叫做视差映射。
左侧为视差映射,右侧为法线映射。可以看到,从最终效果上来看,视差映射远比法线映射更加好,更加真实。但是它们实际上都是平的(噗嗤,偷笑~)。ps:视差映射会在Unity入门精要笔记系列全部转移完成后再出博客,想要了解的读者不妨点一手关注。
不过不管是实现法线映射还是视差映射,都需要了解其中的原理,对于法线映射来说,我们首先要知道的就是法线中储存了什么?
说来也简单,过空间中一个点,垂直于某平面,做一条垂线,该垂线即为法线,由于垂线垂直于平面,因而垂线的方向可以代表平面面向的方向。但是在模型上,我们的单位并非一个平面,而是模型上的一个顶点。一个模型表面就会有很多的法线。大概就是下面这个感觉。
法线说白了就是顶点面朝的方向,既然是一个三维空间的方向,那么我们只要用一个三维向量来储存就好了,正好一张图片可以储存三个通道,不妨就把它储存到图片中吧。
不过存在一个小问题,仔细思考一下,一个模型的法线的范围应该是[-180°,180°]区间的。
为什么呢?有很多问题只要我们认真想一下,就可以了。当一条法线指向模型内部的时候,代表什么?这个就留给大家自己思考啦。当然我们要知道结论,也就是一个法线的分量范围是[-1,1],然而一个纹理只能储存[0,1]的值,所以我们需要做一个映射。关于该部分更加细节的内容,大家可以参看我的另外一篇文章。
王子饼干:游戏开发技术杂谈1:基础法线贴图生成算法22 赞同 · 6 评论文章
映射手段就是一个简单的函数,我们直接看一下原书中的讲解。
严格来说,在空间解析几何中,一个方向AB,应该是由空间中的一个点B的坐标减去空间中的点A得到的。然而大部分时候,我们直接使用一个点来作为一个方向,是由于我们默认为原点为出发点。这倒是没有什么问题,但是我们要理解原点是干嘛用的,原点是用于定义一个空间的中心点的,如果我们以模型的中心点,即模型空间为法线空间,会导致法线错位和偏移。法线其本质就应该是以模型的顶点为原点的。
切线是什么意思嘞,让我们回顾一下。
在平面几何当中,过一个曲线的表面,作一条直线,有且仅有一个点与曲线相交,该直线就可以成为曲线的切线。或者可以说该直线与曲线相切。
换句话说,一个圆可以有无数个切线,有无数个切空间。是的,确实就是这样。数学中是这样的,然而计算机中是做不到的,计算机只能做到尽量的让一个球体变得圆一点。计算机中的球体或者模型都是由三角面构成的。
说了这么一大堆,核心思想就是想说,一个模型有很多个切线空间,每个切线空间都是为了定义每个顶点的切线。
其实切线空间并不是重点,计算光照才是重点,我们要不把切线空间中的法线转换到世界空间来进行光照计算,要不把光照参数,纹理信息变换到切线空间在切线空间进行计算。所以会有两种不同的实现途径,那么在实践之前,我们还需要知道切线空间变换矩阵是怎么得到的
头疼的问题来了,既然每个顶点都有一个切线空间,那么它是怎么得到的呢?总不能一个个的储存到什么变量中去吧。
事实上呢,这个所谓的切线空间,是需要我们去计算出来的。这里就要回顾一下线性代数中的叉积概念了。
点积和叉积都是针对向量的,A向量和B向量的叉积结果是一个向量,该向量垂直于A和B构成的平面。
如果大家没有了解过叉积或者已经淡忘了,可以到这篇博客中补一下课。
点积和叉积www.cnblogs.com/YouXiangLiThon/p/7997796.html
我们按照切线(x轴),副切线(y轴),模型法线(z轴)的顺序排列,即可得到切线空间的变换矩阵,是不是非常魔法。(ps:其实我觉得概率论更加魔法~~)
那切线,副切线,模型法线又是什么呢?在原书中有一张图非常巧妙
z轴我们经常遇到,我们通过NORMAL语义获得的那个就是模型顶点的法线方向。至于x轴,我们可以通过TANGENT语义获得,然后副切线,我们可以通过法线和切线的叉积得到。这样三个向量齐活,我们只要把它们按照顺序塞进一个矩阵里就行啦。(实际的切线计算还是比较复杂的,因为NORMAL和TANGENT都是要自己手动获得的,但是这也是为什么大家都在学Unity3D或者其他主流引擎的理由)
我们既可以在切线空间下计算光照,也可以在世界空间下计算光照。两者的共同点是都需要得到切线空间变换矩阵,有了变换矩阵,就可以求得逆矩阵。所以我们两者都试一下。
Shader "UShaderMagicBook/BumpMapTangentSpace_diffuse"{
Properties{
_MainTex("Main Texture",2D) = "white"{}
_BaseColor("Base Color",Color) = (1.0,1.0,1.0,1.0)
//由于我们本次只计算漫反射,所以只需要_MainTex和_BaseColor
_BumpTex("Bump Texture",2D) = "bump"{}
_BumpScale("Bump Scale",Float) = 1.0
}
SubShader{
pass{
Tags{"LightMode"="ForwardBase"}
CGPROGRAM
#pragma vertex Vertex
#pragma fragment Pixel
#include "Lighting.cginc"
struct vertexInput{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 texcoord : TEXCOORD0;
float4 tangent : TANGENT;
//特别注意tangent虽然是法线,但是它是一个float4类型
//xyz表示切线方向,而第四个分量w表示副切线的方向。如果副切线方向不对,那么
//最终法线会反向
};
struct vertexOutput{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
//这里默认凹凸纹理和主纹理使用同一个uv坐标
//并且我们不对它进行任何的缩放和偏移
float3 lightDir : TEXCOORD1;
};
sampler2D _MainTex;
sampler2D _BumpTex;
fixed4 _BaseColor;
float _BumpScale;
vertexOutput Vertex(vertexInput v){
vertexOutput o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
float3 binormal = cross(normalize(v.normal),normalize(v.tangent.xyz)) * v.tangent.w;
//先计算副切线,就是把法线和切线进行叉积计算,然后乘以副切线的方向参数
float3x3 _2tangentSpace = float3x3(v.tangent.xyz,binormal,v.normal);
//按照切线,副切线,法线的顺序拼在一起就是切线空间的变换矩阵
o.lightDir = mul(_2tangentSpace,ObjSpaceLightDir(v.vertex).xyz);
//把光源方向转换到切线空间
return o;
}
fixed4 Pixel(vertexOutput i):SV_TARGET{
fixed3 tangentLightDir = normalize(i.lightDir);
fixed4 packedNormal = tex2D(_BumpTex,i.uv);
//先对法线纹理进行采样,注意这个采样值暂时还不能用,需要映射
fixed3 tangentNormal;
//使用法线纹理中的法线值来代替模型原来的法线参与光照计算
tangentNormal.xy = (packedNormal.xy * 2 - 1) * _BumpScale;
//作法线值的映射
tangentNormal.z = sqrt(1 - saturate(dot(tangentNormal.xy,tangentNormal.xy)));
//计算法线的z分量
fixed3 albedo = tex2D(_MainTex,i.uv).xyz * _BaseColor.xyz;
fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.xyz * albedo;
fixed3 diffuse = _LightColor0.xyz * albedo * saturate(dot(tangentLightDir,tangentNormal));
//计算漫反射
return fixed4(diffuse + ambient,1.0);
}
ENDCG
}
}
}
下面是最终得到的效果
当然,上面其实有一段代码我不太理解,也就是为什么法线的z计算要用这个公式计算出来
tangentNormal.z = sqrt(1 - satureate(dot(tangentNormal.xy,tangentNormal.xy)));
讲道理,法线纹理中的z是不用做任何映射的,因为它本来就是正的。为了尝试一下直接把packedNormal的z分量赋给tangentNormal和这样计算有什么不同。我用两种方式都试了一下。下面是对比图。
经过我细致的对比,没有看出太大的区别,原书也没有说为什么要这样计算。放一下原书的讲解。
我们在计算法线的时候,需要把范围在[0,1]之内的颜色通道映射回[-1,1]之间,之前是通过我们手动计算来实现的。如下图所示
其实,当我们的法线纹理被设置为 NormalMap时,我们可以不用手动映射,直接通过UnpackNormal来实现获取法线。
NormalMap可以在纹理属性的Texture Type一栏调整
当我们的法线纹理被设置为NormalMap类型的时候,正如书上所说,Unity3D会根据平台把纹理转换为DXT5nm或者BC5格式(这两种格式都是对法线纹理的有损压缩),然而由于设置了DXT5nm,法线纹理的z信息会被直接丢弃,而x信息被储存到A通道,而y信息被储存到G通道,这样是为了更好的压缩法线纹理,至于为什么x信息储存到了第四个通道,而y信息被储存到第二个通道,乍看有点不可理解,但是其实这是由于压缩算法的特性导致的,这里就不涉及了,感兴趣的读者可以百度一下DXT5nm压缩格式。
正是由于z分量被舍弃了,所以z只能通过x和y计算出来,而不是直接从法线纹理中读取。
具体的解释全部写在上面截图了,不过这里再复述一遍。
首先我们可以在UnityCG.cginc中找到这段代码,它是内置的函数之一,先看UnpackNormal的策略,它会检查法线纹理是否为DXT5nm格式,如果不是,我们直接手动计算,也就是三个分量全部乘2再减1。否则,我们调用上面的函数,UnpackNormalmapRGorAG来计算。
由于DXT5nm丢弃了原法线纹理的z分量,并使用G通道储存y信息,A通道储存x信息,因而它的格式是(1,y,1,x),正如代码上的注释所写。这里由于法线的x和向量x冲突,于是我们用m和n来代替x和y,(1,n,1,m)
于是我们需要先将它的m转换到x的位置,我们通过下面这行代码实现
packedNormal.x *= packedNormal.w;
乘完之后,原格式变成了,(m,n,1,m),这样,我们就可以像处理普通纹理一样处理它了,但是由于DXT5nm丢弃了z信息,因而z信息必须手动计算一次。(这样其实会增大运算压力),至于BC5格式,由于BC5格式虽然也压缩了,也丢弃了z信息,但是它并没有变换通道,它的格式仍然是(x,y,0,1),所以可以当成普通纹理一起计算。
如果你没有耐心读完上的内容,这里有一个捷径,不论你的法线是原始纹理还是被标记为NormalMap,你只需要用UnpackNormal函数就可以了。
完事大吉~
左侧为手动计算,右侧为UnpackNormal,TextureType是Normalmap,可以看见左边的手动计算的法线方向已经错位了。而右侧没有任何问题,(在TextureType为default的时候也没有问题)
有点糟糕,本来想在笔记8当中解决掉第7章,篇幅已经超过了,我尽量保持一篇笔记的篇幅大小,内容不会过多,不过还是会保留自己的研究的部分。本节的代码也只有一个Shader,大家可以在我的Github找到。