最近现实中事情太多了,都隔了好长时间没更新博客了,不管怎么样,一个人一生真正的爱好不多,既然发现一个自己热爱的东西,那么还是要坚持下去的。
继续回归CG着色知识学习。
之前我们了解了线性代数中转置矩阵和逆矩阵的定义以及切线空间和法线变换,知道了法线变换的一个特点,那就是需要使用顶点变换矩阵的逆转置矩阵同步变换,才能得到正确的世界空间的法向量,这里又扩展出一个叫做法线映射的技术。
首先我们从美术人员的角度来讲法线映射这个问题,美术人员们在3dmax中创建一个模型(可能附带动画,暂时不讨论动画),为了尽可能的表现出模型精细的细节,需要构建大量顶点三角面,从而构建复杂的网格几何细节,才能让自己创建的模型具有精细的表现力。但是这有个问题,假如美术人员做这个模型是为了图片视频渲染,那无可厚非,必须制作尽可能最精细的细节,然后丢给VRay等渲染器跑就行了。但是假设用来做游戏开发,那么除非每个玩家都是i9k + rtx2080ti,不然还真有可能跑不动这些制作出来的大量高精模型,所以啊,美术人员必须在有限的顶点三角面限制条件下尽可能表现出模型的细节。
然后这种需求又引出了一个半物理光照模型原理,前面我们聊过光照模型对物体产生的影响,同时强调了,非光线追踪下的光照渲染属于一种经验总结的结果,并不是纯粹的真实物理原理,结果就是(逐片段/逐顶点)光照surface颜色 = E(emissive)+ A(ambient)+ D(diffuse) + S(specular)共同叠加的结果,同时Diffuse颜色值关键计算参数之一是顶点P法向量与P到光源向量L的点积,意思就是说光源越是“正对着”顶点,那么光强越大,反之就光强越小,而且Specular颜色值计算参数之一是顶点P到眼睛向量V和到光源向量L的向量和H与法向量的点积,那么我们能总结出什么东西呢?
这就意味着一个surface表面颜色明暗程度与顶点法向量(以及眼睛坐标)息息相关,所以假设我们的美术同事,在精简了模型网格三角面的情况下,依旧保证了大量法向量信息的储存,那我开发光照着色的时候就可以很好的保证高运行效率的同时,达到最好的精细表现效果。
so,问题来了,怎么才能保证大量法向量信息的储存,我们知道法向量和顶点是一对一存在的,美术人员精简了模型顶点的情况下,除非使用额外的向量储存手段,不然还真没法搞定,这个时候就来了一个所谓的法向量纹理了,我们知道一般一个模型含有一张uv纹理,用于表现模型表面贴图,那么美术人员可不可以再加一张纹理用来储存模型的法向量呢?当然是可以的,假设模型存在10个顶点,但是法向量纹理使用RGB储存了100个法向量信息(一个像素RGB刚好和三维向量数据分量相同,但是像素分量范围和向量分量范围不同,后面聊),依次根据uv映射绑定在模型上,使模型具有100个“虚拟”的法向量信息,那么我们开发的时候根据这100个法向量信息进行光照surface颜色计算,就可以得到100个“精度细节”的光照效果,同时模型顶点只存在10个顶点,减小了DC提交开销,提高了运行效率同时表现效果还不打折。
既然我们已经理解其原理了,接下来就来程序实际操作验证,这里开发群技美(技术美术)一歌大佬给了我一些素材(感谢一歌大佬),就是模型网格顶点及表面uv贴图以及normal法线贴图,来实现我们的法线映射技术,首先来详细了解一下这个normal法线贴图,如下图:
假如我们具有一丢丢的美术天赋(图形学是程序和美术的结合,咱们不能只当一个码农,还要具有一点美术直觉),就会发现,这张贴图的总体颜色值偏蓝,平均颜色值约等于(0.5,0.5,1),这里又引出了上面红字标明的问题,也就是数值偏移映射,我们知道color分量取值范围在[0,1],但是单位向量分量取值范围在[-1,1]之间,那么我们将单位向量分量偏移映射到color分量如下:
Color(rgb) = (Normal(xyz) + 1) / 2;
那么反过来,color分量偏移映射到单位向量分量,如下:
Normal(xyz) = Color(rgb) * 2 - 1;
顺便说一下,这个计算方法,CG函数库有PackNormal和UnpackNormal这两个函数直接处理。
好,继续思考,假设我们的美术同学在建模空间构建网格顶点,同时在建模空间构建法线贴图,那就和上面那张法线贴图完全不同啦!因为建模空间中网格顶点的法向量是指向“四面八方”,那么每个法向量转换成颜色值就是“五颜六色”的!那么最终生成的法线贴图一般就如下(我百度找的模型空间法向量贴图):
然而建模空间法线贴图实际使用中会有如下问题需要考虑:
①.光照计算的眼睛坐标(朝向)和光源坐标(朝向)是在世界空间的。
②.美术在建模空间制作的模型,在3D引擎中使用避免不了各种矩阵变换(TRS矩阵变换等)。
③.如果模型为骨骼动画模型,那么每次运动面临的更复杂的矩阵变换。
当然我们码农可以用数学硬杠,也就是根据这几种情况,将建模空间法向量,经过各种复杂的矩阵变换得到正确的世界空间法向量,当然我觉得这是实在没办法的情况下才做的事情。
那么如果美术直接给我们世界空间中法线贴图如何呢?意思就是美术们根据3D引擎实际使用情况,将模型在世界空间中的法线贴图制作出来,这种方法也不是不行,无非就是只能用于静态物体,物体只要产生运动,就又要一番复杂的矩阵变换处理才能保证正确性。
这个时候就要回顾上一篇的切线空间和法线变换了,上一篇提出了一个切线空间的概念,但是没有具体深究,因为必须结合实际CG着色器开发才能准确体会其作用,切线空间我们可以理解为一个独立于MVPS空间的“附着于”模型顶点“表面”的空间,那么每个顶点都有一个自己的切线空间,这个切线空间坐标系的原点为该顶点,同时z轴为该顶点法线方向,x轴为该顶点的切线方向,那么y轴就是重合于法线方向和切线方向的叉积向量方向,这里数学上称为bitangent(又称副切线),如下图:
当然X(tangent)和Y(bitangent)可以互换的。
既然我们构建出了切线空间,那么以切线空间为参考系的Z(normal)轴也就是单位法向量为N(0,0,1),经过转换为Color颜色值为:
C(rgb) = (N(xyz) + 1) / 2 = (0.5,0.5,1); //也就是偏蓝色
这样构建的切线空间以及相应的切线空间法线贴图有如下好处:
①.切线空间法向量是相对于顶点的,那么一张切线空间法线贴图应用在不同模型网格上,也可能得到合适的效果。
②.切线空间法线贴图复用性增大,因为是相对于顶点,所以假设同种模型,比如墙壁,那么完全可以所有同类型墙壁使用同一张切线空间法线贴图。
好,既然完整了解了切线空间及其作用,那么我们接下来就来利用切线空间,上面也说了,法向量就是为了光照的D(diffuse)和S(Specular),同时光照计算的关键参数和过程都是在世界空间的,那么我们将每个法向量信息从切线空间转换到世界空间就OK了?感觉这样消耗挺大的,虽然GPU设计初衷之一就是为了大量的矩阵计算,但我们开发可以优化一下计算过程,将视线方向和光源方向转换到切线空间去计算,这样更加高效率一些。
写了这么多讲解,下面直接来实际Shader代码开发,如下:
Shader "Unlit/TangentNormalShader"
{
Properties
{
_MainTex ("Texture", 2D) = "white" {}
_NormalTex("Normal",2D) = "white" {}
_BumpScale("Bump",Range(-1,1)) = 1 /*这个float参数我用来缩放法向量XY值*/
_AmbientFactor("Ambient",Color) = (0,0,0,1) /*全局光控制系数*/
_DiffuseFactor("Diffuse",Color) = (0,0,0,1) /*漫反射控制系数*/
_SpecularFactor("Specular",Color) = (1,1,1,1) /*镜面反射控制系数*/
_SpecularShininess("Shininess",Range(10,100)) = 20 /*镜面高光强度系数*/
}
SubShader
{
//LightMode光照模式选择ForwardBase前向渲染,告诉引擎底层我们要计算光照了
Tags { "RenderType"="Opaque" "LightMode" = "ForwardBase" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
struct a2v
{
float4 vertex : POSITION; /*建模空间顶点源坐标*/
float2 uv : TEXCOORD0; /*建模时的顶点uv*/
float3 normal : NORMAL; /*建模时的顶点法向量,用来构建切线空间*/
float4 tangent : TANGENT; /*建模时的顶点切向量,用来构建切线空间,注意分量w决定副切线的方向*/
};
struct v2f
{
float2 mainuv : TEXCOORD0; /*定义好用来储存主纹理uv*/
float2 normaluv : TEXCOORD1; /*定义好用来储存法线uv*/
float4 vertex : SV_POSITION; /*用来储存MVP变换后的顶点坐标*/
float3 tangentLightDir : TEXCOORD2; /*储存切线空间光源到顶点朝向向量*/
float3 tangentViewDir : TEXCOORD3; /*储存切线空间视口到顶点的朝向向量*/
};
sampler2D _MainTex;
float4 _MainTex_ST;
sampler2D _NormalTex;
float4 _NormalTex_ST;
float _BumpScale;
fixed4 _AmbientFactor;
fixed4 _DiffuseFactor;
fixed4 _SpecularFactor;
float _SpecularShininess;
v2f vert (a2v v)
{
v2f o;
//处理MVP变换,这个时vert函数最基本常规操作
o.vertex = UnityObjectToClipPos(v.vertex);
//对两套纹理进行转换储存
o.mainuv = TRANSFORM_TEX(v.uv, _MainTex);
o.normaluv = TRANSFORM_TEX(v.uv, _NormalTex);
//这里要注意了,我们构建建模空间到切线空间的变换矩阵,用来处理切线空间中光源朝向和视口朝向
//1.构建建模空间到切线空间的旋转矩阵可以使用unityCG自带的TANGENT_SPACE_ROTATION得到一个rotation矩阵
//2.可以根据tangent和bitangent和normal三个两两垂直的单位向量构建,实际上两种是一样的
//TANGENT_SPACE_ROTATION;
float3 bitangent = cross(v.normal, v.tangent.xyz) * v.tangent.w;
float3x3 model2tangent = float3x3(v.tangent.xyz, bitangent, v.normal);
//构建完model2tangent矩阵后,接着处理建模空间的光源朝向到切线空间
//同时处理建模空间的视口朝向到切线空间
//这里注意,可以使用unityCG自带方法ObjSpaceLightDir和ObjSpaceViewDir
//或者使用世界空间转建模空间再处理光源坐标和顶点坐标的朝向关系
//同时处理建模空间视口坐标和顶点坐标的朝向关系
o.tangentLightDir = normalize(mul(model2tangent, ObjSpaceLightDir(v.vertex)).xyz);
//o.tangentLightDir = normalize(mul(model2tangent, mul(unity_WorldToObject, _WorldSpaceLightPos0).xyz - v.vertex.xyz).xyz);
o.tangentViewDir = normalize(mul(model2tangent, ObjSpaceViewDir(v.vertex)).xyz);
//o.tangentViewDir = normalize(mul(model2tangent, mul(unity_WorldToObject, float4(_WorldSpaceCameraPos.xyz, 1)).xyz - v.vertex.xyz).xyz);
return o;
}
fixed4 frag (v2f i) : SV_Target
{
//纹理主颜色
fixed4 mainCol = tex2D(_MainTex, i.mainuv);
//采样法线颜色和转换法向量
fixed4 normalCol = tex2D(_NormalTex, i.normaluv);
fixed3 normalVector = normalize(UnpackNormal(normalCol));
//给法向量进行一点点操作,这里缩放xy分量
//因为切空间法向量近似于(0,0,1),操作xy分量可以达到修改法线凹凸表现效果
normalVector.xy *= _BumpScale;
//全局光颜色
fixed3 ambient = _AmbientFactor.rgb * UNITY_LIGHTMODEL_AMBIENT.xyz;
//diffuse颜色计算,根据光照模型基本公式来计算
//dot点积就是用来计算光照经验模型的主要参数
fixed3 diffuse = _DiffuseFactor.rgb * _LightColor0.rgb * max(dot(normalVector, i.tangentLightDir), 0);
//specular颜色计算,根据光照模型基本公式来计算
fixed3 hDir = normalize(i.tangentLightDir + i.tangentViewDir);
fixed3 specular = _SpecularFactor.rgb * _LightColor0.rgb * pow(max(dot(normalVector, hDir), 0), _SpecularShininess);
//同时处理主颜色在光照下的明暗程度
//dot点积计算,假如光源与法向量越垂直,则说明越靠近边缘,则颜色越暗
mainCol *= saturate(max(dot(normalVector, i.tangentLightDir), 0));
//合并全部颜色并渲染
return fixed4(mainCol + ambient + specular + diffuse, 1.0);
}
ENDCG
}
}
}
虽然注释了很多,但是请小伙伴打开UnityCG.cginc,主要说明如下:
①.TANGENT_SPACE_ROTATION
构建建模空间到切线空间的变换矩阵,由tangent,bitangent,normal三个两两垂直的坐标系单位向量构成。
②.ObjSpaceLightDir
计算建模空间光源朝向,计算比较简单。
③.ObjSpaceViewDir
计算建模空间视口朝向,和上面一样,都是将世界空间的坐标转换到建模空间,再使用向量朝向计算即可。
④.fixed4(mainCol + ambient + specular + diffuse, 1.0)
颜色合并渲染,想要获得真实感渲染,则需要E(emissive) A(ambient) D(diffuse) S(specular),但是mainCol我们需要计算切空间法向量和切空间光源朝向的dot点积,确定当光源朝向和法向量的夹角垂直或者钝角时,则不渲染主颜色,因为那样意味着“太阳下山了”,所以需要如下一步计算:
mainCol *= saturate(max(dot(normalVector, i.tangentLightDir), 0));
以上就是需要特意提醒的讲解点,接下来让我们看下效果,如图:
是不是挺真实的!这里还需要说一下, 想获得更加真实的效果,则需要更加复杂的纹理叠加和材质及光照参数计算,这些计算我们后面再聊,因为这些都是前人总结的结果,我们可以通过看书和看博客了解,然后应用到我们的shader表现中。
so,接下来继续。