Unity 着色器训练营(2) - MVP转换和法线贴图
https://mp.weixin.qq.com/s/Qf4qT15s9bWjbVGh7H32lw
我们刚刚公布了Unity 2018.1中,Unity将会内置可视化编程工具Shader Graph,很多开发者留言给小编是否以后构建着色器编码就可以下岗了。实际上Unity希望开发能够越来越大众化,减轻技术上的壁垒,Shader Graph虽然能够更加轻松的构建着色器,但实际上开发者都应该掌握着色器的开发。
广受开发者欢迎的Unity着色器训练营已经开展三期了。本篇文章将由Unity技术经理鲍健运帮助大家温习第二期训练营的内容:MVP转换和法线贴图。
MVP转换
MVP转换全称:Model * View * Projection Matrix 模型视图投影矩阵转换。在图形学领域,这个是非常重要的内容。利用模型、观察和投影矩阵,可以将变换过程清晰地分解为三个阶段。虽然此法并非必需,但采用此法较为稳妥。我们将看到这种公认的方法对变换流程作了清晰的划分。
首先,在三维空间中,我们看到一个简单对象。这个对象假设为一个立方体,而通常的三维模型也是由一组顶点定义。就像下图中空间顶点的一些坐标。
顶点的XYZ坐标是相对于物体中心定义的。换而言之,若某顶点位于(0,0,0),则其位于物体的中心。而现在的坐标系,其实主要就是模型空间(Model Space)。
我们希望能够移动它,玩家有时也需要通过键盘鼠标控制这个模型。而这些操作无外乎,缩放旋转平移。在每一帧中,用算出的这个矩阵去乘(所有的顶点,物体就会移动。唯一不动的是世界空间(World Space)的中心。物体所有顶点都位于世界空间。图中黄色箭头的意思是:从模型空间(Model Space)(顶点都相对于模型的中心定义)变换到世界空间(顶点都相对于世界空间中心定义)。
仔细想想,摄像机的原理也是相通的。如果想换个角度观察一座山,你可以移动摄像机,当然也可以移动山……但是,后者在实际中不可行,在计算机图形学中却十分方便。起初摄像机位于世界坐标系的原点。移动世界只需乘一个矩阵。假如你想把摄像机向右(X轴正方向)移动3个单位,这和把整个世界(包括网格)向左(X轴负方向)移3个单位是等效的!
下图便展示了从世界空间(顶点都相对于世界空间中心定义)到摄像机空间(Camera Space,顶点都相对于摄像机定义)的变换。
现在,我们处于摄像机空间中。从下图中可以发现,摄像机所观察到的锥形空间(上图中以近似圆锥体的方式呈现),通过诸如近剪裁、远剪裁、视野这些重要的参数,剪裁平面是摄像机空间中的XY坐标系,摄像机空间中的“远”与“近”,即反映了Z的取值。
从摄像机空间(顶点都相对于摄像机定义)到齐次坐空间(Homogeneous Space)(顶点都在一个小立方体中定义。立方体内的物体都会在屏幕上显示)的变换。摄像机空间给定的两个端点(1,1),(-1,-1)就是屏幕投影空间的两端坐标。
再看下面的图,以便大家更好地理解投影变换。投影前,蓝色物体都位于摄像机空间中,红色的东西是摄像机的平截头体(frustum):这是摄像机实际能看见的区域。
用投影矩阵去乘前面的结果,得到如下效果:下图中平截头体变成了一个正方体(每条棱的范围都是-1到1),所有的蓝色物体都经过了相同的变形。因此离摄像机近的物体就显得大一些,远的显得小一些。这和现实生活一样!这样就完成了MVP转换。
法线贴图
还记得下图中的场景吗?第一期的训练营我们有具体的展示。第一个机器蜘蛛是使用顶点/片元着色器,仅以简单的贴图做显示,所以感觉很平面。第二个机器蜘蛛加入了光照通道来辅助运算,所以有光照影响的效果。前两个材质没有法线贴图,最后个standard有法线贴图,可以看到很多的细节,明显的凹凸感。这些凹凸感就是通过法线贴图(Normal Mapping)来实现的。
下图呈现了两张图片,左边的就是常规的贴图,展现了机器蜘蛛的纹理,而右边的图片颜色奇怪的图片就是法线贴图,实际上大家可以发现,它与左边的问题其实是吻合的,并且有明显的凹凸质感,在实际附着在材质上显示时,物体的表面也会由观察的角度和光照的关系产生凹凸的感觉。
那么到底什么是法线呢?
下图大家可能会想起一些学校里学习的数学概念。图中绿色的线垂直于AB两点的连线,而这条绿色的线就是法线。
但是在现实空间中,法线是有长有短的。为了便于之后对于颜色值的处理(值域0~1),需要对其进行归一化(Normalize)的处理(值域-1~1),将数值从绝对的量变为相对的量。这些具体的实现会在下文中代码部分解释。
法线的应用在现实世界中的表现有很多,就以下图二个图对比为例,法线就影响了AB线上的弹性值,给小球不同的反弹效果。但是,更为广泛的应用场合是在光照相关的场景。
如何计算法线呢?在欧几里得空间中,三点可以确定一个平面。我们就试着计算A、B、C这三个点所在平面的法线。连接AB点与AC点,构成二条线段。
在向量计算中,一般使用叉乘的方式来获得与两个向量都垂直的向量,在这里就是获得法线向量。在Unity中我们所遵循的是“左手定则”,正如所展示的,通过AB × AC可以得到蓝色的法线向量;通过AC × AB可以得到红色的法线向量。从归一化到计算方式,现在基本梳理了法线的原理。
一般外部导入的模型,本身就包含了法线与切线的一些相关设置。下图红框所确定的区域就是对应的设置选项。
这里的设置主要是用于定义是否以及如何计算法线,这个选项对优化游戏大小很有用。
-
Import(导入):这项是默认选项,就是从原文件中导出法线值。
-
Calculate(计算):基于Smoothing angle(平滑角度)计算法线值。
-
None(无):禁用法线。
-
Normals Mode(法线模式),即Unity如何计算法线,当Normal设置为Calculate才有效。
-
Unweighted Legacy(传统未加权):主要是针对从2017.1版本之前的版本迁移过来的项目,迁移过来之后这个就是默认选项。计算的结果与现有加权方式略有不同。
-
Unweighted(未加权):Unity 2017.1及以上版本的未加权方式计算。
-
Area Weighted(区域加权):根据表面的区域加权计算。
-
Angle Weighted(顶点角加权):根据每个表面上顶点的角度加权计算。
-
Area and Angle Weighted(区域与顶点角加权):综合区域与顶点角的加权,这个是新项目的默认选项。
那么为什么法线贴图能呈现出各种凹凸效果呢?现在我们先从下图网格与法线的关系展开。首先这是常规的网格,每个面上的法线值是一样的,因此在光照下这网格上所呈现的凹凸感与实际面的形状是一致的。
但是如果面上的各个法线呈圆周相关的值变化,这样在光照下就能呈现出平滑弧面的质感。
推而广之,这里还能使用更为丰富的法线值来表现凹凸感十足的效果,而这些在网格上的法线数值,平铺到法线贴图上就会表现为不同的颜色效果。
这里我先以最简单的法线相关的Shader来呈现效果:
Shader "Custom/SimpleNormals"
{
SubShader
{
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
float3 normal : NORMAL;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.normal = v.normal;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
float3 normal = normalize(i.normal);
float3 color = (normal + 1) * 0.5;
return fixed4(color.rgb, 0);
}
ENDCG
}
}
}
这里值得注意的是,v2f在顶点函数部分获取了材质的法线值,对于这个法线值在片元函数中先进行了归一化处理,使其值域在-1到1之间。但是该处理后的数值还不便于进行直观的表现,因此通过加1在乘0.5的方式将值域变换到0到1之间,最后返回fixed4(color.rgb,0),这样法线值就能以颜色值rgb的方式呈现出来。
正如上图所呈现法线颜色均匀表现。二个不同多边形的球体,但是着色器是一样的。
除了叉乘之外,另外一个重要概念就是点乘,它的结果直接反应了两个向量直接的关系。常见的有三种,二向量同向,点乘值为1;二向量相垂直,点乘值为0;二向量互为反向,点乘值为-1。光对于物体照射后的效果,如何做出正确的呈现就必须用到点乘的方法。
举个更为直观的例子。在宇宙空间中,太阳发射出阳光,照射到地球。
但是观看者实际感受光的效果,是由“地球”给到我们的反射光所决定的。通过反射光与物体表面的法线进行一定的处理(主要是点乘),来获得凹凸感的效果。
比方说这个反射光与法线同向时,即在物体表面最为高亮的角度。法线与光照的点乘值就为1。
当这两者是相互垂直的时候,它们的点乘就为0,你可以认为该点就是暗的,因为没有有效的光照。
到了最背光面法线与反射光的点乘,结果就是为-1,但是-1这个数值在用于颜色计算没有意义。这时候就需要使用saturate(饱和)方法。saturate是CG语言的函数,功能是返回不小于标量或每个向量分量的最小整数。其参考实现如下:
float saturate(float x)
{
return max(0, min(1, x));
}
一旦,数值小于0之后,它将返回0。
既然有了这些,我们可以写个将光照与法线进行简单运算的着色器:
Shader "Custom/SimpleLightingObjectSpace"
{
SubShader
{
Pass
{
Tags{ "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
};
struct v2f
{
float4 vertex : SV_POSITION;
float3 normal : NORMAL;
};
sampler2D _MainTex;
float4 _MainTex_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.normal = v.normal;
return o;
}
fixed4 frag (v2f i) : SV_Target
{
return saturate(dot(i.normal, _WorldSpaceLightPos0));
}
ENDCG
}
}
}
接着查看其运行效果。
但是我们应该可以从旋转的圆球上发现,光影的显示有一定的问题。一开始光影还是正确的,但是光影在旋转过程中,好像只是附着在圆球上,不受环境光照的影响,这是为什么呢?因为在vert顶点函数中,“o.normal = v.normal;”这个语句只是赋予了对象本身的法线值,仅仅是模型空间自己的,而且只是第一次获取到光照时的法线值。静态物体没什么问题,但是如果动态的时候,就需要获取其在世界空间中对应法线的数值来进行运算了。世界空间中的法线值可以通过UnityCG.cginc的UnityObjectToWorldNormal方法来处理。
现在只需改写一句便可:
o.normal = UnityObjectToWorldNormal(v.normal);
现在我们再观察使用修正后着色器SimpleLightingObjectSpaceCorrect.shader,显示的圆球就有正常显示效果了。
上图中左边的是通过法线贴图来辅助显示光照效果的,右边的完全是通过圆球和面片的结合来显示光照效果,而这些对象就直接使用SimpleLightingObjectSpaceCorrect.shader。我们调整场景中光照的角度,对象上的阴影也会随之变化。但是两边阴影是对称的,而不是一致的,这肯定有问题了。因为法线是正确的,那么是哪里不对呢?实际上问题出在另外一个方面:切线。
这里一个球体作为参照,蓝色的就是某个点的法线。切线在哪里呢?因为在二维空间中,某点的切线一般就1条。但是在三维空间中就不一样了。
因为在三维空间中,法线相关的是一个切平面,这就比较难去选取所需的切线了。但是我们也有约定俗成的方式去选取所需的切线。
这边使用一张有数字和规整的区域作为参照,我们通常以纹理的方向找出一条切线(Tangent),而与之相垂直的(基于左手定则)选取另一条副切线,通过这个基准我们就可以得到世界法线值(WorldNormal)。引入副切线主要是便于进行转换的运算。
理解了相关世界法线的计算之后,现在我们可以来看看法线贴图的应用了。在上图中哪个是正确的显示,哪个是错误的显示呢?实际上左边是错误的,而右边的是正确的。
为了便于理解我们制作了这个场景,原理上而言还是需要遵守左手定则,原来的UV就对应反了。法线一般用蓝色的表示(RGB中的Blue),这个是朝向屏幕的,因此这里看不到。上边的就是对应绿色的副切线(RGB中的G),右边的就是对应红色的切线(RGB中的R)。
这样我们就可以得到正确的代码实现。
SimpleNormalMappedLighting.shader:
Shader "Custom/SimpleNormalMappedLighting"
{
Properties
{
_NormalTex("Normal Map", 2D) = "white"
}
SubShader
{
Pass
{
Tags{ "LightMode" = "ForwardBase" }
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float3 normal : NORMAL;
float4 tangent : TANGENT;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float3 tbn[3] : TEXCOORD1; // TEXCOORD2; TEXCOORD3;
};
sampler2D _NormalTex;
float4 _NormalTex_ST;
v2f vert(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _NormalTex);
float3 normal = UnityObjectToWorldNormal(v.normal);
float3 tangent = UnityObjectToWorldNormal(v.tangent);
float3 bitangent = cross(tangent, normal);
o.tbn[0] = tangent;
o.tbn[1] = bitangent;
o.tbn[2] = normal;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
float3 tangentNormal = tex2D(_NormalTex, i.uv) * 2 - 1;
float3 surfaceNormal = i.tbn[2];
float3 worldNormal = float3(i.tbn[0] * tangentNormal.r + i.tbn[1] * tangentNormal.g + i.tbn[2] * tangentNormal.b);
return dot(worldNormal, _WorldSpaceLightPos0);
}
ENDCG
}
}
}
这些做完之后,我们就能看到最终的显示效果。法线贴图的相关知识点也就梳理至此了。