Unity Shader 入门到改行5——法线贴图

the best of blur

0.本文示例代码地址

GitHub

1. 法线贴图理论

1.1 什么是法线贴图

一般的贴图中存储的是表面颜色值(RGBA),而法线贴图存放的则是法线信息(xyzw),假设某顶点处的 uv 坐标为 (u,v), 那么在法线贴图 (u,v)处纹素的值表示该顶点的“法线”方向。通常法线贴图中存储的并不是这个顶点的真实法线信息。

1.2 法线贴图的作用

想象一下,如果我们想要表现一个凹凸不平的模型表面(想象一个橙子的表面),有哪些办法呢?

  • 直接把模型做成凹凸不平。这种方法最理想,效果也最好。但是模型需要太多顶点了,例如橙子表面的一个“坑”,需要增加额外的若干个顶点。

  • 做一个一定精度的平滑模型(例如把橙子做成一个球体模型),把表面的”坑“或”凸点“信息,也就是某一点的”海拔“记录下来,渲染的时候根据这些信息动态生成顶点信息,得到凹凸不平的模型。不用说,这种方法需要单独的存储空间来记录凹凸信息,而且顶点动态生成将会非常消耗。

  • 和第二种方法一样,做一个平滑模型,同样记录表面的“海拔”,渲染时不是动态生成顶点,而是根据“海拔”信息反推顶点的法线信息,通过光照效果来表现表面的”凹凸“。这种方法在计算光照时需要先进行表面法线的计算,比较消耗。

  • 同样做一个光滑模型,不是记录表面的凹凸信息本身,而是记录”假定的凹凸情形下的法线信息“,渲染时根据“有偏差”的法线信息来进行光照计算,使得渲染出来的画面看起来凹凸不平。

上面第三种方法称为基于“高度纹理”的凹凸表现。而第四种方法就是基于“法线纹理”的凹凸表现。

注意:高度贴图和法线贴图用来表现“凹凸”,在模型轮廓的边缘会穿帮。比如你可以用这两种方法使一个平滑的橙子模型表面看起来凹凸不平,但是在橙子的边缘总是平滑的。

1.3 法线贴图纹素取值范围

通常贴图纹素用来表示 RGBA,那么每个分量的取值范围是[0,1],而法线的每个分量取值范围为[-1,1],所以用贴图纹素表示一个法线时,需要针对每一个分量做映射

pixel = (normal + 1) / 2;

在针对法线贴图采样后,进行逆运算

normal = 2 * pixel - 1;

得到实际的法线分量值。

1.4 法线贴图基于什么坐标系

法线贴图储存了表面法线,而法线是一个方向,那么这个方向是基于什么坐标系?通常跟随顶点数据一起传输到 顶点着色器中的法线,由 NORMAL 语义指定,是基于模型坐标系的。所以我们可以将法线在模型坐标中的值存储到法线贴图中,得到模型空间的法线贴图,而在实际制作中,应用更多的是顶点切线空间的法线贴图
对于每个顶点,以顶点自身作为原点,顶点切线方向为x轴,法线方向为z轴,切线和法线方向叉乘得到 y 轴(副法线方向),得到这个顶点的 切线坐标空间,基于这个空间的法线记录下来得到 顶点切线空间的法线贴图

左:模型空间的法线贴图 右:切线空间的法线贴图

  • 模型空间法线贴图的优点
    (1)实现简单,直观
    (2)更平滑的缝合和边界处的表现。

  • 切线空间法线贴图的优点
    (1)可重用,记录的是“相对法线信息”,而模型空间的法线贴图记录的是“绝对法线信息”。
    (2)可以做 UV 动画来实现凹凸移动效果。
    (3)可压缩。z分量永远是正方向,可以只存储xy分量。

1.5 为什么切线空间的法线贴图看起来都是偏蓝色的?

切线空间的法线贴图保存的是基于顶点的切线空间中的法线数值,而在顶点的切线空间中,真实法线的反向永远是(0,0,1),经过上述的计算公式得到法线贴图中存储的值为 (0.5,0.5, 1),偏蓝色。而修改后的法线通常也是 z 值最大,因为你不太可能有90度以上的法线修改,整体还是偏蓝。

通常使用顶点切线空间的法线贴图,而顶点空间中的修改后的法线值,z分量最大,换算成颜色就是 b 分量最大,所以法线贴图通常看起来偏蓝色。

2. 如何在 Shader 中应用法线贴图

我们使用在切线空间下的法线贴图,先上完整 shader 代码,然后逐步分析,代码如下:

Shader "Shader_Examples/04_NormalTexture_TangentSpace"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _SpecularColor ("SpecularColor", Color) = (1,1,1,1)
        _Gloss ("Gloss", Range(8, 256)) = 20
        _BumpTex ("BumpTex", 2D) = "bump" {}
        _BumpScale ("BumpScale", Float) = 1.0
    }

    SubShader
    {
        Tags { "RenderType"="Opaque" "LightMode" = "ForwardBase" }      

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag                       
            
            #include "UnityCG.cginc"
            #include "Lighting.cginc"

            sampler2D _MainTex;
            float4 _MainTex_ST;
            fixed4 _SpecularColor;
            float _BumpScale;
            sampler2D _BumpTex;
            float4 _BumpTex_ST;
            float _Gloss;

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
                float4 tangent : TANGENT;
                float3 normal : NORMAL;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;          
                float4 vertex : SV_POSITION;
                float3 lightDir : TEXCOORD1;
                float3 viewDir : TEXCOORD2; 
            };          
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                // 模型空间副法线
                fixed3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;

                float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);

                float3 lightDir = ObjSpaceLightDir(v.vertex);
                float3 viewDir = ObjSpaceViewDir(v.vertex);

                o.lightDir = mul(rotation, lightDir);
                o.viewDir = mul(rotation, viewDir);
                o.uv = v.uv;                                
                return o;
            }
            
            fixed4 frag (v2f i) : SV_Target
            {               
                float3 lightDir = normalize(i.lightDir);
                float3 viewDir = normalize(i.viewDir);
                float3 halfDir = normalize(lightDir + viewDir);             

                float4 packedNormal = tex2D(_BumpTex, i.uv);

                float3 tangentNormal = UnpackNormal(packedNormal);
                tangentNormal.xy *= _BumpScale;
                tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

                fixed3 albedo = tex2D(_MainTex, i.uv).rgb;
                fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(tangentNormal, lightDir));

                fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo;

                fixed3 specular = _SpecularColor * _LightColor0 * pow(saturate(dot(halfDir, tangentNormal)), _Gloss);
                return fixed4(diffuse + ambient + specular, 1.0);
            }
            ENDCG
        }
    }
}

渲染效果如图:


法线贴图效果

2.1 shader 属性与对应的变量

    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
        _SpecularColor ("SpecularColor", Color) = (1,1,1,1)
        _Gloss ("Gloss", Range(8, 256)) = 20
        _BumpTex ("BumpTex", 2D) = "bump" {}
        _BumpScale ("BumpScale", Float) = 1.0
    }

漫反射纹理 _MainTex, 高光颜色 _SpecularColor 和高光系数 _Gloss 没什么好说的,新增的纹理 _BumpTex 为法线贴图,默认值为 unity 内置法线贴图 "bump",_BumpScale 用来控制表面的“凹凸”程度,后面会分析它是怎么起作用的。对应的变量声明:

sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _SpecularColor;
float _BumpScale;
sampler2D _BumpTex;
float4 _BumpTex_ST;
float _Gloss;

2.2 着色器输入结构

struct appdata
{
    float4 vertex : POSITION;
    float2 uv : TEXCOORD0;
    float4 tangent : TANGENT;
    float3 normal : NORMAL;
};

struct v2f
{
    float2 uv : TEXCOORD0;          
    float4 vertex : SV_POSITION;
    float3 lightDir : TEXCOORD1;
    float3 viewDir : TEXCOORD2; 
};
  • 语义TANGENT指定的切线是一个 float4 类型的变量,而语义NORMAL指定的法线是 float3 类型,因为 TANGENT 的z分量需要用来确定 副法线 的方向,下一个段落会介绍如何计算副法线
  • 因为使用了顶点切线空间下的法线贴图,我们需要把所有的光照计算都变换到顶点切线空间下,在顶点着色器中将光线方向lightDir和视线方向viewDir变换到顶点切线空间,再输入到片元着色器中。
  • 因为我们这里没有涉及到纹理的 ST 变化,所以 _MainTex 和 _BumpTex 功用纹理坐标
  • v2f 中并没有定义法线,因为我们这里使用的是发现贴图中的法线,而不直接使用顶点法线了

2.3 顶点着色器

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    // 模型空间副法线
    fixed3 binormal = cross(normalize(v.normal), normalize(v.tangent.xyz)) * v.tangent.w;
    // 模型空间到顶点切线空间的变换矩阵
    float3x3 rotation = float3x3(v.tangent.xyz, binormal, v.normal);

    // 光线方向和视线防线变换到顶点切线空间
    float3 lightDir = ObjSpaceLightDir(v.vertex);
    float3 viewDir = ObjSpaceViewDir(v.vertex);
    o.lightDir = mul(rotation, lightDir);
    o.viewDir = mul(rotation, viewDir);
                
    o.uv = v.uv;                                
    return o;
}
  • 顶点的法线:顶点所在的所有平面的法线加权平均,得到顶点法线
  • 顶点的切线:我们都知道顶点切线与顶点法线垂直、但与顶点法线垂直的方向有很多?哪一条是顶点切线呢?约定俗成 切线最终规定为顶点 uv 坐标中的 u 方向,可以参考文末的参考文章1。
  • 顶点的副法线:由法线和切线叉乘得到,方向性由顶点切线的z分量确定。
  • 如何计算模型空间到顶点切线空间的变换矩阵:参考我的推导过程模型空间到顶点切线空间变换矩阵的推导。结论就是:将模型空间下的切线、副法线、法线按行排列得到变换矩阵。
  • 在顶点着色器中将光线方向和视线方向变换到顶点的切线空间并传递给片元着色器。

2.4 片元着色器

fixed4 frag (v2f i) : SV_Target
{               
    float3 lightDir = normalize(i.lightDir);
    float3 viewDir = normalize(i.viewDir);
    float3 halfDir = normalize(lightDir + viewDir);             

    float4 packedNormal = tex2D(_BumpTex, i.uv);

    float3 tangentNormal = UnpackNormal(packedNormal);
    tangentNormal.xy *= _BumpScale;
    tangentNormal.z = sqrt(1.0 - saturate(dot(tangentNormal.xy, tangentNormal.xy)));

    fixed3 albedo = tex2D(_MainTex, i.uv).rgb;
    fixed3 diffuse = _LightColor0.rgb * albedo.rgb * saturate(dot(tangentNormal, lightDir));

    fixed3 ambient = UNITY_LIGHTMODEL_AMBIENT.rgb * albedo;

    fixed3 specular = _SpecularColor * _LightColor0 * pow(saturate(dot(halfDir, tangentNormal)), _Gloss);
    return fixed4(diffuse + ambient + specular, 1.0);
}
  • 如何从法线贴图中得到法线:tex2D采样 _BumpTex 得到该点的法线像素值,需要计算出对应的xyz值,因我们已经在 Unity 编辑器中将 _BumpTex 设置为 "Normal Map" ,所以内置方法 UnpackNormal 已经执行了这个计算
  • albedo,diffuse,ambient,specular 的计算不用多说了
  • _BumpScale 的作用:用来控制“凹凸程度”,当 _BumpScale 为0时,表示该点的顶点法线和法线贴图中采样出的法线重合,说明该点没有“凹凸”,_BumpScale 绝对值越大,表示该点的顶点法线和贴图中的法线偏差越远,说明“凹凸感”越明显。
    下面5个胶囊体的 _BumpScale 取值分别为 2/1/0/-1/-2
    不同的_BumpScale凹凸效果

3. Unity中的法线贴图类型设置

在上面的片元着色器中,我们从法线贴图中采样出纹素后,使用了 Unity 内置函数 UnpackNormal 来计算最终的法线值。只有正确的设置图片的类型为 "Normal Map" 时,使用这个内置函数才能得到正确结果,在 Unity 中的设置面板如下:

法线贴图设置

  • Create from Grayscale 表示是否“高度图”生成的纹理贴图。当我们在贴图中记录的是相对高度(黑色表示更低,白色表示更高)时,除了要设置类型为“Normal Map”之外,还要勾选这个选项,这个贴图就会被当成纹理贴图使用了。
  • 勾选了 Create from Grayscale 之后,有两个选项:bumpness表示凹凸程度,filtering 决定了如何生成纹理贴图,smooth 表示生成的法线过渡比较平滑,而sharp 则表示法线过渡比较锋利。

参考文章:
1. 关于顶点法线、切线和副法线
2. 模型空间到顶点切线空间变换矩阵的推导

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,817评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,329评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,354评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,498评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,600评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,829评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,979评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,722评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,189评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,519评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,654评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,329评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,940评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,762评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,993评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,382评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,543评论 2 349

推荐阅读更多精彩内容